@netnodeag/kraftwerk 0.4.0 → 0.6.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 (38) hide show
  1. package/README.md +166 -5
  2. package/dist/cli/doctor.js +38 -0
  3. package/dist/cli/knowledge.d.ts +2 -0
  4. package/dist/cli/knowledge.js +213 -0
  5. package/dist/cli/kraftwerk.js +5 -2
  6. package/dist/cli/ui.js +1 -1
  7. package/dist/config.d.ts +6 -0
  8. package/dist/config.js +4 -2
  9. package/dist/gates.d.ts +7 -0
  10. package/dist/gates.js +27 -0
  11. package/dist/inspector/chat/acp.d.ts +2 -0
  12. package/dist/inspector/chat/acp.js +155 -0
  13. package/dist/inspector/chat/backend.d.ts +36 -0
  14. package/dist/inspector/chat/backend.js +1 -0
  15. package/dist/inspector/chat/pi.d.ts +2 -0
  16. package/dist/inspector/chat/pi.js +105 -0
  17. package/dist/inspector/chat/sessions.d.ts +29 -0
  18. package/dist/inspector/chat/sessions.js +316 -0
  19. package/dist/inspector/chat/store.d.ts +8 -0
  20. package/dist/inspector/chat/store.js +69 -0
  21. package/dist/inspector/chat/types.d.ts +77 -0
  22. package/dist/inspector/chat/types.js +8 -0
  23. package/dist/inspector/knowledge.d.ts +24 -0
  24. package/dist/inspector/knowledge.js +41 -0
  25. package/dist/inspector/routines.d.ts +56 -0
  26. package/dist/inspector/routines.js +266 -0
  27. package/dist/inspector/server.js +272 -0
  28. package/dist/inspector/team.d.ts +50 -0
  29. package/dist/inspector/team.js +119 -0
  30. package/dist/okf.d.ts +118 -0
  31. package/dist/okf.js +398 -0
  32. package/dist/yaml.js +41 -8
  33. package/inspector/dist/assets/{index-BCSWkslF.css → index-BtcPa8nx.css} +1 -1
  34. package/inspector/dist/assets/index-CImcmBVs.js +10 -0
  35. package/inspector/dist/index.html +2 -2
  36. package/package.json +4 -1
  37. package/schema/workflow.schema.json +166 -31
  38. package/inspector/dist/assets/index-D6WuziFQ.js +0 -9
