@netnodeag/kraftwerk 0.4.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +192 -6
  2. package/dist/cli/doctor.js +77 -2
  3. package/dist/cli/init.js +65 -15
  4. package/dist/cli/knowledge.d.ts +2 -0
  5. package/dist/cli/knowledge.js +213 -0
  6. package/dist/cli/kraftwerk.js +12 -10
  7. package/dist/cli/runs.js +13 -6
  8. package/dist/cli/ui.js +3 -4
  9. package/dist/config.d.ts +9 -0
  10. package/dist/config.js +5 -2
  11. package/dist/gates.d.ts +7 -0
  12. package/dist/gates.js +27 -0
  13. package/dist/harnesses/claude.js +0 -1
  14. package/dist/inspector/chat/acp.d.ts +2 -2
  15. package/dist/inspector/chat/acp.js +44 -3
  16. package/dist/inspector/chat/backend.d.ts +14 -0
  17. package/dist/inspector/chat/pi.d.ts +2 -0
  18. package/dist/inspector/chat/pi.js +106 -0
  19. package/dist/inspector/chat/sessions.d.ts +33 -0
  20. package/dist/inspector/chat/sessions.js +407 -0
  21. package/dist/inspector/chat/store.d.ts +8 -0
  22. package/dist/inspector/chat/store.js +69 -0
  23. package/dist/inspector/chat/types.d.ts +7 -0
  24. package/dist/inspector/context.d.ts +7 -2
  25. package/dist/inspector/context.js +11 -3
  26. package/dist/inspector/knowledge.d.ts +24 -0
  27. package/dist/inspector/knowledge.js +41 -0
  28. package/dist/inspector/routines.d.ts +56 -0
  29. package/dist/inspector/routines.js +266 -0
  30. package/dist/inspector/runner.d.ts +10 -0
  31. package/dist/inspector/runner.js +6 -8
  32. package/dist/inspector/runs.d.ts +2 -0
  33. package/dist/inspector/runs.js +17 -12
  34. package/dist/inspector/server.d.ts +2 -0
  35. package/dist/inspector/server.js +311 -1
  36. package/dist/inspector/skills.d.ts +26 -0
  37. package/dist/inspector/skills.js +65 -0
  38. package/dist/inspector/team.d.ts +57 -0
  39. package/dist/inspector/team.js +121 -0
  40. package/dist/okf.d.ts +118 -0
  41. package/dist/okf.js +398 -0
  42. package/dist/runner/docker.d.ts +3 -1
  43. package/dist/runner/docker.js +5 -5
  44. package/dist/workflow.d.ts +7 -0
  45. package/dist/workflow.js +19 -0
  46. package/dist/yaml.js +43 -10
  47. package/inspector/dist/assets/index-C8AKqc_i.css +1 -0
  48. package/inspector/dist/assets/index-G3eFm1X4.js +13 -0
  49. package/inspector/dist/assets/roboto-latin-400-normal-BqEyEoaF.woff2 +0 -0
  50. package/inspector/dist/assets/roboto-latin-400-normal-D27F5sTu.woff +0 -0
  51. package/inspector/dist/assets/roboto-latin-500-normal-7RbcRiD8.woff2 +0 -0
  52. package/inspector/dist/assets/roboto-latin-500-normal-fsTFPL3E.woff +0 -0
  53. package/inspector/dist/assets/roboto-latin-700-normal-BZpUvMxY.woff2 +0 -0
  54. package/inspector/dist/assets/roboto-latin-700-normal-YuyVweIx.woff +0 -0
  55. package/inspector/dist/assets/roboto-mono-latin-400-normal-DBZPkcnn.woff +0 -0
  56. package/inspector/dist/assets/roboto-mono-latin-400-normal-GekRknry.woff2 +0 -0
  57. package/inspector/dist/assets/roboto-mono-latin-500-normal-D6j6AMzd.woff +0 -0
  58. package/inspector/dist/assets/roboto-mono-latin-500-normal-pxIOXZmA.woff2 +0 -0
  59. package/inspector/dist/assets/roboto-mono-latin-700-normal-CHYrj1DZ.woff +0 -0
  60. package/inspector/dist/assets/roboto-mono-latin-700-normal-foxDnrFi.woff2 +0 -0
  61. package/inspector/dist/index.html +2 -2
  62. package/package.json +1 -1
  63. package/schema/workflow.schema.json +166 -31
  64. package/inspector/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  65. package/inspector/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  66. package/inspector/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  67. package/inspector/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  68. package/inspector/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  69. package/inspector/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  70. package/inspector/dist/assets/ibm-plex-mono-latin-700-normal-7sUh57Bg.woff2 +0 -0
  71. package/inspector/dist/assets/ibm-plex-mono-latin-700-normal-CNHXzs6v.woff +0 -0
  72. package/inspector/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  73. package/inspector/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  74. package/inspector/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  75. package/inspector/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  76. package/inspector/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  77. package/inspector/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  78. package/inspector/dist/assets/ibm-plex-sans-latin-700-normal-Bth3BMcD.woff +0 -0
  79. package/inspector/dist/assets/ibm-plex-sans-latin-700-normal-Bxkt5Cjx.woff2 +0 -0
  80. package/inspector/dist/assets/index-BCSWkslF.css +0 -1
  81. package/inspector/dist/assets/index-D6WuziFQ.js +0 -9
