Add sssf skill, installable via the skills CLI

Port the sssf skill from ~/.agents/skills/sssf into this repo so it can be
distributed and installed with the skills CLI (skills add INDigitalStudio/skills
--skill sssf).

- Copy the skill (SKILL.md, cookbooks, references, scripts, templates, and the
  visualizer app source) into sssf/.
- Gitignore build/runtime artifacts: the visualizer's node_modules/ and dist/,
  Python bytecode, and the machine-specific repos.json.
- Make the skill location-independent: install.py now stamps the skill's real
  path into the stamped justfile's skill_dir (replacing the hardcoded
  ~/.agents/skills/sssf), so 'just obs' finds the visualizer wherever the CLI
  installed the skill.
- Update cookbooks to use <skill>/scripts/... instead of the hardcoded path,
  and document the skills CLI install command.
- Update the repo README with install instructions.
This commit is contained in:
INDigitalStudio 2026-08-09 21:00:28 +00:00
parent a42608f602
commit 2cc766aabe
98 changed files with 12508 additions and 0 deletions

View file

@ -0,0 +1,261 @@
/**
* SSSF visualizer server — JSON API over one or more repos' sssf.db, plus the
* built UI when ./dist exists. Reads are read-only; the single write is
* POST /api/sessions/:adw_id/archive, which sets one review flag on a row.
*
* There is no ingest endpoint and no websocket. The data path is
* agents → sqlite → web ui, and the UI gets there by polling.
*
* Single repo (legacy):
* bun run server/index.ts
* bun run server/index.ts --db /path/to/repo/adws/adw_data/sssf.db
* SSSF_DB=/path/to/sssf.db PORT=4600 bun run server/index.ts
*
* Multi repo:
* SSSF_REPOS=/path/to/repos.json PORT=4600 bun run server/index.ts
*
* In multi-repo mode every route is prefixed with the repo slug:
* /api/repos
* /api/:repo/sessions
* /api/:repo/sessions/:adw_id
* ...
* The legacy unprefixed routes (/api/sessions, ...) are kept and resolve to
* the first repo, so a single-repo deployment and the SPA's default view
* keep working unchanged.
*/
import { existsSync, statSync } from "node:fs";
import { join, resolve, sep } from "node:path";
import { buildRepos, type RepoRuntime } from "./repos.ts";
import type { AgentPrompts, ApiError, HealthResponse } from "../shared/types.ts";
import type { Server } from "bun";
const PORT = Number(process.env.PORT ?? 4600);
const DIST_DIR = resolve(import.meta.dir, "..", "dist");
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
},
});
}
function notFound(message: string): Response {
return json({ error: message } satisfies ApiError, 404);
}
/** Guard every handler so a malformed query can't take the server down mid-run. */
function safely(
handler: (req: Request) => Response | Promise<Response>,
): (req: Request) => Promise<Response> {
return async (req) => {
try {
return await handler(req);
} catch (error) {
console.error(`[sssf] ${req.method} ${new URL(req.url).pathname}:`, error);
return json({ error: (error as Error).message } satisfies ApiError, 500);
}
};
}
/**
* adw_ids and agent names are path segments on disk, so anything that isn't a
* plain identifier is rejected outright rather than sanitized into something
* that might still escape the sessions directory.
*/
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
function isSafeSegment(value: string): boolean {
return SAFE_SEGMENT.test(value) && value !== "." && value !== "..";
}
function param(req: Request, key: string): string {
return decodeURIComponent(
(req as Request & { params: Record<string, string> }).params[key] ?? "",
);
}
function intQuery(req: Request, key: string, fallback: number): number {
const raw = new URL(req.url).searchParams.get(key);
if (raw === null || raw.trim() === "") return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
/** Serve the built SPA if it has been built; otherwise point at the dev server. */
async function serveStatic(req: Request): Promise<Response> {
const { pathname } = new URL(req.url);
if (!existsSync(DIST_DIR)) {
return new Response(
`SSSF visualizer API is running on :${PORT}.\n\n` +
`No ./dist build found. Run "bun run dev" for the Vite dev server ` +
`(it proxies /api here), or "bun run build" to serve the UI from this process.\n`,
{ status: 200, headers: { "content-type": "text/plain; charset=utf-8" } },
);
}
// Reject traversal before touching the filesystem.
const candidate = resolve(join(DIST_DIR, pathname));
if (candidate === DIST_DIR || candidate.startsWith(DIST_DIR + "/")) {
if (existsSync(candidate) && statSync(candidate).isFile()) {
return new Response(Bun.file(candidate));
}
}
// SPA fallback: breadcrumb routes are client-side.
const indexHtml = join(DIST_DIR, "index.html");
if (existsSync(indexHtml)) {
return new Response(Bun.file(indexHtml), {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
return notFound("not found");
}
/** Build the route table for one repo, mounted at /api/:repo and /api (default). */
function repoRoutes(repo: RepoRuntime, prefix: string) {
const db = repo.db;
const p = (path: string) => `${prefix}${path}`;
return {
[p("/health")]: safely(
() =>
json({
ok: true,
repo: repo.slug,
db: db.path,
journal_mode: db.journalMode,
sessions: db.sessionCount(),
} satisfies HealthResponse),
),
[p("/sessions")]: safely((req) => json(db.sessions(intQuery(req, "limit", 200)))),
[p("/sessions/:adw_id")]: safely((req) => {
const detail = db.sessionDetail(param(req, "adw_id"));
return detail ? json(detail) : notFound(`no session ${param(req, "adw_id")}`);
}),
// The one write. Archiving is review triage — it belongs to the reader, not
// to the run — so it never touches anything a tracer wrote.
[p("/sessions/:adw_id/archive")]: {
POST: safely(async (req) => {
const adwId = param(req, "adw_id");
if (!isSafeSegment(adwId)) {
return json({ error: "invalid adw_id" } satisfies ApiError, 400);
}
const body = (await req.json().catch(() => ({}))) as { archived?: unknown };
const archived = body.archived === undefined ? true : Boolean(body.archived);
return db.setArchived(adwId, archived)
? json({ adw_id: adwId, archived })
: notFound(`no session ${adwId}`);
}),
},
[p("/sessions/:adw_id/events")]: safely((req) =>
json(
db.events(
param(req, "adw_id"),
intQuery(req, "after", 0),
intQuery(req, "limit", 500),
),
),
),
[p("/sessions/:adw_id/envelopes")]: safely((req) =>
json(db.envelopes(param(req, "adw_id"))),
),
[p("/sessions/:adw_id/gates")]: safely((req) => json(db.gates(param(req, "adw_id")))),
// The exact prompts an agent was sent, read from the session dir. Files are
// the raw record; the db has no copy of them.
[p("/sessions/:adw_id/agents/:agent/prompts")]: safely(async (req) => {
const adwId = param(req, "adw_id");
const agent = param(req, "agent");
if (!isSafeSegment(adwId) || !isSafeSegment(agent)) {
return json({ error: "invalid adw_id or agent" } satisfies ApiError, 400);
}
if (!db.session(adwId)) return notFound(`no session ${adwId}`);
const dir = resolve(db.sessionsDir, adwId, agent, "prompts");
// Defense in depth: the segment check already forbids traversal.
if (dir !== db.sessionsDir && !dir.startsWith(db.sessionsDir + sep)) {
return json({ error: "invalid path" } satisfies ApiError, 400);
}
// A prompt file is absent whenever the agent never ran in this session —
// a normal state, so it reads as null rather than an error.
const read = async (name: string): Promise<string | null> => {
const file = Bun.file(join(dir, `${name}.md`));
return (await file.exists()) ? await file.text() : null;
};
return json({
system: await read("system"),
user: await read("user"),
} satisfies AgentPrompts);
}),
};
}
// The registry is mutable: POST /api/reload re-reads repos.json and swaps the
// route table in place (Bun's server.reload), so new repos appear without a
// process restart. `server` is assigned below; the reload handler only runs on
// a request, by which point it is set.
let repos: RepoRuntime[] = (await buildRepos()).repos;
let server: Server<undefined>;
/** Build the route table for the current registry. */
function buildRoutes(repos: RepoRuntime[]): Record<string, unknown> {
const routes: Record<string, unknown> = {
"/api/repos": safely(() =>
json(repos.map((r) => ({ slug: r.slug, name: r.name, db: r.db.path }))),
),
// Re-read repos.json and swap the registry + routes in place. The UI calls
// this after a repo is added/removed, so it shows up without a restart.
"/api/reload": {
POST: safely(async () => {
const next = (await buildRepos()).repos;
repos = next;
server.reload({ routes: buildRoutes(next) as never });
return json({
ok: true,
repos: next.map((r) => ({ slug: r.slug, name: r.name, db: r.db.path })),
});
}),
},
};
for (const repo of repos) {
Object.assign(routes, repoRoutes(repo, `/api/${repo.slug}`));
}
Object.assign(routes, repoRoutes(repos[0], "/api"));
return routes;
}
const routes = buildRoutes(repos);
server = Bun.serve({
port: PORT,
routes: routes as never,
fetch(req) {
const { pathname } = new URL(req.url);
if (pathname.startsWith("/api/")) return notFound(`no route ${pathname}`);
return serveStatic(req);
},
});
console.log(`[sssf] visualizer api http://localhost:${server.port}`);
for (const repo of repos) {
console.log(`[sssf] repo ${repo.slug} ${repo.db.path} [journal_mode=${repo.db.journalMode}]`);
}
console.log(
existsSync(DIST_DIR)
? `[sssf] serving ui from ${DIST_DIR}`
: `[sssf] no ./dist — use "bun run dev" for the Vite dev server on :4601`,
);
process.on("SIGINT", () => {
server.stop();
process.exit(0);
});