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:
parent
a42608f602
commit
2cc766aabe
98 changed files with 12508 additions and 0 deletions
261
sssf/apps/visualizer/server/index.ts
Normal file
261
sssf/apps/visualizer/server/index.ts
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue