@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.
- package/README.md +166 -5
- package/dist/cli/doctor.js +38 -0
- package/dist/cli/knowledge.d.ts +2 -0
- package/dist/cli/knowledge.js +213 -0
- package/dist/cli/kraftwerk.js +5 -2
- package/dist/cli/ui.js +1 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.js +4 -2
- package/dist/gates.d.ts +7 -0
- package/dist/gates.js +27 -0
- package/dist/inspector/chat/acp.d.ts +2 -0
- package/dist/inspector/chat/acp.js +155 -0
- package/dist/inspector/chat/backend.d.ts +36 -0
- package/dist/inspector/chat/backend.js +1 -0
- package/dist/inspector/chat/pi.d.ts +2 -0
- package/dist/inspector/chat/pi.js +105 -0
- package/dist/inspector/chat/sessions.d.ts +29 -0
- package/dist/inspector/chat/sessions.js +316 -0
- package/dist/inspector/chat/store.d.ts +8 -0
- package/dist/inspector/chat/store.js +69 -0
- package/dist/inspector/chat/types.d.ts +77 -0
- package/dist/inspector/chat/types.js +8 -0
- package/dist/inspector/knowledge.d.ts +24 -0
- package/dist/inspector/knowledge.js +41 -0
- package/dist/inspector/routines.d.ts +56 -0
- package/dist/inspector/routines.js +266 -0
- package/dist/inspector/server.js +272 -0
- package/dist/inspector/team.d.ts +50 -0
- package/dist/inspector/team.js +119 -0
- package/dist/okf.d.ts +118 -0
- package/dist/okf.js +398 -0
- package/dist/yaml.js +41 -8
- package/inspector/dist/assets/{index-BCSWkslF.css → index-BtcPa8nx.css} +1 -1
- package/inspector/dist/assets/index-CImcmBVs.js +10 -0
- package/inspector/dist/index.html +2 -2
- package/package.json +4 -1
- package/schema/workflow.schema.json +166 -31
- package/inspector/dist/assets/index-D6WuziFQ.js +0 -9
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { Readable, Writable } from "node:stream";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, RequestError, } from "@agentclientprotocol/sdk";
|
|
5
|
+
/**
|
|
6
|
+
* ACP-backed chat: spawn an adapter (claude-agent-acp / codex-acp) as a
|
|
7
|
+
* subprocess, speak Agent Client Protocol over its stdio, and translate
|
|
8
|
+
* session/update notifications into chat events. One subprocess lives for
|
|
9
|
+
* the whole chat; the ACP session id carries the conversation.
|
|
10
|
+
*
|
|
11
|
+
* Auth rides on the local CLI logins (Claude Code / Codex) via the
|
|
12
|
+
* inherited environment — same story as the kraftwerk harnesses.
|
|
13
|
+
*/
|
|
14
|
+
const ADAPTERS = {
|
|
15
|
+
claude: "@agentclientprotocol/claude-agent-acp/dist/index.js",
|
|
16
|
+
codex: "@agentclientprotocol/codex-acp/dist/index.js",
|
|
17
|
+
};
|
|
18
|
+
/** Effort tier -> Claude thinking budget (the adapter reads MAX_THINKING_TOKENS). */
|
|
19
|
+
const CLAUDE_THINKING_BUDGET = {
|
|
20
|
+
low: 2048,
|
|
21
|
+
medium: 8192,
|
|
22
|
+
high: 16384,
|
|
23
|
+
xhigh: 32000,
|
|
24
|
+
max: 63999,
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Model/effort overrides ride on adapter-specific channels: the claude
|
|
28
|
+
* adapter takes the model via session `_meta.claudeCode.options` and the
|
|
29
|
+
* thinking budget via env; codex-acp merges a CODEX_CONFIG env JSON into
|
|
30
|
+
* the session config it hands to `codex app-server`.
|
|
31
|
+
*/
|
|
32
|
+
function tuningEnv(agent, tuning) {
|
|
33
|
+
const env = { ...process.env };
|
|
34
|
+
if (agent === "claude" && tuning.effort && CLAUDE_THINKING_BUDGET[tuning.effort]) {
|
|
35
|
+
env.MAX_THINKING_TOKENS = String(CLAUDE_THINKING_BUDGET[tuning.effort]);
|
|
36
|
+
}
|
|
37
|
+
if (agent === "codex" && (tuning.model || tuning.effort)) {
|
|
38
|
+
env.CODEX_CONFIG = JSON.stringify({
|
|
39
|
+
...(tuning.model ? { model: tuning.model } : {}),
|
|
40
|
+
...(tuning.effort ? { model_reasoning_effort: tuning.effort } : {}),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return env;
|
|
44
|
+
}
|
|
45
|
+
function contentText(content) {
|
|
46
|
+
return content.type === "text" ? content.text : "";
|
|
47
|
+
}
|
|
48
|
+
export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
|
|
49
|
+
const entry = fileURLToPath(import.meta.resolve(ADAPTERS[agent]));
|
|
50
|
+
const child = spawn(process.execPath, [entry], {
|
|
51
|
+
cwd,
|
|
52
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
53
|
+
env: tuningEnv(agent, tuning),
|
|
54
|
+
});
|
|
55
|
+
let stderr = "";
|
|
56
|
+
child.stderr.on("data", (c) => {
|
|
57
|
+
stderr += c.toString("utf8");
|
|
58
|
+
if (stderr.length > 20_000)
|
|
59
|
+
stderr = stderr.slice(-20_000);
|
|
60
|
+
});
|
|
61
|
+
let dead = false;
|
|
62
|
+
child.on("close", (code) => {
|
|
63
|
+
dead = true;
|
|
64
|
+
if (code !== 0 && code !== null) {
|
|
65
|
+
hooks.emit({
|
|
66
|
+
type: "error",
|
|
67
|
+
message: `${agent} agent exited (code ${code})${stderr.trim() ? `: ${stderr.trim().slice(-500)}` : ""}`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
const client = {
|
|
72
|
+
sessionUpdate(params) {
|
|
73
|
+
const u = params.update;
|
|
74
|
+
switch (u.sessionUpdate) {
|
|
75
|
+
case "agent_message_chunk": {
|
|
76
|
+
const text = contentText(u.content);
|
|
77
|
+
if (text)
|
|
78
|
+
hooks.emit({ type: "text", text });
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case "agent_thought_chunk": {
|
|
82
|
+
const text = contentText(u.content);
|
|
83
|
+
if (text)
|
|
84
|
+
hooks.emit({ type: "thought", text });
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case "tool_call":
|
|
88
|
+
hooks.emit({
|
|
89
|
+
type: "tool_call",
|
|
90
|
+
callId: u.toolCallId,
|
|
91
|
+
title: u.title,
|
|
92
|
+
kind: u.kind ?? undefined,
|
|
93
|
+
status: u.status ?? undefined,
|
|
94
|
+
});
|
|
95
|
+
break;
|
|
96
|
+
case "tool_call_update":
|
|
97
|
+
hooks.emit({
|
|
98
|
+
type: "tool_update",
|
|
99
|
+
callId: u.toolCallId,
|
|
100
|
+
title: u.title ?? undefined,
|
|
101
|
+
status: u.status ?? undefined,
|
|
102
|
+
});
|
|
103
|
+
break;
|
|
104
|
+
// plans, mode/config updates etc. carry no thread content — skip.
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
async requestPermission(params) {
|
|
108
|
+
const optionId = await hooks.askPermission(params.toolCall.title ?? params.toolCall.toolCallId, params.options.map((o) => ({ optionId: o.optionId, name: o.name, kind: o.kind })));
|
|
109
|
+
return optionId
|
|
110
|
+
? { outcome: { outcome: "selected", optionId } }
|
|
111
|
+
: { outcome: { outcome: "cancelled" } };
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
|
|
115
|
+
const conn = new ClientSideConnection(() => client, stream);
|
|
116
|
+
await conn.initialize({
|
|
117
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
118
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
|
|
119
|
+
clientInfo: { name: "kraftwerk-inspector", version: "1.0.0" },
|
|
120
|
+
});
|
|
121
|
+
const session = await conn.newSession({
|
|
122
|
+
cwd,
|
|
123
|
+
mcpServers: [],
|
|
124
|
+
...(agent === "claude" && tuning.model
|
|
125
|
+
? { _meta: { claudeCode: { options: { model: tuning.model } } } }
|
|
126
|
+
: {}),
|
|
127
|
+
});
|
|
128
|
+
const sessionId = session.sessionId;
|
|
129
|
+
return {
|
|
130
|
+
async prompt(text) {
|
|
131
|
+
if (dead)
|
|
132
|
+
throw new Error(`${agent} agent process is gone — start a new chat`);
|
|
133
|
+
try {
|
|
134
|
+
const res = await conn.prompt({
|
|
135
|
+
sessionId,
|
|
136
|
+
prompt: [{ type: "text", text }],
|
|
137
|
+
});
|
|
138
|
+
return res.stopReason;
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
if (err instanceof RequestError)
|
|
142
|
+
throw new Error(`${agent} agent: ${err.message}`);
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
cancel() {
|
|
147
|
+
if (!dead)
|
|
148
|
+
void conn.cancel({ sessionId }).catch(() => { });
|
|
149
|
+
},
|
|
150
|
+
dispose() {
|
|
151
|
+
if (!dead)
|
|
152
|
+
child.kill("SIGTERM");
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ChatEvent } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* A chat backend is one live agent conversation: prompt() runs a full turn
|
|
4
|
+
* (resolves when the agent finishes), streaming intermediate activity
|
|
5
|
+
* through the hooks. ACP agents (claude, codex) keep a subprocess alive for
|
|
6
|
+
* the whole chat; pi respawns per message and resumes via --session-id.
|
|
7
|
+
*/
|
|
8
|
+
export interface BackendHooks {
|
|
9
|
+
/** Stream a thread event (text chunk, tool call, ...) to the session. */
|
|
10
|
+
emit(ev: ChatEvent): void;
|
|
11
|
+
/**
|
|
12
|
+
* Surface a permission request to the user; resolves with the chosen
|
|
13
|
+
* optionId, or null when the user (or a cancel) dismissed it.
|
|
14
|
+
*/
|
|
15
|
+
askPermission(title: string, options: Array<{
|
|
16
|
+
optionId: string;
|
|
17
|
+
name: string;
|
|
18
|
+
kind?: string;
|
|
19
|
+
}>): Promise<string | null>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Optional per-chat model/effort overrides (from a team member definition).
|
|
23
|
+
* Each backend maps them onto whatever its agent actually supports.
|
|
24
|
+
*/
|
|
25
|
+
export interface BackendTuning {
|
|
26
|
+
model?: string;
|
|
27
|
+
effort?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface ChatBackend {
|
|
30
|
+
/** Send one user message; resolves with the stop reason at turn end. */
|
|
31
|
+
prompt(text: string): Promise<string>;
|
|
32
|
+
/** Interrupt the current turn (the running prompt() still resolves). */
|
|
33
|
+
cancel(): void;
|
|
34
|
+
/** Kill the agent subprocess, if any. */
|
|
35
|
+
dispose(): void;
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
const clip = (raw) => {
|
|
4
|
+
const oneLine = String(raw ?? "").replace(/\s+/g, " ").trim();
|
|
5
|
+
return oneLine.length > 120 ? `${oneLine.slice(0, 120)}…` : oneLine;
|
|
6
|
+
};
|
|
7
|
+
export function startPiBackend(cwd, hooks, tuning = {}) {
|
|
8
|
+
const sessionId = randomUUID();
|
|
9
|
+
let child = null;
|
|
10
|
+
let cancelled = false;
|
|
11
|
+
const tuningArgs = [
|
|
12
|
+
...(tuning.model ? ["--model", tuning.model] : []),
|
|
13
|
+
...(tuning.effort ? ["--thinking", tuning.effort] : []),
|
|
14
|
+
];
|
|
15
|
+
return {
|
|
16
|
+
prompt(text) {
|
|
17
|
+
cancelled = false;
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const proc = spawn("pi", ["-p", "--mode", "json", "--session-id", sessionId, ...tuningArgs], {
|
|
20
|
+
cwd,
|
|
21
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
22
|
+
env: process.env,
|
|
23
|
+
});
|
|
24
|
+
child = proc;
|
|
25
|
+
proc.stdin.write(text);
|
|
26
|
+
proc.stdin.end();
|
|
27
|
+
let sawAssistant = false;
|
|
28
|
+
let failure;
|
|
29
|
+
let stderr = "";
|
|
30
|
+
let buffer = "";
|
|
31
|
+
let toolSeq = 0;
|
|
32
|
+
const handleLine = (line) => {
|
|
33
|
+
if (!line.trim())
|
|
34
|
+
return;
|
|
35
|
+
let event;
|
|
36
|
+
try {
|
|
37
|
+
event = JSON.parse(line);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return; // non-JSON noise on stdout
|
|
41
|
+
}
|
|
42
|
+
if (event.type === "tool_execution_start" && event.toolName) {
|
|
43
|
+
const a = event.args ?? {};
|
|
44
|
+
const target = clip(a.path ?? a.file_path ?? a.command ?? a.pattern ?? "");
|
|
45
|
+
hooks.emit({
|
|
46
|
+
type: "tool_call",
|
|
47
|
+
callId: `pi-${sessionId}-${toolSeq++}`,
|
|
48
|
+
title: target ? `${event.toolName} ${target}` : event.toolName,
|
|
49
|
+
status: "completed",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (event.type === "message_end" && event.message?.role === "assistant") {
|
|
53
|
+
sawAssistant = true;
|
|
54
|
+
for (const block of event.message.content ?? []) {
|
|
55
|
+
if (block?.type === "text" && block.text?.trim()) {
|
|
56
|
+
hooks.emit({ type: "text", text: block.text });
|
|
57
|
+
}
|
|
58
|
+
if (block?.type === "thinking" && block.text?.trim()) {
|
|
59
|
+
hooks.emit({ type: "thought", text: block.text });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (event.type === "error") {
|
|
64
|
+
failure = typeof event.error === "string" ? event.error : line;
|
|
65
|
+
}
|
|
66
|
+
// API failures don't exit non-zero: they ride on turn_end as
|
|
67
|
+
// stopReason "error" with an empty assistant message.
|
|
68
|
+
if (event.type === "turn_end" && event.message?.stopReason === "error") {
|
|
69
|
+
failure = clip(event.message.errorMessage ?? "unknown pi error");
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
proc.stdout.on("data", (chunk) => {
|
|
73
|
+
buffer += chunk.toString("utf8");
|
|
74
|
+
const lines = buffer.split("\n");
|
|
75
|
+
buffer = lines.pop() ?? "";
|
|
76
|
+
lines.forEach(handleLine);
|
|
77
|
+
});
|
|
78
|
+
proc.stderr.on("data", (chunk) => {
|
|
79
|
+
stderr += chunk.toString("utf8");
|
|
80
|
+
});
|
|
81
|
+
proc.on("error", (err) => reject(new Error(`could not spawn pi: ${err.message}`)));
|
|
82
|
+
proc.on("close", (code) => {
|
|
83
|
+
child = null;
|
|
84
|
+
handleLine(buffer);
|
|
85
|
+
if (cancelled)
|
|
86
|
+
return resolve("cancelled");
|
|
87
|
+
if (failure || code !== 0 || !sawAssistant) {
|
|
88
|
+
reject(new Error(`pi failed (exit ${code})` +
|
|
89
|
+
(failure ? `: ${failure}` : "") +
|
|
90
|
+
(stderr.trim() ? `\nstderr: ${stderr.trim().slice(-1000)}` : "")));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
resolve("end_turn");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
cancel() {
|
|
98
|
+
cancelled = true;
|
|
99
|
+
child?.kill("SIGTERM");
|
|
100
|
+
},
|
|
101
|
+
dispose() {
|
|
102
|
+
child?.kill("SIGTERM");
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ChatAgentId, ChatMeta, ChatScope, StoredChatEvent } from "./types.js";
|
|
2
|
+
export declare function createChat(opts: {
|
|
3
|
+
agent: ChatAgentId;
|
|
4
|
+
scope: ChatScope;
|
|
5
|
+
/** Preset title (e.g. routine runs); otherwise the first message names the chat. */
|
|
6
|
+
title?: string;
|
|
7
|
+
}): Promise<ChatMeta>;
|
|
8
|
+
export declare function listChats(): Promise<Array<ChatMeta & {
|
|
9
|
+
busy: boolean;
|
|
10
|
+
}>>;
|
|
11
|
+
export declare function getChat(id: string): Promise<{
|
|
12
|
+
meta: ChatMeta;
|
|
13
|
+
events: StoredChatEvent[];
|
|
14
|
+
busy: boolean;
|
|
15
|
+
} | null>;
|
|
16
|
+
export declare function postMessage(id: string, text: string): Promise<{
|
|
17
|
+
error?: string;
|
|
18
|
+
}>;
|
|
19
|
+
export declare function resolvePermission(id: string, requestId: string, optionId: string | null): Promise<{
|
|
20
|
+
error?: string;
|
|
21
|
+
}>;
|
|
22
|
+
export declare function cancelChat(id: string): Promise<{
|
|
23
|
+
error?: string;
|
|
24
|
+
}>;
|
|
25
|
+
/**
|
|
26
|
+
* SSE subscription: replays events after `afterSeq`, then streams new ones.
|
|
27
|
+
* Returns an unsubscribe function.
|
|
28
|
+
*/
|
|
29
|
+
export declare function subscribeChat(id: string, afterSeq: number, fn: (ev: StoredChatEvent) => void): Promise<(() => void) | null>;
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getOutputDir, getProjectRoot } from "../context.js";
|
|
3
|
+
import { knowledgeIndex } from "../knowledge.js";
|
|
4
|
+
import { getRun, listRuns, safeRunDir } from "../runs.js";
|
|
5
|
+
import { listWorkflows } from "../workflows.js";
|
|
6
|
+
import { getMember } from "../team.js";
|
|
7
|
+
import { startAcpBackend } from "./acp.js";
|
|
8
|
+
import { startPiBackend } from "./pi.js";
|
|
9
|
+
import { appendEvent, listChatMetas, newChatId, readEvents, readMeta, writeMeta } from "./store.js";
|
|
10
|
+
const states = new Map();
|
|
11
|
+
async function loadState(id) {
|
|
12
|
+
const existing = states.get(id);
|
|
13
|
+
if (existing)
|
|
14
|
+
return existing;
|
|
15
|
+
const meta = await readMeta(id);
|
|
16
|
+
if (!meta)
|
|
17
|
+
return null;
|
|
18
|
+
const state = {
|
|
19
|
+
meta,
|
|
20
|
+
events: await readEvents(id),
|
|
21
|
+
subscribers: new Set(),
|
|
22
|
+
backend: null,
|
|
23
|
+
busy: false,
|
|
24
|
+
pendingPermissions: new Map(),
|
|
25
|
+
writeChain: Promise.resolve(),
|
|
26
|
+
};
|
|
27
|
+
// Two racing loads: keep whichever registered first.
|
|
28
|
+
return states.get(id) ?? (states.set(id, state), state);
|
|
29
|
+
}
|
|
30
|
+
function emit(state, ev) {
|
|
31
|
+
const stored = {
|
|
32
|
+
...ev,
|
|
33
|
+
seq: (state.events[state.events.length - 1]?.seq ?? 0) + 1,
|
|
34
|
+
ts: new Date().toISOString(),
|
|
35
|
+
};
|
|
36
|
+
state.events.push(stored);
|
|
37
|
+
state.meta.updatedAt = stored.ts;
|
|
38
|
+
state.writeChain = state.writeChain
|
|
39
|
+
.then(() => appendEvent(state.meta.id, stored))
|
|
40
|
+
.catch(() => { });
|
|
41
|
+
for (const fn of state.subscribers)
|
|
42
|
+
fn(stored);
|
|
43
|
+
return stored;
|
|
44
|
+
}
|
|
45
|
+
/* ---------- scope context ---------- */
|
|
46
|
+
/** "## Your knowledge" block for a team member's connected OKF bundles. */
|
|
47
|
+
async function teamKnowledgeContext(member) {
|
|
48
|
+
if (member.knowledge.length === 0)
|
|
49
|
+
return "";
|
|
50
|
+
const { bundles } = await knowledgeIndex().catch(() => ({ bundles: [] }));
|
|
51
|
+
const lines = member.knowledge
|
|
52
|
+
.map((name) => {
|
|
53
|
+
const b = bundles.find((x) => x.name === name);
|
|
54
|
+
return b
|
|
55
|
+
? `- ${b.name} (${b.concepts} concepts${b.updatedAt ? `, updated ${b.updatedAt.slice(0, 10)}` : ""})`
|
|
56
|
+
: `- ${name} (not found in knowledge/ — tell the user if you need it)`;
|
|
57
|
+
})
|
|
58
|
+
.join("\n");
|
|
59
|
+
return (`## Your knowledge\nThese OKF knowledge bundles (markdown + YAML frontmatter under knowledge/) are ` +
|
|
60
|
+
`part of your job. Consult them before answering questions in their domain, and keep them current ` +
|
|
61
|
+
`when you learn something durable:\n${lines}\n\n` +
|
|
62
|
+
`Read with \`npx kraftwerk knowledge list <bundle>\`, \`get <bundle>/<path>\`, \`search <text>\`. ` +
|
|
63
|
+
`Write ONLY through \`npx kraftwerk knowledge put <bundle>/<path> --file <tmp.md> --actor ${member.slug}/${member.harness}\` ` +
|
|
64
|
+
`(write the markdown to a temp file first; the CLI stamps provenance and maintains index.md/log.md). ` +
|
|
65
|
+
`Never hand-edit index.md or log.md, and never add \`verified\` yourself — verification is the human's ` +
|
|
66
|
+
`click in the UI.\n\n`);
|
|
67
|
+
}
|
|
68
|
+
async function scopeContext(scope, agent) {
|
|
69
|
+
if (scope.kind === "team") {
|
|
70
|
+
const member = await getMember(scope.member).catch(() => null);
|
|
71
|
+
if (!member) {
|
|
72
|
+
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.`;
|
|
73
|
+
}
|
|
74
|
+
const { workflows } = await listWorkflows().catch(() => ({ workflows: [] }));
|
|
75
|
+
const connected = workflows.filter((w) => member.workflows.includes(w.slug));
|
|
76
|
+
const missing = member.workflows.filter((slug) => !connected.some((w) => w.slug === slug));
|
|
77
|
+
const wfLines = connected
|
|
78
|
+
.map((w) => `- ${w.slug}: ${w.description ?? w.name ?? ""} (${w.steps} steps)`)
|
|
79
|
+
.join("\n");
|
|
80
|
+
return (`You are ${member.emoji} ${member.name}, a persistent agent teammate on this project's team ` +
|
|
81
|
+
`(defined in agents/${member.slug}/). The user works with you like with a colleague: every session ` +
|
|
82
|
+
`is a conversation with the same ${member.name}, so keep this role consistently. You are an AI agent, ` +
|
|
83
|
+
`not a human — never pretend otherwise, but do own your role.\n\n` +
|
|
84
|
+
`## Your role\n${member.system || "(no system prompt written yet — ask the user what your job should be)"}\n\n` +
|
|
85
|
+
(member.workflows.length > 0
|
|
86
|
+
? `## Your workflows\nThese kraftwerk workflows are part of your job. When the user's request matches one, ` +
|
|
87
|
+
`run it yourself instead of doing the work by hand:\n${wfLines || "(none found)"}\n` +
|
|
88
|
+
(missing.length > 0
|
|
89
|
+
? `(configured but not found in this project: ${missing.join(", ")})\n`
|
|
90
|
+
: "") +
|
|
91
|
+
`\nRun a workflow with:\n` +
|
|
92
|
+
` KRAFTWERK_YES=1 npx kraftwerk run <workflow> "<request>"\n` +
|
|
93
|
+
`The command blocks until the run finishes; artifacts land in output/run-*/ — tell the user the ` +
|
|
94
|
+
`run id and summarize the result. Confirm with the user before starting a long or expensive run ` +
|
|
95
|
+
`unless they clearly asked for it.\n\n`
|
|
96
|
+
: "") +
|
|
97
|
+
(await teamKnowledgeContext(member)) +
|
|
98
|
+
`The working directory is the project root. Stay within your role; if a request is clearly outside it, ` +
|
|
99
|
+
`say so and suggest which teammate or tool fits better.` +
|
|
100
|
+
(scope.routine
|
|
101
|
+
? `\n\nThis session was started automatically by your scheduled routine "${scope.routine}" — ` +
|
|
102
|
+
`nobody may be watching live. Complete the task autonomously (tool permissions are ` +
|
|
103
|
+
`auto-approved) and end with a clear, self-contained summary of what you did and found.`
|
|
104
|
+
: ""));
|
|
105
|
+
}
|
|
106
|
+
if (scope.kind === "knowledge") {
|
|
107
|
+
const { root, bundles } = await knowledgeIndex().catch(() => ({ root: "", bundles: [] }));
|
|
108
|
+
const bundleLines = bundles
|
|
109
|
+
.map((b) => `- ${b.name} (${b.concepts} concepts${b.updatedAt ? `, updated ${b.updatedAt.slice(0, 10)}` : ""})`)
|
|
110
|
+
.join("\n");
|
|
111
|
+
return (`You are the knowledge curator inside the kraftwerk inspector ("Context & Knowledge"). ` +
|
|
112
|
+
`This project keeps knowledge as OKF v0.2 bundles (Open Knowledge Format): directories of markdown ` +
|
|
113
|
+
`files with YAML frontmatter under ${root || "knowledge/"}. Each direct subdirectory is one bundle; each .md file ` +
|
|
114
|
+
`(except the reserved index.md and log.md) is one concept.\n\n` +
|
|
115
|
+
`Existing bundles:\n${bundleLines || "(none yet)"}\n` +
|
|
116
|
+
(scope.bundle ? `\nThe user wants to work on the "${scope.bundle}" bundle.\n` : "") +
|
|
117
|
+
`\nOKF essentials:\n` +
|
|
118
|
+
`- Frontmatter needs exactly one required key: \`type\` (free-form, e.g. Playbook, Metric, Reference, API Endpoint). ` +
|
|
119
|
+
`Recommended: title, description, tags, resource (canonical URI of the described asset).\n` +
|
|
120
|
+
`- Provenance/trust families (all optional): \`sources\` (list of { id, resource, title, author, usage_count, last_modified }), ` +
|
|
121
|
+
`\`generated: { by, at }\`, \`verified: [{ by, at }]\`, \`status\` (draft|stable|deprecated), \`stale_after\` (ISO datetime).\n` +
|
|
122
|
+
`- Actors: \`<producer>/<version>\` for agents, \`human:<id>\` for people, \`process:<id>\` for automation.\n` +
|
|
123
|
+
`- Concepts cross-link with normal markdown links, bundle-absolute (\`/path/concept.md\`) preferred. ` +
|
|
124
|
+
`Per-claim attribution uses markdown footnotes whose label is a \`sources[].id\`.\n` +
|
|
125
|
+
`- Favor structural markdown (headings, tables, lists) over prose.\n\n` +
|
|
126
|
+
`ALWAYS write through the kraftwerk CLI so provenance is stamped and the bundle log/index stay maintained:\n` +
|
|
127
|
+
`- \`npx kraftwerk knowledge init <bundle>\` — new bundle\n` +
|
|
128
|
+
`- \`npx kraftwerk knowledge put <bundle>/<path> --file <tmp.md> --actor kraftwerk-chat/${agent}\` — create/update a concept ` +
|
|
129
|
+
`(write the markdown to a temp file first; the CLI stamps generated.by/at, appends log.md, regenerates index.md)\n` +
|
|
130
|
+
`- \`npx kraftwerk knowledge list [bundle]\`, \`get <bundle>/<path>\`, \`search <text>\`, \`validate\`\n` +
|
|
131
|
+
`Do not hand-edit index.md or log.md (derived/maintained), and do not add \`verified\` yourself — ` +
|
|
132
|
+
`verification is the human's click in the UI. Ask the user what knowledge to capture, then author concise, well-typed concepts.`);
|
|
133
|
+
}
|
|
134
|
+
if (scope.kind === "kraftwerk") {
|
|
135
|
+
const [{ workflows }, runs] = await Promise.all([listWorkflows(), listRuns()]);
|
|
136
|
+
const wfLines = workflows
|
|
137
|
+
.map((w) => `- ${w.name ?? w.slug}: ${w.description ?? ""} (${w.steps} steps, ${w.agents} agents)`)
|
|
138
|
+
.join("\n");
|
|
139
|
+
const runLines = runs
|
|
140
|
+
.slice(0, 10)
|
|
141
|
+
.map((r) => `- ${r.id} — ${r.workflow ?? "?"} [${r.status}] ${r.request ?? ""}`.trim())
|
|
142
|
+
.join("\n");
|
|
143
|
+
return (`You are the assistant inside the kraftwerk inspector, a UI for a workflow-as-code agent framework. ` +
|
|
144
|
+
`The consumer project root is ${getProjectRoot()}; run outputs live in ${getOutputDir()} (one run-* folder per run, each with a trace.jsonl and working files).\n\n` +
|
|
145
|
+
`Workflows in this project:\n${wfLines || "(none)"}\n\nRecent runs:\n${runLines || "(none)"}\n\n` +
|
|
146
|
+
`Answer questions about workflows and runs by reading these files. Do not modify run outputs unless asked.`);
|
|
147
|
+
}
|
|
148
|
+
if (scope.kind === "run") {
|
|
149
|
+
const run = await getRun(scope.runId);
|
|
150
|
+
if (!run)
|
|
151
|
+
return `The user wants to discuss kraftwerk run ${scope.runId}, but it was not found.`;
|
|
152
|
+
const phases = run.phases
|
|
153
|
+
.map((p) => `- ${p.phase} [${p.status}]${p.summary ? `: ${p.summary}` : ""}`)
|
|
154
|
+
.join("\n");
|
|
155
|
+
const files = run.files.map((f) => `- ${f.name} (${f.size} bytes)`).join("\n");
|
|
156
|
+
return (`You are the assistant for one kraftwerk workflow run. Your working directory is the run's output folder.\n\n` +
|
|
157
|
+
`Run ${run.id} — workflow "${run.workflow ?? "?"}", status ${run.status}.\n` +
|
|
158
|
+
`Request: ${run.request ?? "(none)"}\n\nPhases:\n${phases || "(none)"}\n\nFiles in this folder:\n${files || "(none)"}\n\n` +
|
|
159
|
+
`trace.jsonl holds the full event log. Read files as needed to answer; do not modify the run's artifacts unless asked.`);
|
|
160
|
+
}
|
|
161
|
+
return "";
|
|
162
|
+
}
|
|
163
|
+
/* ---------- backend lifecycle ---------- */
|
|
164
|
+
/** Team chats carry the member's model/effort; resolved live so edits apply to new backends. */
|
|
165
|
+
async function backendTuning(scope) {
|
|
166
|
+
if (scope.kind !== "team")
|
|
167
|
+
return {};
|
|
168
|
+
const member = await getMember(scope.member).catch(() => null);
|
|
169
|
+
return {
|
|
170
|
+
...(member?.model ? { model: member.model } : {}),
|
|
171
|
+
...(member?.effort ? { effort: member.effort } : {}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
async function ensureBackend(state) {
|
|
175
|
+
if (state.backend)
|
|
176
|
+
return state.backend;
|
|
177
|
+
const hooks = {
|
|
178
|
+
emit: (ev) => void emit(state, ev),
|
|
179
|
+
askPermission: (title, options) => {
|
|
180
|
+
// Routine-triggered sessions run unattended: auto-approve, but keep
|
|
181
|
+
// the request/resolution pair in the thread as an audit trail.
|
|
182
|
+
const scope = state.meta.scope;
|
|
183
|
+
if (scope.kind === "team" && scope.routine) {
|
|
184
|
+
const allow = options.find((o) => o.kind?.startsWith("allow")) ?? options[0];
|
|
185
|
+
const requestId = randomUUID();
|
|
186
|
+
emit(state, { type: "permission_request", requestId, title, options });
|
|
187
|
+
emit(state, { type: "permission_resolved", requestId, optionId: allow?.optionId ?? null });
|
|
188
|
+
return Promise.resolve(allow?.optionId ?? null);
|
|
189
|
+
}
|
|
190
|
+
return new Promise((resolve) => {
|
|
191
|
+
const requestId = randomUUID();
|
|
192
|
+
state.pendingPermissions.set(requestId, (optionId) => {
|
|
193
|
+
state.pendingPermissions.delete(requestId);
|
|
194
|
+
emit(state, { type: "permission_resolved", requestId, optionId });
|
|
195
|
+
resolve(optionId);
|
|
196
|
+
});
|
|
197
|
+
emit(state, { type: "permission_request", requestId, title, options });
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
const { agent, cwd } = state.meta;
|
|
202
|
+
const tuning = await backendTuning(state.meta.scope);
|
|
203
|
+
state.backend =
|
|
204
|
+
agent === "pi"
|
|
205
|
+
? startPiBackend(cwd, hooks, tuning)
|
|
206
|
+
: await startAcpBackend(agent, cwd, hooks, tuning);
|
|
207
|
+
return state.backend;
|
|
208
|
+
}
|
|
209
|
+
/* ---------- public API (used by server.ts) ---------- */
|
|
210
|
+
export async function createChat(opts) {
|
|
211
|
+
const cwd = opts.scope.kind === "run" ? safeRunDir(opts.scope.runId) : getProjectRoot();
|
|
212
|
+
const now = new Date().toISOString();
|
|
213
|
+
const meta = {
|
|
214
|
+
id: newChatId(),
|
|
215
|
+
agent: opts.agent,
|
|
216
|
+
title: opts.title ?? "",
|
|
217
|
+
cwd,
|
|
218
|
+
scope: opts.scope,
|
|
219
|
+
createdAt: now,
|
|
220
|
+
updatedAt: now,
|
|
221
|
+
};
|
|
222
|
+
await writeMeta(meta);
|
|
223
|
+
states.set(meta.id, {
|
|
224
|
+
meta,
|
|
225
|
+
events: [],
|
|
226
|
+
subscribers: new Set(),
|
|
227
|
+
backend: null,
|
|
228
|
+
busy: false,
|
|
229
|
+
pendingPermissions: new Map(),
|
|
230
|
+
writeChain: Promise.resolve(),
|
|
231
|
+
});
|
|
232
|
+
return meta;
|
|
233
|
+
}
|
|
234
|
+
export async function listChats() {
|
|
235
|
+
const metas = await listChatMetas();
|
|
236
|
+
return metas.map((m) => ({
|
|
237
|
+
...(states.get(m.id)?.meta ?? m),
|
|
238
|
+
busy: states.get(m.id)?.busy ?? false,
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
export async function getChat(id) {
|
|
242
|
+
const state = await loadState(id);
|
|
243
|
+
if (!state)
|
|
244
|
+
return null;
|
|
245
|
+
return { meta: state.meta, events: state.events, busy: state.busy };
|
|
246
|
+
}
|
|
247
|
+
export async function postMessage(id, text) {
|
|
248
|
+
const state = await loadState(id);
|
|
249
|
+
if (!state)
|
|
250
|
+
return { error: "not found" };
|
|
251
|
+
if (state.busy)
|
|
252
|
+
return { error: "agent is still working — wait for the turn to finish" };
|
|
253
|
+
state.busy = true;
|
|
254
|
+
const isFirst = !state.events.some((e) => e.type === "user_message");
|
|
255
|
+
if (isFirst && !state.meta.title) {
|
|
256
|
+
state.meta.title = text.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
257
|
+
void writeMeta(state.meta).catch(() => { });
|
|
258
|
+
}
|
|
259
|
+
emit(state, { type: "user_message", text });
|
|
260
|
+
// The turn runs in the background; the HTTP request returns immediately
|
|
261
|
+
// and the browser follows along on the SSE stream.
|
|
262
|
+
void (async () => {
|
|
263
|
+
try {
|
|
264
|
+
const backend = await ensureBackend(state);
|
|
265
|
+
const context = isFirst ? await scopeContext(state.meta.scope, state.meta.agent) : "";
|
|
266
|
+
const promptText = context ? `<context>\n${context}\n</context>\n\n${text}` : text;
|
|
267
|
+
const stopReason = await backend.prompt(promptText);
|
|
268
|
+
emit(state, { type: "turn_end", stopReason });
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
emit(state, { type: "error", message: err.message });
|
|
272
|
+
// A failed turn may mean a dead subprocess; drop it so the next
|
|
273
|
+
// message spawns a fresh agent (thread history stays on disk).
|
|
274
|
+
state.backend?.dispose();
|
|
275
|
+
state.backend = null;
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
state.busy = false;
|
|
279
|
+
void writeMeta(state.meta).catch(() => { });
|
|
280
|
+
}
|
|
281
|
+
})();
|
|
282
|
+
return {};
|
|
283
|
+
}
|
|
284
|
+
export async function resolvePermission(id, requestId, optionId) {
|
|
285
|
+
const state = await loadState(id);
|
|
286
|
+
if (!state)
|
|
287
|
+
return { error: "not found" };
|
|
288
|
+
const resolve = state.pendingPermissions.get(requestId);
|
|
289
|
+
if (!resolve)
|
|
290
|
+
return { error: "no such pending permission request" };
|
|
291
|
+
resolve(optionId);
|
|
292
|
+
return {};
|
|
293
|
+
}
|
|
294
|
+
export async function cancelChat(id) {
|
|
295
|
+
const state = await loadState(id);
|
|
296
|
+
if (!state)
|
|
297
|
+
return { error: "not found" };
|
|
298
|
+
for (const resolve of state.pendingPermissions.values())
|
|
299
|
+
resolve(null);
|
|
300
|
+
state.backend?.cancel();
|
|
301
|
+
return {};
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* SSE subscription: replays events after `afterSeq`, then streams new ones.
|
|
305
|
+
* Returns an unsubscribe function.
|
|
306
|
+
*/
|
|
307
|
+
export async function subscribeChat(id, afterSeq, fn) {
|
|
308
|
+
const state = await loadState(id);
|
|
309
|
+
if (!state)
|
|
310
|
+
return null;
|
|
311
|
+
for (const ev of state.events)
|
|
312
|
+
if (ev.seq > afterSeq)
|
|
313
|
+
fn(ev);
|
|
314
|
+
state.subscribers.add(fn);
|
|
315
|
+
return () => state.subscribers.delete(fn);
|
|
316
|
+
}
|
|
@@ -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[]>;
|