@@ -0,0 +1,407 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import { getOutputDir, getProjectRoot } from "../context.js";
4
+ import { knowledgeIndex } from "../knowledge.js";
5
+ import { getRun, listRuns, safeRunDir } from "../runs.js";
6
+ import { listSkills, readSkill } from "../skills.js";
7
+ import { listWorkflows } from "../workflows.js";
8
+ import { getMember } from "../team.js";
9
+ import { startAcpBackend } from "./acp.js";
10
+ import { startPiBackend } from "./pi.js";
11
+ import { appendEvent, listChatMetas, newChatId, readEvents, readMeta, safeChatDir, writeMeta, } from "./store.js";
12
+ const states = new Map();
13
+ async function loadState(id) {
14
+ const existing = states.get(id);
15
+ if (existing)
16
+ return existing;
17
+ const meta = await readMeta(id);
18
+ if (!meta)
19
+ return null;
20
+ const state = {
21
+ meta,
22
+ events: await readEvents(id),
23
+ subscribers: new Set(),
24
+ backend: null,
25
+ busy: false,
26
+ pendingPermissions: new Map(),
27
+ writeChain: Promise.resolve(),
28
+ };
29
+ // Two racing loads: keep whichever registered first.
30
+ return states.get(id) ?? (states.set(id, state), state);
31
+ }
32
+ function emit(state, ev) {
33
+ const stored = {
34
+ ...ev,
35
+ seq: (state.events[state.events.length - 1]?.seq ?? 0) + 1,
36
+ ts: new Date().toISOString(),
37
+ };
38
+ state.events.push(stored);
39
+ state.meta.updatedAt = stored.ts;
40
+ state.writeChain = state.writeChain
41
+ .then(() => appendEvent(state.meta.id, stored))
42
+ .catch(() => { });
43
+ for (const fn of state.subscribers)
44
+ fn(stored);
45
+ return stored;
46
+ }
47
+ /* ---------- skills ---------- */
48
+ /**
49
+ * Skills visible to a chat: every discovered skill (project + user
50
+ * .claude/skills), narrowed by the team member's allowlist when the chat
51
+ * is a team session (absent allowlist = all, empty = none).
52
+ */
53
+ async function availableSkills(scope) {
54
+ const all = await listSkills().catch(() => []);
55
+ if (scope.kind !== "team" || all.length === 0)
56
+ return all;
57
+ const member = await getMember(scope.member).catch(() => null);
58
+ if (!member || member.skills === undefined)
59
+ return all;
60
+ const allowed = new Set(member.skills.map((n) => n.toLowerCase()));
61
+ return all.filter((s) => allowed.has(s.name.toLowerCase()));
62
+ }
63
+ /** "## Your skills" context block (empty when no skills are visible). */
64
+ function skillsBlock(skills) {
65
+ if (skills.length === 0)
66
+ return "";
67
+ const lines = skills
68
+ .map((s) => `- /${s.name}${s.description ? `: ${s.description}` : ""} — ${s.path}`)
69
+ .join("\n");
70
+ return (`## Your skills\nSkills are reusable instruction packages (a SKILL.md per skill). When the ` +
71
+ `user's request matches one, read its SKILL.md file and follow the instructions in it. The user ` +
72
+ `can also invoke one explicitly by starting a message with /<skill-name>.\n${lines}`);
73
+ }
74
+ /**
75
+ * Explicit skill invocation: a message starting with /<skill-name> is
76
+ * expanded into the skill's SKILL.md instructions (the stored user_message
77
+ * keeps the original "/name args" text). Unknown names pass through as-is.
78
+ */
79
+ async function expandSkillInvocation(scope, text) {
80
+ const m = /^\/([A-Za-z0-9][\w-]*)[ \t]*([\s\S]*)$/.exec(text);
81
+ if (!m)
82
+ return text;
83
+ const skills = await availableSkills(scope);
84
+ const skill = skills.find((s) => s.name.toLowerCase() === m[1].toLowerCase());
85
+ if (!skill)
86
+ return text;
87
+ const body = await readSkill(skill);
88
+ if (!body.trim())
89
+ return text;
90
+ const args = m[2].trim();
91
+ return (`The user invoked the skill "${skill.name}" (${skill.path}). Follow the skill's instructions:\n\n` +
92
+ `<skill name="${skill.name}">\n${body.trim()}\n</skill>\n\n` +
93
+ `Arguments the user passed to the skill: ${args || "(none)"}`);
94
+ }
95
+ /* ---------- scope context ---------- */
96
+ /** "## Your knowledge" block for a team member's connected OKF bundles. */
97
+ async function teamKnowledgeContext(member) {
98
+ if (member.knowledge.length === 0)
99
+ return "";
100
+ const { bundles } = await knowledgeIndex().catch(() => ({ bundles: [] }));
101
+ const lines = member.knowledge
102
+ .map((name) => {
103
+ const b = bundles.find((x) => x.name === name);
104
+ return b
105
+ ? `- ${b.name} (${b.concepts} concepts${b.updatedAt ? `, updated ${b.updatedAt.slice(0, 10)}` : ""})`
106
+ : `- ${name} (not found in knowledge/ — tell the user if you need it)`;
107
+ })
108
+ .join("\n");
109
+ return (`## Your knowledge\nThese OKF knowledge bundles (markdown + YAML frontmatter under knowledge/) are ` +
110
+ `part of your job. Consult them before answering questions in their domain, and keep them current ` +
111
+ `when you learn something durable:\n${lines}\n\n` +
112
+ `Read with \`npx kraftwerk knowledge list <bundle>\`, \`get <bundle>/<path>\`, \`search <text>\`. ` +
113
+ `Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file <tmp.md> --actor ${member.slug}/${member.harness}\` ` +
114
+ `(write the markdown to a temp file first; the CLI stamps provenance and maintains index.md/log.md). ` +
115
+ `Never hand-edit index.md or log.md, and never add \`verified\` yourself — verification is the human's ` +
116
+ `click in the UI.\n\n`);
117
+ }
118
+ /** Base context per scope; scopeContext() appends the shared skills block. */
119
+ async function baseScopeContext(scope, agent) {
120
+ if (scope.kind === "team") {
121
+ const member = await getMember(scope.member).catch(() => null);
122
+ if (!member) {
123
+ return `You were opened as team agent "${scope.member}", but its definition under agents/ is missing. Tell the user and ask them to recreate it.`;
124
+ }
125
+ const { workflows } = await listWorkflows().catch(() => ({ workflows: [] }));
126
+ const connected = workflows.filter((w) => member.workflows.includes(w.slug));
127
+ const missing = member.workflows.filter((slug) => !connected.some((w) => w.slug === slug));
128
+ const wfLines = connected
129
+ .map((w) => `- ${w.slug}: ${w.description ?? w.name ?? ""} (${w.steps} steps)`)
130
+ .join("\n");
131
+ return (`You are ${member.emoji} ${member.name}, a persistent agent teammate on this project's team ` +
132
+ `(defined in agents/${member.slug}/). The user works with you like with a colleague: every session ` +
133
+ `is a conversation with the same ${member.name}, so keep this role consistently. You are an AI agent, ` +
134
+ `not a human — never pretend otherwise, but do own your role.\n\n` +
135
+ `## Your role\n${member.system || "(no system prompt written yet — ask the user what your job should be)"}\n\n` +
136
+ (member.workflows.length > 0
137
+ ? `## Your workflows\nThese kraftwerk workflows are part of your job. When the user's request matches one, ` +
138
+ `run it yourself instead of doing the work by hand:\n${wfLines || "(none found)"}\n` +
139
+ (missing.length > 0
140
+ ? `(configured but not found in this project: ${missing.join(", ")})\n`
141
+ : "") +
142
+ `\nRun a workflow with:\n` +
143
+ ` KRAFTWERK_YES=1 npx kraftwerk run <workflow> "<request>"\n` +
144
+ `The command blocks until the run finishes; artifacts land in output/runs/*/ — tell the user the ` +
145
+ `run id and summarize the result. Confirm with the user before starting a long or expensive run ` +
146
+ `unless they clearly asked for it.\n\n`
147
+ : "") +
148
+ (await teamKnowledgeContext(member)) +
149
+ `The working directory is the project root. Stay within your role; if a request is clearly outside it, ` +
150
+ `say so and suggest which teammate or tool fits better.` +
151
+ (scope.routine
152
+ ? `\n\nThis session was started automatically by your scheduled routine "${scope.routine}" — ` +
153
+ `nobody may be watching live. Complete the task autonomously (tool permissions are ` +
154
+ `auto-approved) and end with a clear, self-contained summary of what you did and found.`
155
+ : ""));
156
+ }
157
+ if (scope.kind === "knowledge") {
158
+ const { root, bundles } = await knowledgeIndex().catch(() => ({ root: "", bundles: [] }));
159
+ const bundleLines = bundles
160
+ .map((b) => `- ${b.name} (${b.concepts} concepts${b.updatedAt ? `, updated ${b.updatedAt.slice(0, 10)}` : ""})`)
161
+ .join("\n");
162
+ return (`You are the knowledge curator inside the kraftwerk inspector ("Context & Knowledge"). ` +
163
+ `This project keeps knowledge as OKF v0.2 bundles (Open Knowledge Format): directories of markdown ` +
164
+ `files with YAML frontmatter under ${root || "knowledge/"}. Each direct subdirectory is one bundle; each .md file ` +
165
+ `(except the reserved index.md and log.md) is one concept.\n\n` +
166
+ `Existing bundles:\n${bundleLines || "(none yet)"}\n` +
167
+ (scope.bundle ? `\nThe user wants to work on the "${scope.bundle}" bundle.\n` : "") +
168
+ `\nOKF essentials:\n` +
169
+ `- Frontmatter needs exactly one required key: \`type\` (free-form, e.g. Playbook, Metric, Reference, API Endpoint). ` +
170
+ `Recommended: title, description, tags, resource (canonical URI of the described asset).\n` +
171
+ `- Provenance/trust families (all optional): \`sources\` (list of { id, resource, title, author, usage_count, last_modified }), ` +
172
+ `\`generated: { by, at }\`, \`verified: [{ by, at }]\`, \`status\` (draft|stable|deprecated), \`stale_after\` (ISO datetime).\n` +
173
+ `- Actors: \`<producer>/<version>\` for agents, \`human:<id>\` for people, \`process:<id>\` for automation.\n` +
174
+ `- Concepts cross-link with normal markdown links, bundle-absolute (\`/path/concept.md\`) preferred. ` +
175
+ `Per-claim attribution uses markdown footnotes whose label is a \`sources[].id\`.\n` +
176
+ `- Favor structural markdown (headings, tables, lists) over prose.\n\n` +
177
+ `ALWAYS write through the kraftwerk CLI so provenance is stamped and the bundle log/index stay maintained:\n` +
178
+ `- \`npx kraftwerk knowledge init <bundle>\` — new bundle\n` +
179
+ `- \`npx kraftwerk knowledge put <bundle>/<path> --file <tmp.md> --actor kraftwerk-chat/${agent}\` — create/update a concept ` +
180
+ `(write the markdown to a temp file first; the CLI stamps generated.by/at, appends log.md, regenerates index.md)\n` +
181
+ `- \`npx kraftwerk knowledge list [bundle]\`, \`get <bundle>/<path>\`, \`search <text>\`, \`validate\`\n` +
182
+ `Do not hand-edit index.md or log.md (derived/maintained), and do not add \`verified\` yourself — ` +
183
+ `verification is the human's click in the UI. Ask the user what knowledge to capture, then author concise, well-typed concepts.`);
184
+ }
185
+ if (scope.kind === "kraftwerk") {
186
+ const [{ workflows }, runs] = await Promise.all([listWorkflows(), listRuns()]);
187
+ const wfLines = workflows
188
+ .map((w) => `- ${w.name ?? w.slug}: ${w.description ?? ""} (${w.steps} steps, ${w.agents} agents)`)
189
+ .join("\n");
190
+ const runLines = runs
191
+ .slice(0, 10)
192
+ .map((r) => `- ${r.id} — ${r.workflow ?? "?"} [${r.status}] ${r.request ?? ""}`.trim())
193
+ .join("\n");
194
+ return (`You are the assistant inside the kraftwerk inspector, a UI for a workflow-as-code agent framework. ` +
195
+ `The consumer project root is ${getProjectRoot()}; run outputs live in ${getOutputDir()} (one folder per run under runs/, each with a trace.jsonl and working files).\n\n` +
196
+ `Workflows in this project:\n${wfLines || "(none)"}\n\nRecent runs:\n${runLines || "(none)"}\n\n` +
197
+ `Answer questions about workflows and runs by reading these files. Do not modify run outputs unless asked.`);
198
+ }
199
+ if (scope.kind === "run") {
200
+ const run = await getRun(scope.runId);
201
+ if (!run)
202
+ return `The user wants to discuss kraftwerk run ${scope.runId}, but it was not found.`;
203
+ const phases = run.phases
204
+ .map((p) => `- ${p.phase} [${p.status}]${p.summary ? `: ${p.summary}` : ""}`)
205
+ .join("\n");
206
+ const files = run.files.map((f) => `- ${f.name} (${f.size} bytes)`).join("\n");
207
+ return (`You are the assistant for one kraftwerk workflow run. Your working directory is the run's output folder.\n\n` +
208
+ `Run ${run.id} — workflow "${run.workflow ?? "?"}", status ${run.status}.\n` +
209
+ `Request: ${run.request ?? "(none)"}\n\nPhases:\n${phases || "(none)"}\n\nFiles in this folder:\n${files || "(none)"}\n\n` +
210
+ `trace.jsonl holds the full event log. Read files as needed to answer; do not modify the run's artifacts unless asked.`);
211
+ }
212
+ return "";
213
+ }
214
+ async function scopeContext(scope, agent) {
215
+ const [base, skills] = await Promise.all([
216
+ baseScopeContext(scope, agent),
217
+ availableSkills(scope),
218
+ ]);
219
+ const block = skillsBlock(skills);
220
+ if (!block)
221
+ return base;
222
+ return base ? `${base}\n\n${block}` : block;
223
+ }
224
+ /* ---------- backend lifecycle ---------- */
225
+ /** Team chats carry the member's model/effort; resolved live so edits apply to new backends. */
226
+ async function backendTuning(scope, agent) {
227
+ const tuning = {};
228
+ if (scope.kind === "team") {
229
+ const member = await getMember(scope.member).catch(() => null);
230
+ if (member?.model)
231
+ tuning.model = member.model;
232
+ if (member?.effort)
233
+ tuning.effort = member.effort;
234
+ // Claude discovers skills natively; a defined allowlist narrows that.
235
+ if (agent === "claude" && member?.skills)
236
+ tuning.skills = member.skills;
237
+ }
238
+ // Run chats live in the run folder — grant claude the project root so
239
+ // project-level skills and files stay reachable.
240
+ if (scope.kind === "run" && agent === "claude")
241
+ tuning.addDirs = [getProjectRoot()];
242
+ // pi loads skills only when told: hand it every visible skill folder.
243
+ if (agent === "pi") {
244
+ const skills = await availableSkills(scope);
245
+ if (skills.length > 0)
246
+ tuning.skillDirs = skills.map((s) => s.dir);
247
+ }
248
+ return tuning;
249
+ }
250
+ async function ensureBackend(state) {
251
+ if (state.backend)
252
+ return state.backend;
253
+ const hooks = {
254
+ emit: (ev) => void emit(state, ev),
255
+ askPermission: (title, options) => {
256
+ // Routine-triggered sessions run unattended: auto-approve, but keep
257
+ // the request/resolution pair in the thread as an audit trail.
258
+ const scope = state.meta.scope;
259
+ if (scope.kind === "team" && scope.routine) {
260
+ const allow = options.find((o) => o.kind?.startsWith("allow")) ?? options[0];
261
+ const requestId = randomUUID();
262
+ emit(state, { type: "permission_request", requestId, title, options });
263
+ emit(state, { type: "permission_resolved", requestId, optionId: allow?.optionId ?? null });
264
+ return Promise.resolve(allow?.optionId ?? null);
265
+ }
266
+ return new Promise((resolve) => {
267
+ const requestId = randomUUID();
268
+ state.pendingPermissions.set(requestId, (optionId) => {
269
+ state.pendingPermissions.delete(requestId);
270
+ emit(state, { type: "permission_resolved", requestId, optionId });
271
+ resolve(optionId);
272
+ });
273
+ emit(state, { type: "permission_request", requestId, title, options });
274
+ });
275
+ },
276
+ };
277
+ const { agent, cwd } = state.meta;
278
+ const tuning = await backendTuning(state.meta.scope, agent);
279
+ state.backend =
280
+ agent === "pi"
281
+ ? startPiBackend(cwd, hooks, tuning)
282
+ : await startAcpBackend(agent, cwd, hooks, tuning);
283
+ return state.backend;
284
+ }
285
+ /* ---------- public API (used by server.ts) ---------- */
286
+ export async function createChat(opts) {
287
+ const cwd = opts.scope.kind === "run" ? safeRunDir(opts.scope.runId) : getProjectRoot();
288
+ const now = new Date().toISOString();
289
+ const meta = {
290
+ id: newChatId(),
291
+ agent: opts.agent,
292
+ title: opts.title ?? "",
293
+ cwd,
294
+ scope: opts.scope,
295
+ createdAt: now,
296
+ updatedAt: now,
297
+ };
298
+ await writeMeta(meta);
299
+ states.set(meta.id, {
300
+ meta,
301
+ events: [],
302
+ subscribers: new Set(),
303
+ backend: null,
304
+ busy: false,
305
+ pendingPermissions: new Map(),
306
+ writeChain: Promise.resolve(),
307
+ });
308
+ return meta;
309
+ }
310
+ export async function listChats() {
311
+ const metas = await listChatMetas();
312
+ return metas.map((m) => ({
313
+ ...(states.get(m.id)?.meta ?? m),
314
+ busy: states.get(m.id)?.busy ?? false,
315
+ }));
316
+ }
317
+ export async function getChat(id) {
318
+ const state = await loadState(id);
319
+ if (!state)
320
+ return null;
321
+ return { meta: state.meta, events: state.events, busy: state.busy };
322
+ }
323
+ export async function postMessage(id, text) {
324
+ const state = await loadState(id);
325
+ if (!state)
326
+ return { error: "not found" };
327
+ if (state.busy)
328
+ return { error: "agent is still working — wait for the turn to finish" };
329
+ state.busy = true;
330
+ const isFirst = !state.events.some((e) => e.type === "user_message");
331
+ if (isFirst && !state.meta.title) {
332
+ state.meta.title = text.replace(/\s+/g, " ").trim().slice(0, 80);
333
+ void writeMeta(state.meta).catch(() => { });
334
+ }
335
+ emit(state, { type: "user_message", text });
336
+ // The turn runs in the background; the HTTP request returns immediately
337
+ // and the browser follows along on the SSE stream.
338
+ void (async () => {
339
+ try {
340
+ const backend = await ensureBackend(state);
341
+ const context = isFirst ? await scopeContext(state.meta.scope, state.meta.agent) : "";
342
+ // "/<skill> args" expands to the skill's instructions for the agent;
343
+ // the thread keeps the short form the user typed.
344
+ const body = await expandSkillInvocation(state.meta.scope, text);
345
+ const promptText = context ? `<context>\n${context}\n</context>\n\n${body}` : body;
346
+ const stopReason = await backend.prompt(promptText);
347
+ emit(state, { type: "turn_end", stopReason });
348
+ }
349
+ catch (err) {
350
+ emit(state, { type: "error", message: err.message });
351
+ // A failed turn may mean a dead subprocess; drop it so the next
352
+ // message spawns a fresh agent (thread history stays on disk).
353
+ state.backend?.dispose();
354
+ state.backend = null;
355
+ }
356
+ finally {
357
+ state.busy = false;
358
+ void writeMeta(state.meta).catch(() => { });
359
+ }
360
+ })();
361
+ return {};
362
+ }
363
+ export async function resolvePermission(id, requestId, optionId) {
364
+ const state = await loadState(id);
365
+ if (!state)
366
+ return { error: "not found" };
367
+ const resolve = state.pendingPermissions.get(requestId);
368
+ if (!resolve)
369
+ return { error: "no such pending permission request" };
370
+ resolve(optionId);
371
+ return {};
372
+ }
373
+ /** Delete a chat: kill its backend, drop the live state, remove its folder. */
374
+ export async function deleteChat(id) {
375
+ const state = await loadState(id);
376
+ if (!state)
377
+ return { error: "not found" };
378
+ for (const resolve of state.pendingPermissions.values())
379
+ resolve(null);
380
+ state.backend?.dispose();
381
+ states.delete(id);
382
+ await fs.rm(safeChatDir(id), { recursive: true, force: true });
383
+ return {};
384
+ }
385
+ export async function cancelChat(id) {
386
+ const state = await loadState(id);
387
+ if (!state)
388
+ return { error: "not found" };
389
+ for (const resolve of state.pendingPermissions.values())
390
+ resolve(null);
391
+ state.backend?.cancel();
392
+ return {};
393
+ }
394
+ /**
395
+ * SSE subscription: replays events after `afterSeq`, then streams new ones.
396
+ * Returns an unsubscribe function.
397
+ */
398
+ export async function subscribeChat(id, afterSeq, fn) {
399
+ const state = await loadState(id);
400
+ if (!state)
401
+ return null;
402
+ for (const ev of state.events)
403
+ if (ev.seq > afterSeq)
404
+ fn(ev);
405
+ state.subscribers.add(fn);
406
+ return () => state.subscribers.delete(fn);
407
+ }
@@ -0,0 +1,8 @@
1
+ import type { ChatMeta, StoredChatEvent } from "./types.js";
2
+ export declare function safeChatDir(id: string): string;
3
+ export declare function newChatId(): string;
4
+ export declare function writeMeta(meta: ChatMeta): Promise<void>;
5
+ export declare function readMeta(id: string): Promise<ChatMeta | null>;
6
+ export declare function appendEvent(id: string, ev: StoredChatEvent): Promise<void>;
7
+ export declare function readEvents(id: string): Promise<StoredChatEvent[]>;
8
+ export declare function listChatMetas(): Promise<ChatMeta[]>;
@@ -0,0 +1,69 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { getOutputDir } from "../context.js";
4
+ /**
5
+ * Chats live on disk under <output>/chats/<chat-id>/ as meta.json plus an
6
+ * append-only events.jsonl — inspectable with plain tools and durable
7
+ * across server restarts, like run folders.
8
+ */
9
+ const chatsDir = () => path.join(getOutputDir(), "chats");
10
+ export function safeChatDir(id) {
11
+ if (!/^chat-[0-9a-z-]+$/.test(id))
12
+ throw new Error("invalid chat id");
13
+ return path.join(chatsDir(), id);
14
+ }
15
+ export function newChatId() {
16
+ const d = new Date();
17
+ const p = (n) => String(n).padStart(2, "0");
18
+ const stamp = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` +
19
+ `-${p(d.getHours())}${p(d.getMinutes())}-${p(d.getSeconds())}`;
20
+ return `chat-${stamp}-${Math.random().toString(36).slice(2, 6)}`;
21
+ }
22
+ export async function writeMeta(meta) {
23
+ const dir = safeChatDir(meta.id);
24
+ await fs.mkdir(dir, { recursive: true });
25
+ await fs.writeFile(path.join(dir, "meta.json"), JSON.stringify(meta, null, 2));
26
+ }
27
+ export async function readMeta(id) {
28
+ try {
29
+ return JSON.parse(await fs.readFile(path.join(safeChatDir(id), "meta.json"), "utf8"));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export async function appendEvent(id, ev) {
36
+ await fs.appendFile(path.join(safeChatDir(id), "events.jsonl"), JSON.stringify(ev) + "\n");
37
+ }
38
+ export async function readEvents(id) {
39
+ let raw;
40
+ try {
41
+ raw = await fs.readFile(path.join(safeChatDir(id), "events.jsonl"), "utf8");
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ const events = [];
47
+ for (const line of raw.split("\n")) {
48
+ if (!line.trim())
49
+ continue;
50
+ try {
51
+ events.push(JSON.parse(line));
52
+ }
53
+ catch {
54
+ /* partially written last line */
55
+ }
56
+ }
57
+ return events;
58
+ }
59
+ export async function listChatMetas() {
60
+ let entries;
61
+ try {
62
+ entries = (await fs.readdir(chatsDir())).filter((e) => e.startsWith("chat-"));
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ const metas = await Promise.all(entries.map(readMeta));
68
+ return metas.filter(Boolean).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
69
+ }
@@ -13,6 +13,13 @@ export type ChatScope = {
13
13
  } | {
14
14
  kind: "run";
15
15
  runId: string;
16
+ } | {
17
+ kind: "knowledge";
18
+ bundle?: string;
19
+ } | {
20
+ kind: "team";
21
+ member: string;
22
+ routine?: string;
16
23
  };
17
24
  export interface ChatMeta {
18
25
  id: string;
@@ -1,5 +1,10 @@
1
1
  export declare function setOutputDir(dir: string): void;
2
- /** Absolute output directory (one run-* folder per run). */
2
+ export declare function setProjectRoot(dir: string): void;
3
+ /** Absolute output directory (runs/ + chats/ live inside). */
3
4
  export declare function getOutputDir(): string;
4
- /** The consumer project root is the parent of the output dir. */
5
+ /**
6
+ * The consumer project root. Set explicitly at startup; falls back to the
7
+ * parent of the output dir (wrong for nested output dirs like
8
+ * kraftwerk-data/output — always pass projectRoot when known).
9
+ */
5
10
  export declare function getProjectRoot(): string;
@@ -4,16 +4,24 @@ import path from "node:path";
4
4
  * Set once by startInspector() before any request is served.
5
5
  */
6
6
  let outputDir = "";
7
+ let projectRoot = "";
7
8
  export function setOutputDir(dir) {
8
9
  outputDir = path.resolve(dir);
9
10
  }
10
- /** Absolute output directory (one run-* folder per run). */
11
+ export function setProjectRoot(dir) {
12
+ projectRoot = path.resolve(dir);
13
+ }
14
+ /** Absolute output directory (runs/ + chats/ live inside). */
11
15
  export function getOutputDir() {
12
16
  if (!outputDir)
13
17
  throw new Error("inspector context not initialized");
14
18
  return outputDir;
15
19
  }
16
- /** The consumer project root is the parent of the output dir. */
20
+ /**
21
+ * The consumer project root. Set explicitly at startup; falls back to the
22
+ * parent of the output dir (wrong for nested output dirs like
23
+ * kraftwerk-data/output — always pass projectRoot when known).
24
+ */
17
25
  export function getProjectRoot() {
18
- return path.dirname(getOutputDir());
26
+ return projectRoot || path.dirname(getOutputDir());
19
27
  }
@@ -0,0 +1,24 @@
1
+ import { type BundleInfo, type ConceptDetail, type ConceptInfo } from "../okf.js";
2
+ /**
3
+ * Context & Knowledge for the inspector: a thin API layer over the OKF
4
+ * core (src/okf.ts). Writes from the UI are stamped as `human:user` — the
5
+ * browser user is the human in the loop; agents write through the CLI (or
6
+ * chat) with their own actor.
7
+ */
8
+ export declare const UI_ACTOR = "human:user";
9
+ export interface KnowledgeIndex {
10
+ root: string;
11
+ bundles: BundleInfo[];
12
+ }
13
+ export interface BundleDetail {
14
+ name: string;
15
+ concepts: ConceptInfo[];
16
+ /** Raw log.md, if the bundle keeps one. */
17
+ log?: string;
18
+ }
19
+ export declare function knowledgeIndex(): Promise<KnowledgeIndex>;
20
+ export declare function createBundle(name: string): Promise<BundleInfo>;
21
+ export declare function bundleDetail(name: string): Promise<BundleDetail | null>;
22
+ export declare function conceptDetail(bundle: string, id: string): Promise<ConceptDetail | null>;
23
+ export declare function putConcept(bundle: string, id: string, content: string): Promise<ConceptDetail>;
24
+ export declare function verifyFromUi(bundle: string, id: string): Promise<ConceptDetail>;
@@ -0,0 +1,41 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveProject } from "../config.js";
4
+ import { getConcept, initBundle, knowledgeRoot, listBundles, listConcepts, safeBundleName, verifyConcept, writeConcept, } from "../okf.js";
5
+ import { getProjectRoot } from "./context.js";
6
+ /**
7
+ * Context & Knowledge for the inspector: a thin API layer over the OKF
8
+ * core (src/okf.ts). Writes from the UI are stamped as `human:user` — the
9
+ * browser user is the human in the loop; agents write through the CLI (or
10
+ * chat) with their own actor.
11
+ */
12
+ export const UI_ACTOR = "human:user";
13
+ async function root() {
14
+ const project = await resolveProject(getProjectRoot());
15
+ return knowledgeRoot(project.root, project.config.knowledge);
16
+ }
17
+ export async function knowledgeIndex() {
18
+ const kroot = await root();
19
+ return { root: kroot, bundles: await listBundles(kroot) };
20
+ }
21
+ export async function createBundle(name) {
22
+ await initBundle(await root(), name);
23
+ return { name, concepts: 0 };
24
+ }
25
+ export async function bundleDetail(name) {
26
+ const kroot = await root();
27
+ const dir = path.join(kroot, safeBundleName(name));
28
+ if (!(await fs.stat(dir).catch(() => null)))
29
+ return null;
30
+ const log = await fs.readFile(path.join(dir, "log.md"), "utf8").catch(() => undefined);
31
+ return { name, concepts: await listConcepts(kroot, name), log };
32
+ }
33
+ export async function conceptDetail(bundle, id) {
34
+ return getConcept(await root(), bundle, id);
35
+ }
36
+ export async function putConcept(bundle, id, content) {
37
+ return (await writeConcept(await root(), bundle, id, content, UI_ACTOR)).concept;
38
+ }
39
+ export async function verifyFromUi(bundle, id) {
40
+ return verifyConcept(await root(), bundle, id, UI_ACTOR);
41
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Routines: per-team-member scheduled prompts — "message your agent every
3
+ * weekday at 9". Definitions live next to the member (git-tracked):
4
+ *
5
+ * agents/<slug>/routines.yml # list of { id, name, schedule, prompt, enabled }
6
+ *
7
+ * Runtime state (last run / last session / errors) is run-state, not
8
+ * config, so it lives in <output>/routines-state.json. The scheduler is a
9
+ * plain interval inside the inspector server: each due routine opens a new
10
+ * session (chat) for the member and posts the prompt — the run shows up in
11
+ * the member's sessions sidebar like any conversation. Schedules use
12
+ * standard 5-field cron in the server's local time.
13
+ */
14
+ export interface Routine {
15
+ id: string;
16
+ name: string;
17
+ /** 5-field cron (min hour dom month dow) or @hourly/@daily/@weekly/@monthly. */
18
+ schedule: string;
19
+ prompt: string;
20
+ enabled: boolean;
21
+ }
22
+ export interface RoutineStatus extends Routine {
23
+ nextRunAt?: string;
24
+ lastRunAt?: string;
25
+ lastChatId?: string;
26
+ lastError?: string;
27
+ }
28
+ interface CronSpec {
29
+ minute: Set<number>;
30
+ hour: Set<number>;
31
+ dom: Set<number>;
32
+ month: Set<number>;
33
+ dow: Set<number>;
34
+ domStar: boolean;
35
+ dowStar: boolean;
36
+ }
37
+ export declare function parseCron(expr: string): CronSpec;
38
+ /** Next matching instant after `from`, scanning up to 60 days; undefined if none. */
39
+ export declare function nextRun(schedule: string, from?: Date): string | undefined;
40
+ export declare function listRoutines(slug: string): Promise<Routine[]>;
41
+ export interface SaveRoutineInput {
42
+ id?: string;
43
+ name: string;
44
+ schedule: string;
45
+ prompt: string;
46
+ enabled?: boolean;
47
+ }
48
+ export declare function saveRoutine(slug: string, input: SaveRoutineInput): Promise<Routine>;
49
+ export declare function deleteRoutine(slug: string, id: string): Promise<void>;
50
+ export declare function routineStatuses(slug: string): Promise<RoutineStatus[]>;
51
+ export declare function runRoutineNow(slug: string, id: string): Promise<{
52
+ chatId: string;
53
+ }>;
54
+ /** Start the in-process scheduler; safe to call more than once. */
55
+ export declare function startRoutineScheduler(): void;
56
+ export {};