@@ -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
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Chat data model shared across the chat backend modules. Every chat is a
3
+ * folder under <output>/chats/ holding meta.json plus events.jsonl — the
4
+ * same files-on-disk philosophy as runs. Events are the single source of
5
+ * truth: the UI replays them to render the thread, and the SSE stream just
6
+ * appends live ones.
7
+ */
8
+ export type ChatAgentId = "claude" | "codex" | "pi";
9
+ export type ChatScope = {
10
+ kind: "general";
11
+ } | {
12
+ kind: "kraftwerk";
13
+ } | {
14
+ kind: "run";
15
+ runId: string;
16
+ } | {
17
+ kind: "knowledge";
18
+ bundle?: string;
19
+ } | {
20
+ kind: "team";
21
+ member: string;
22
+ routine?: string;
23
+ };
24
+ export interface ChatMeta {
25
+ id: string;
26
+ agent: ChatAgentId;
27
+ title: string;
28
+ cwd: string;
29
+ scope: ChatScope;
30
+ createdAt: string;
31
+ updatedAt: string;
32
+ }
33
+ /** One thread event; `seq` orders them and drives SSE resume (?after=seq). */
34
+ export type ChatEvent = {
35
+ type: "user_message";
36
+ text: string;
37
+ } | {
38
+ type: "text";
39
+ text: string;
40
+ } | {
41
+ type: "thought";
42
+ text: string;
43
+ } | {
44
+ type: "tool_call";
45
+ callId: string;
46
+ title: string;
47
+ kind?: string;
48
+ status?: string;
49
+ } | {
50
+ type: "tool_update";
51
+ callId: string;
52
+ title?: string;
53
+ status?: string;
54
+ } | {
55
+ type: "permission_request";
56
+ requestId: string;
57
+ title: string;
58
+ options: Array<{
59
+ optionId: string;
60
+ name: string;
61
+ kind?: string;
62
+ }>;
63
+ } | {
64
+ type: "permission_resolved";
65
+ requestId: string;
66
+ optionId: string | null;
67
+ } | {
68
+ type: "turn_end";
69
+ stopReason: string;
70
+ } | {
71
+ type: "error";
72
+ message: string;
73
+ };
74
+ export type StoredChatEvent = ChatEvent & {
75
+ seq: number;
76
+ ts: string;
77
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Chat data model shared across the chat backend modules. Every chat is a
3
+ * folder under <output>/chats/ holding meta.json plus events.jsonl — the
4
+ * same files-on-disk philosophy as runs. Events are the single source of
5
+ * truth: the UI replays them to render the thread, and the SSE stream just
6
+ * appends live ones.
7
+ */
8
+ export {};
@@ -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 {};
@@ -0,0 +1,266 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { parse, stringify } from "yaml";
4
+ import { getOutputDir } from "./context.js";
5
+ import { getMember, listMembers, safeMemberSlug, teamRoot } from "./team.js";
6
+ import { createChat, postMessage } from "./chat/sessions.js";
7
+ /* ---------- cron ---------- */
8
+ const ALIASES = {
9
+ "@hourly": "0 * * * *",
10
+ "@daily": "0 0 * * *",
11
+ "@weekly": "0 0 * * 0",
12
+ "@monthly": "0 0 1 * *",
13
+ };
14
+ function parseField(spec, min, max, label) {
15
+ const out = new Set();
16
+ for (const part of spec.split(",")) {
17
+ const m = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part);
18
+ if (!m)
19
+ throw new Error(`invalid ${label} field "${part}"`);
20
+ const step = m[2] ? Number(m[2]) : 1;
21
+ if (step < 1)
22
+ throw new Error(`invalid step in ${label} field`);
23
+ let lo = min;
24
+ let hi = max;
25
+ if (m[1] !== "*") {
26
+ const [a, b] = m[1].split("-").map(Number);
27
+ lo = a;
28
+ hi = b ?? (m[2] ? max : a); // "a/n" means a..max stepped; bare "a" is exact
29
+ }
30
+ if (lo < min || hi > max || lo > hi)
31
+ throw new Error(`${label} field out of range (${min}-${max})`);
32
+ for (let v = lo; v <= hi; v += step)
33
+ out.add(v);
34
+ }
35
+ return out;
36
+ }
37
+ export function parseCron(expr) {
38
+ const fields = (ALIASES[expr.trim()] ?? expr.trim()).split(/\s+/);
39
+ if (fields.length !== 5) {
40
+ throw new Error("schedule must be 5 cron fields (min hour dom month dow) or an @alias");
41
+ }
42
+ const spec = {
43
+ minute: parseField(fields[0], 0, 59, "minute"),
44
+ hour: parseField(fields[1], 0, 23, "hour"),
45
+ dom: parseField(fields[2], 1, 31, "day-of-month"),
46
+ month: parseField(fields[3], 1, 12, "month"),
47
+ dow: new Set(
48
+ // 7 is an alias for Sunday (0)
49
+ [...parseField(fields[4], 0, 7, "day-of-week")].map((v) => (v === 7 ? 0 : v))),
50
+ domStar: fields[2] === "*",
51
+ dowStar: fields[4] === "*",
52
+ };
53
+ return spec;
54
+ }
55
+ function cronMatches(spec, d) {
56
+ if (!spec.minute.has(d.getMinutes()))
57
+ return false;
58
+ if (!spec.hour.has(d.getHours()))
59
+ return false;
60
+ if (!spec.month.has(d.getMonth() + 1))
61
+ return false;
62
+ const domOk = spec.dom.has(d.getDate());
63
+ const dowOk = spec.dow.has(d.getDay());
64
+ // Standard cron rule: if both day fields are restricted, either may match.
65
+ if (!spec.domStar && !spec.dowStar)
66
+ return domOk || dowOk;
67
+ return domOk && dowOk;
68
+ }
69
+ /** Next matching instant after `from`, scanning up to 60 days; undefined if none. */
70
+ export function nextRun(schedule, from = new Date()) {
71
+ let spec;
72
+ try {
73
+ spec = parseCron(schedule);
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ const d = new Date(from);
79
+ d.setSeconds(0, 0);
80
+ for (let i = 0; i < 60 * 24 * 60; i++) {
81
+ d.setMinutes(d.getMinutes() + 1);
82
+ if (cronMatches(spec, d))
83
+ return d.toISOString();
84
+ }
85
+ return undefined;
86
+ }
87
+ /* ---------- definition CRUD (agents/<slug>/routines.yml) ---------- */
88
+ async function routinesFile(slug) {
89
+ return path.join(await teamRoot(), safeMemberSlug(slug), "routines.yml");
90
+ }
91
+ function normalizeRoutine(raw) {
92
+ const id = String(raw.id ?? "").trim();
93
+ if (!id)
94
+ return null;
95
+ return {
96
+ id,
97
+ name: String(raw.name ?? id),
98
+ schedule: String(raw.schedule ?? ""),
99
+ prompt: String(raw.prompt ?? ""),
100
+ enabled: raw.enabled !== false,
101
+ };
102
+ }
103
+ export async function listRoutines(slug) {
104
+ let raw;
105
+ try {
106
+ raw = parse(await fs.readFile(await routinesFile(slug), "utf8"));
107
+ }
108
+ catch {
109
+ return [];
110
+ }
111
+ if (!Array.isArray(raw))
112
+ return [];
113
+ return raw
114
+ .map((r) => normalizeRoutine((r ?? {})))
115
+ .filter(Boolean);
116
+ }
117
+ async function writeRoutines(slug, routines) {
118
+ await fs.writeFile(await routinesFile(slug), stringify(routines));
119
+ }
120
+ export async function saveRoutine(slug, input) {
121
+ const name = input.name?.trim();
122
+ if (!name)
123
+ throw new Error("name is required");
124
+ if (!input.prompt?.trim())
125
+ throw new Error("prompt is required");
126
+ parseCron(input.schedule); // throws with a useful message on a bad schedule
127
+ const id = input.id?.trim() ||
128
+ name
129
+ .toLowerCase()
130
+ .replace(/[^a-z0-9]+/g, "-")
131
+ .replace(/^-+|-+$/g, "")
132
+ .slice(0, 48);
133
+ if (!id)
134
+ throw new Error("name must contain at least one letter or digit");
135
+ const routine = {
136
+ id,
137
+ name,
138
+ schedule: input.schedule.trim(),
139
+ prompt: input.prompt,
140
+ enabled: input.enabled !== false,
141
+ };
142
+ const routines = await listRoutines(slug);
143
+ const i = routines.findIndex((r) => r.id === id);
144
+ if (i >= 0)
145
+ routines[i] = routine;
146
+ else
147
+ routines.push(routine);
148
+ await writeRoutines(slug, routines);
149
+ return routine;
150
+ }
151
+ export async function deleteRoutine(slug, id) {
152
+ const routines = await listRoutines(slug);
153
+ await writeRoutines(slug, routines.filter((r) => r.id !== id));
154
+ }
155
+ const stateFile = () => path.join(getOutputDir(), "routines-state.json");
156
+ async function readState() {
157
+ try {
158
+ return JSON.parse(await fs.readFile(stateFile(), "utf8"));
159
+ }
160
+ catch {
161
+ return {};
162
+ }
163
+ }
164
+ async function writeState(state) {
165
+ await fs.mkdir(getOutputDir(), { recursive: true });
166
+ await fs.writeFile(stateFile(), JSON.stringify(state, null, 2));
167
+ }
168
+ export async function routineStatuses(slug) {
169
+ const [routines, state] = await Promise.all([listRoutines(slug), readState()]);
170
+ return routines.map((r) => {
171
+ const s = state[`${slug}/${r.id}`] ?? {};
172
+ return {
173
+ ...r,
174
+ ...(r.enabled ? { nextRunAt: nextRun(r.schedule) } : {}),
175
+ ...(s.lastRunAt ? { lastRunAt: s.lastRunAt } : {}),
176
+ ...(s.lastChatId ? { lastChatId: s.lastChatId } : {}),
177
+ ...(s.lastError ? { lastError: s.lastError } : {}),
178
+ };
179
+ });
180
+ }
181
+ /* ---------- firing ---------- */
182
+ /** Open a fresh session for the member and post the routine's prompt. */
183
+ async function fireRoutine(slug, routine) {
184
+ const member = await getMember(slug);
185
+ if (!member)
186
+ throw new Error(`team agent "${slug}" not found`);
187
+ const meta = await createChat({
188
+ agent: member.harness,
189
+ scope: { kind: "team", member: slug, routine: routine.id },
190
+ title: `⏰ ${routine.name}`,
191
+ });
192
+ const { error } = await postMessage(meta.id, routine.prompt);
193
+ if (error)
194
+ throw new Error(error);
195
+ return meta.id;
196
+ }
197
+ export async function runRoutineNow(slug, id) {
198
+ const routine = (await listRoutines(slug)).find((r) => r.id === id);
199
+ if (!routine)
200
+ throw new Error("routine not found");
201
+ const state = await readState();
202
+ const key = `${slug}/${id}`;
203
+ try {
204
+ const chatId = await fireRoutine(slug, routine);
205
+ state[key] = { ...state[key], lastRunAt: new Date().toISOString(), lastChatId: chatId };
206
+ delete state[key].lastError;
207
+ await writeState(state);
208
+ return { chatId };
209
+ }
210
+ catch (err) {
211
+ state[key] = { ...state[key], lastError: err.message };
212
+ await writeState(state);
213
+ throw err;
214
+ }
215
+ }
216
+ /* ---------- scheduler ---------- */
217
+ let timer = null;
218
+ async function tick() {
219
+ const now = new Date();
220
+ const p = (n) => String(n).padStart(2, "0");
221
+ const minuteKey = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}T${p(now.getHours())}:${p(now.getMinutes())}`;
222
+ let members;
223
+ try {
224
+ members = await listMembers();
225
+ }
226
+ catch {
227
+ return;
228
+ }
229
+ const state = await readState();
230
+ let dirty = false;
231
+ for (const m of members) {
232
+ for (const r of await listRoutines(m.slug).catch(() => [])) {
233
+ if (!r.enabled)
234
+ continue;
235
+ let due = false;
236
+ try {
237
+ due = cronMatches(parseCron(r.schedule), now);
238
+ }
239
+ catch {
240
+ continue; // invalid schedule — surfaced by validation on save, skip here
241
+ }
242
+ const key = `${m.slug}/${r.id}`;
243
+ if (!due || state[key]?.lastMinute === minuteKey)
244
+ continue;
245
+ state[key] = { ...state[key], lastMinute: minuteKey };
246
+ dirty = true;
247
+ try {
248
+ const chatId = await fireRoutine(m.slug, r);
249
+ state[key] = { ...state[key], lastRunAt: now.toISOString(), lastChatId: chatId };
250
+ delete state[key].lastError;
251
+ }
252
+ catch (err) {
253
+ state[key] = { ...state[key], lastError: err.message };
254
+ }
255
+ }
256
+ }
257
+ if (dirty)
258
+ await writeState(state);
259
+ }
260
+ /** Start the in-process scheduler; safe to call more than once. */
261
+ export function startRoutineScheduler() {
262
+ if (timer)
263
+ return;
264
+ timer = setInterval(() => void tick().catch(() => { }), 20_000);
265
+ timer.unref?.();
266
+ }