@dotdrelle/wiki-manager 0.6.17
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/.env.example +58 -0
- package/LICENSE +108 -0
- package/README.md +693 -0
- package/agents.docker-compose.yml +96 -0
- package/bin/wiki-manager.js +34 -0
- package/bunfig.toml +1 -0
- package/docker-compose.yml +135 -0
- package/mcp.endpoints.example.json +28 -0
- package/package.json +57 -0
- package/src/agent/graph.js +638 -0
- package/src/agent/llm.js +239 -0
- package/src/cli/wiki-manager.js +389 -0
- package/src/commands/slash.js +1044 -0
- package/src/core/activity.js +236 -0
- package/src/core/activity.test.js +127 -0
- package/src/core/agentEvents.js +238 -0
- package/src/core/agentEvents.test.js +134 -0
- package/src/core/compose.js +310 -0
- package/src/core/documentIntake.js +311 -0
- package/src/core/documentIntake.test.js +121 -0
- package/src/core/env.js +57 -0
- package/src/core/jobQueue.js +197 -0
- package/src/core/mcp.js +402 -0
- package/src/core/mcp.test.js +228 -0
- package/src/core/plan.js +181 -0
- package/src/core/plan.test.js +168 -0
- package/src/core/skills.js +142 -0
- package/src/core/wikirc.js +65 -0
- package/src/core/workspaces.js +81 -0
- package/src/shell/FileEditorDialog.tsx +94 -0
- package/src/shell/LeftPane.tsx +680 -0
- package/src/shell/RightPane.tsx +291 -0
- package/src/shell/SlashDialog.tsx +39 -0
- package/src/shell/renderer.ts +69 -0
- package/src/shell/repl.js +1490 -0
- package/src/shell/tui.tsx +205 -0
- package/src/shell/useAgent.ts +47 -0
- package/src/shell/useSession.ts +370 -0
- package/tsconfig.json +16 -0
- package/wiki-workspace +773 -0
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import { Annotation, END, START, StateGraph } from '@langchain/langgraph';
|
|
2
|
+
import {
|
|
3
|
+
buildLlmTools,
|
|
4
|
+
callMcpTool,
|
|
5
|
+
formatMcpToolResult,
|
|
6
|
+
formatMcpToolsForAgent,
|
|
7
|
+
parseToolCallName,
|
|
8
|
+
} from '../core/mcp.js';
|
|
9
|
+
import { formatSkillsForAgent } from '../core/skills.js';
|
|
10
|
+
import { handleSlashCommand } from '../commands/slash.js';
|
|
11
|
+
import { extractActivity, formatActivitySummary, parseJsonText } from '../core/activity.js';
|
|
12
|
+
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
13
|
+
import { enqueueProductionJob, formatQueue, productionLockBusy } from '../core/jobQueue.js';
|
|
14
|
+
|
|
15
|
+
const MAX_TOOL_ITERATIONS = 80;
|
|
16
|
+
const MAX_SPINNER_ARG_LENGTH = 96;
|
|
17
|
+
const AGENT_SLASH_COMMANDS = new Set([
|
|
18
|
+
'help',
|
|
19
|
+
'version',
|
|
20
|
+
'workspaces',
|
|
21
|
+
'new',
|
|
22
|
+
'workspace',
|
|
23
|
+
'use',
|
|
24
|
+
'config',
|
|
25
|
+
'status',
|
|
26
|
+
'services',
|
|
27
|
+
'skills',
|
|
28
|
+
'upload',
|
|
29
|
+
'uploads',
|
|
30
|
+
'queue',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const SHELL_RUN_COMMAND_TOOL = {
|
|
34
|
+
type: 'function',
|
|
35
|
+
function: {
|
|
36
|
+
name: 'shell__run_command',
|
|
37
|
+
description: [
|
|
38
|
+
'Run a deterministic wiki-manager slash command inside the current shell session.',
|
|
39
|
+
'Allowed commands: /workspaces, /new <name> [path], /use <workspace>, /config, /status, /services, /skills, /skills show <name>, /skills run <name>, /upload <path>, /upload convert <id|pending>, /uploads.',
|
|
40
|
+
'Do not use for arbitrary system shell commands, /mcp call, /wiki run, /start, /stop, /logs, or /exit.',
|
|
41
|
+
].join(' '),
|
|
42
|
+
parameters: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
additionalProperties: false,
|
|
45
|
+
properties: {
|
|
46
|
+
command: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
description: 'Slash command to run, for example "/workspaces", "/new demo", or "/use juno".',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
required: ['command'],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const WIKI_PLAN_SET_TOOL = {
|
|
57
|
+
type: 'function',
|
|
58
|
+
function: {
|
|
59
|
+
name: 'wiki__plan_set',
|
|
60
|
+
description: [
|
|
61
|
+
'Declare the ordered list of steps you intend to execute for this multi-step task.',
|
|
62
|
+
'Call this once at the start, before executing any step.',
|
|
63
|
+
'The orchestrator will track progress and show step status on each re-invocation.',
|
|
64
|
+
].join(' '),
|
|
65
|
+
parameters: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
additionalProperties: false,
|
|
68
|
+
properties: {
|
|
69
|
+
steps: {
|
|
70
|
+
type: 'array',
|
|
71
|
+
items: { type: 'string' },
|
|
72
|
+
description: 'Ordered step descriptions, e.g. ["CME export", "Production ingest", "Build", "Polish", "Email report"].',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
required: ['steps'],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const WIKI_PLAN_DONE_TOOL = {
|
|
81
|
+
type: 'function',
|
|
82
|
+
function: {
|
|
83
|
+
name: 'wiki__plan_done',
|
|
84
|
+
description: [
|
|
85
|
+
'Mark a plan step as done or failed.',
|
|
86
|
+
'Use for steps that complete synchronously (no _activity polling needed).',
|
|
87
|
+
'For async MCP jobs, the orchestrator marks steps automatically via activity matching.',
|
|
88
|
+
].join(' '),
|
|
89
|
+
parameters: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
properties: {
|
|
93
|
+
step: {
|
|
94
|
+
type: 'number',
|
|
95
|
+
description: 'Step number to update (1-based, matching the order declared in wiki__plan_set).',
|
|
96
|
+
},
|
|
97
|
+
status: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
enum: ['done', 'failed'],
|
|
100
|
+
description: 'Step outcome. Defaults to "done".',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
required: ['step'],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const AgentState = Annotation.Root({
|
|
109
|
+
input: Annotation(),
|
|
110
|
+
session: Annotation(),
|
|
111
|
+
response: Annotation(),
|
|
112
|
+
messages: Annotation({
|
|
113
|
+
reducer: (existing, update) => [...(existing ?? []), ...(update ?? [])],
|
|
114
|
+
default: () => [],
|
|
115
|
+
}),
|
|
116
|
+
toolIterations: Annotation({ default: () => 0 }),
|
|
117
|
+
pendingToolCalls: Annotation(),
|
|
118
|
+
readyToStream: Annotation(),
|
|
119
|
+
streamContext: Annotation(),
|
|
120
|
+
streamedInline: Annotation(),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
function commandList(session) {
|
|
124
|
+
return session.commands.map((command) => `/${command}`).join(', ');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function summarizeToolArguments(rawArguments) {
|
|
128
|
+
if (!rawArguments || rawArguments === '{}') return '';
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(rawArguments);
|
|
131
|
+
const entries = Object.entries(parsed ?? {});
|
|
132
|
+
if (entries.length === 0) return '';
|
|
133
|
+
const summary = entries
|
|
134
|
+
.slice(0, 4)
|
|
135
|
+
.map(([key, value]) => {
|
|
136
|
+
const rendered = typeof value === 'string' ? value : JSON.stringify(value);
|
|
137
|
+
return `${key}=${String(rendered).replace(/\s+/g, ' ').slice(0, 36)}`;
|
|
138
|
+
})
|
|
139
|
+
.join(', ');
|
|
140
|
+
return summary.length > MAX_SPINNER_ARG_LENGTH
|
|
141
|
+
? `${summary.slice(0, MAX_SPINNER_ARG_LENGTH - 3)}...`
|
|
142
|
+
: summary;
|
|
143
|
+
} catch {
|
|
144
|
+
return String(rawArguments).replace(/\s+/g, ' ').slice(0, MAX_SPINNER_ARG_LENGTH);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildQueuedResult(session, item, activeJobId = null) {
|
|
149
|
+
const message = activeJobId != null
|
|
150
|
+
? `Production job queued as ${item.id}; waiting for ${activeJobId}.`
|
|
151
|
+
: `Production job queued as ${item.id}; waiting for the current production lock.`;
|
|
152
|
+
session._onStep?.(`Queue: ${item.id} waiting for production lock`);
|
|
153
|
+
return JSON.stringify({
|
|
154
|
+
ok: false,
|
|
155
|
+
queued: true,
|
|
156
|
+
queueId: item.id,
|
|
157
|
+
status: item.status,
|
|
158
|
+
workspace: item.workspace,
|
|
159
|
+
...(activeJobId != null ? { activeJobId } : {}),
|
|
160
|
+
message,
|
|
161
|
+
}, null, 2);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function basename(value) {
|
|
165
|
+
return String(value ?? '').split('/').filter(Boolean).pop() ?? '';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function formatProductionProgress(payload) {
|
|
169
|
+
const progress = payload?.progress;
|
|
170
|
+
const job = payload?.job;
|
|
171
|
+
if (!progress && !job && !payload?.jobId) return null;
|
|
172
|
+
|
|
173
|
+
const percent = Number.isFinite(Number(progress?.percent))
|
|
174
|
+
? `${Math.round(Number(progress.percent))}%`
|
|
175
|
+
: null;
|
|
176
|
+
const sourceCount = Number(progress?.sourceCount);
|
|
177
|
+
const sourceIndex = Number(progress?.sourceIndex);
|
|
178
|
+
const sourceDoneCount = Number(progress?.sourceDoneCount);
|
|
179
|
+
const fileProgress = Number.isFinite(sourceCount) && sourceCount > 0
|
|
180
|
+
? Number.isFinite(sourceIndex)
|
|
181
|
+
? `file ${Math.min(sourceCount, sourceIndex + 1)}/${sourceCount}`
|
|
182
|
+
: Number.isFinite(sourceDoneCount)
|
|
183
|
+
? `files ${Math.min(sourceCount, sourceDoneCount)}/${sourceCount}`
|
|
184
|
+
: null
|
|
185
|
+
: null;
|
|
186
|
+
const batchProgress = progress?.batchCount
|
|
187
|
+
? `batch ${Number(progress.batchIndex ?? 0) + 1}/${progress.batchCount}`
|
|
188
|
+
: null;
|
|
189
|
+
const progressDetail = batchProgress && /^batch\s+\d+\/\d+/i.test(String(progress?.detail ?? ''))
|
|
190
|
+
? null
|
|
191
|
+
: progress?.detail;
|
|
192
|
+
const parts = [
|
|
193
|
+
progress?.phase ?? progress?.currentStep ?? job?.type,
|
|
194
|
+
job?.status ?? payload?.status,
|
|
195
|
+
percent,
|
|
196
|
+
fileProgress,
|
|
197
|
+
batchProgress,
|
|
198
|
+
progress?.source ? basename(progress.source) : null,
|
|
199
|
+
progress?.template ? basename(progress.template) : null,
|
|
200
|
+
progress?.deliverable ? basename(progress.deliverable) : null,
|
|
201
|
+
progressDetail,
|
|
202
|
+
progress?.instructionCount ? `${progress.instructionCount} instructions` : null,
|
|
203
|
+
progress?.lastEvent ? `last ${progress.lastEvent}` : null,
|
|
204
|
+
].filter(Boolean);
|
|
205
|
+
|
|
206
|
+
return parts.length > 0 ? `Production: ${parts.join(' · ')}` : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function normalizeShellCommand(value) {
|
|
210
|
+
const command = String(value ?? '').trim();
|
|
211
|
+
return command.startsWith('/') ? command : `/${command}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function assertAgentSlashCommandAllowed(commandLine) {
|
|
215
|
+
const parts = commandLine.slice(1).trim().split(/\s+/).filter(Boolean);
|
|
216
|
+
const command = parts[0] ?? '';
|
|
217
|
+
if (!AGENT_SLASH_COMMANDS.has(command)) {
|
|
218
|
+
throw new Error(`Command is not available to the agent: /${command}`);
|
|
219
|
+
}
|
|
220
|
+
if (command === 'new' && parts.length < 2) {
|
|
221
|
+
throw new Error('Usage: /new <name> [path].');
|
|
222
|
+
}
|
|
223
|
+
if (command === 'workspace' && parts[1] !== 'init') {
|
|
224
|
+
throw new Error('Only /workspace init is available to the agent.');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function withActiveWorkspaceForExternalTool(session, server, tool, args) {
|
|
229
|
+
const needsWorkspace =
|
|
230
|
+
(server === 'documents' && tool.startsWith('documents_') && tool !== 'documents_status') ||
|
|
231
|
+
(server === 'cme' && tool.startsWith('cme_') && tool !== 'cme_export_cancel' && !(tool === 'cme_export_status' && args.job_id));
|
|
232
|
+
if (!needsWorkspace) return args;
|
|
233
|
+
if (!session.workspace) {
|
|
234
|
+
throw new Error(`No active workspace available for ${server}.${tool}. Use /use <workspace> first.`);
|
|
235
|
+
}
|
|
236
|
+
if (args.workspace && args.workspace !== session.workspace) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`${server}.${tool} targets workspace "${args.workspace}" but the active workspace is "${session.workspace}". Use /use ${args.workspace} first.`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return { ...args, workspace: session.workspace };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function runShellCommandTool(session, commandLine) {
|
|
245
|
+
const command = normalizeShellCommand(commandLine);
|
|
246
|
+
assertAgentSlashCommandAllowed(command);
|
|
247
|
+
session._onStep?.(`Shell: ${command}`);
|
|
248
|
+
const result = await handleSlashCommand(command, {
|
|
249
|
+
packageJson: session.packageJson ?? { version: '0.0.0' },
|
|
250
|
+
session,
|
|
251
|
+
onStep: session._onStep,
|
|
252
|
+
});
|
|
253
|
+
if (result.exit) {
|
|
254
|
+
throw new Error('/exit is not available to the agent.');
|
|
255
|
+
}
|
|
256
|
+
return result.output ?? 'Command completed.';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function rememberProductionProgress(session, payload, label) {
|
|
260
|
+
const job = payload?.job;
|
|
261
|
+
const jobId = payload?.jobId ?? job?.jobId;
|
|
262
|
+
if (!jobId && !label) return;
|
|
263
|
+
const status = job?.status ?? payload?.status ?? payload?.progress?.status ?? 'running';
|
|
264
|
+
session.productionActivity = {
|
|
265
|
+
jobId: jobId ?? session.productionActivity?.jobId ?? null,
|
|
266
|
+
status,
|
|
267
|
+
label: label ?? `Production: ${status}`,
|
|
268
|
+
terminal: ['done', 'failed', 'cancelled'].includes(String(status)),
|
|
269
|
+
updatedAt: new Date().toISOString(),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function emitAgentEvent(session, type, origin, payload = {}) {
|
|
274
|
+
dispatchAgentEvent(session, createAgentEvent(type, { origin, payload }));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function handleWikiTool(session, tool, args) {
|
|
278
|
+
if (tool === 'plan_set') {
|
|
279
|
+
const steps = Array.isArray(args.steps) ? args.steps : [];
|
|
280
|
+
emitAgentEvent(session, 'plan_set', 'tool', {
|
|
281
|
+
steps: steps.map((description, i) => ({
|
|
282
|
+
step: i + 1,
|
|
283
|
+
description: String(description),
|
|
284
|
+
status: 'pending',
|
|
285
|
+
})),
|
|
286
|
+
});
|
|
287
|
+
return `Plan registered: ${steps.length} step${steps.length !== 1 ? 's' : ''}.`;
|
|
288
|
+
}
|
|
289
|
+
if (tool === 'plan_done') {
|
|
290
|
+
const plan = session.headlessPlan;
|
|
291
|
+
if (!plan) return 'No active plan. Call wiki__plan_set first.';
|
|
292
|
+
const step = plan.find((s) => s.step === Number(args.step));
|
|
293
|
+
if (!step) return `Step ${args.step} not found (plan has ${plan.length} steps).`;
|
|
294
|
+
const status = args.status === 'failed' ? 'failed' : 'done';
|
|
295
|
+
emitAgentEvent(session, 'plan_step_updated', 'tool', { step: Number(args.step), status });
|
|
296
|
+
return `Step ${args.step} marked as ${status}.`;
|
|
297
|
+
}
|
|
298
|
+
return `Unknown wiki tool: ${tool}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function buildAgentSystemPrompt(state) {
|
|
302
|
+
const workspace = state.session.workspace ?? 'no workspace selected';
|
|
303
|
+
const wikirc = state.session.wikirc?.profile ?? 'no profile loaded';
|
|
304
|
+
const language = state.session.language ?? 'en-US';
|
|
305
|
+
const mcpTools = formatMcpToolsForAgent(state.session.mcp);
|
|
306
|
+
const skills = formatSkillsForAgent(state.session);
|
|
307
|
+
const customPrompt = state.session.systemPrompt ?? null;
|
|
308
|
+
|
|
309
|
+
const agentContext = [
|
|
310
|
+
'You are Donna, the terminal orchestrator agent for llm-wiki-manager.',
|
|
311
|
+
'The shell is agent-first: every input without a leading slash is routed to you.',
|
|
312
|
+
'Commands starting with / are deterministic primitives. You may run a safe subset through shell__run_command.',
|
|
313
|
+
`Reply language: ${language}.`,
|
|
314
|
+
`Current workspace: ${workspace}.`,
|
|
315
|
+
`Current wikirc profile: ${wikirc}.`,
|
|
316
|
+
`Available primitives: ${commandList(state.session)}.`,
|
|
317
|
+
'Connected MCP tools (use the server__tool naming convention for tool calls):',
|
|
318
|
+
mcpTools,
|
|
319
|
+
'Current local MCP job queue:',
|
|
320
|
+
formatQueue(state.session),
|
|
321
|
+
'Available skills:',
|
|
322
|
+
skills,
|
|
323
|
+
'You can call MCP tools directly using the provided tool functions.',
|
|
324
|
+
'When the user asks for an action that can be performed with connected MCP tools or safe primitives, do not answer with future intent such as "I will call...", "I am going to run...", or "launching..." unless you also call the tool in the same turn. Either call the tool now, ask for the exact missing required arguments, or explain the concrete blocker.',
|
|
325
|
+
'For CME configuration/setup/update requests, if a matching CME tool such as cme_setup is connected and the required arguments are known, call it immediately. If the CME server or tool is not connected, say which CME capability is missing and recommend the exact service/status primitive to inspect it. Do not invent a pending CME action in plain text.',
|
|
326
|
+
'For workspace-scoped external MCP tools, the orchestrator enforces workspace injection. When calling documents_* conversion tools or cme_* configuration/source/export tools, use the active workspace only. cme_export_status(job_id=...) and cme_export_cancel(job_id=...) can be used from any active workspace.',
|
|
327
|
+
'You can call shell__run_command for safe manager slash commands such as /workspaces, /new <name> [path], /use <workspace>, /config, /status, /services, /skills, /skills show <name>, and /skills run <name>.',
|
|
328
|
+
'Skills are workflow instructions, not executable code. When a user asks to run a skill, inspect it, propose the concrete primitive/tool plan, and ask for confirmation before costly or mutating actions.',
|
|
329
|
+
[
|
|
330
|
+
state.session.headless ? 'HEADLESS MODE ACTIVE. Execute the requested skill or task autonomously using available safe primitives and MCP tools. Do not ask for interactive confirmation unless the request is genuinely ambiguous or outside the loaded workspace.' : null,
|
|
331
|
+
'',
|
|
332
|
+
'You have two internal planning tools: wiki__plan_set and wiki__plan_done.',
|
|
333
|
+
'Prefer MCP tools that declare their own plan via _activity.plan.steps — when such a tool returns _activity, the shell creates and tracks the plan automatically without requiring wiki__plan_set.',
|
|
334
|
+
'Use wiki__plan_set when the MCP tool cannot declare its own plan or when the task spans multiple independent tools (e.g. CME export then email report). For a single self-describing async job, wiki__plan_set is optional.',
|
|
335
|
+
'',
|
|
336
|
+
'Task startup:',
|
|
337
|
+
' 1. If the next MCP tool returns _activity.plan.steps, call that tool directly; the shell will create the visible plan from the returned activity.',
|
|
338
|
+
' 2. If the tool cannot declare its own plan, call wiki__plan_set(steps=["Step description", ...]) before executing the first step.',
|
|
339
|
+
' Multi-tool example: wiki__plan_set(steps=["CME export", "Production pipeline", "Email report"])',
|
|
340
|
+
' 3. Immediately execute the first step using the appropriate MCP tool. Do not start step 2 in the same turn unless one async pipeline tool owns and declares the whole sequence.',
|
|
341
|
+
' For synchronous steps (result is immediate, no _activity polling), call wiki__plan_done(step=1) after confirming success.',
|
|
342
|
+
' For async MCP jobs (returns _activity with poll), the orchestrator tracks completion automatically.',
|
|
343
|
+
'',
|
|
344
|
+
state.session.headless ? [
|
|
345
|
+
'Headless follow-up turns — the orchestrator re-invokes you with:',
|
|
346
|
+
' (a) the original task,',
|
|
347
|
+
' (b) the current plan status — [✓] done / [✗] failed / [ ] pending,',
|
|
348
|
+
' (c) the just-completed activities.',
|
|
349
|
+
' Read the plan status. Find the first [ ] pending step. Execute it only.',
|
|
350
|
+
' Never re-execute a [✓] or [✗] step. Never skip a [ ] step.',
|
|
351
|
+
'',
|
|
352
|
+
'Final turn — when all steps are [✓] or [✗]: respond with a concise summary. Do not start new actions.',
|
|
353
|
+
].join('\n') : null,
|
|
354
|
+
'',
|
|
355
|
+
'On failure: if a completed activity is failed/error/cancelled, call wiki__plan_done(step=N, status="failed") then stop with a clear error report.',
|
|
356
|
+
].filter(Boolean).join('\n'),
|
|
357
|
+
'For service actions, recommend /services, /start, /stop or /logs with the exact service name.',
|
|
358
|
+
'Disambiguate export requests carefully.',
|
|
359
|
+
'Confluence/CME/source export means exporting external Confluence sources into raw/untracked: use cme MCP tools (`cme_export_run`, then `cme_export_status`). Never use production `type=export` for Confluence source export.',
|
|
360
|
+
'Wiki/deliverable/publication export means exporting generated deliverables from the wiki: use production MCP tools (`production_start_job` with `type:"export"` or pipeline steps). Require the deliverable path when exporting deliverables.',
|
|
361
|
+
'For ingest/build/export/polish/pipeline workflows, use production MCP tools. Do not route these through direct /wiki shortcuts. To chain multiple sequential steps (e.g. build then polish), always use a single production_start_job call with type="pipeline" and steps=["build","polish"] — never start them as separate jobs: the first job is asynchronous and the second would run before it completes. For existing deliverables where content stability matters, pass stabilize:true so the build step preserves unchanged sections; keep polish in the pipeline when publication output is requested. Do not ask the user to confirm between steps; start the pipeline call directly.',
|
|
362
|
+
'Long-running MCP jobs: do not call the same status tool more than once consecutively. When chaining jobs sequentially: (1) start the job, report job/activity id and status; (2) check status once — if done, proceed to the next step immediately; (3) if still running, report status, list the remaining steps, and return control; (4) when re-invoked, check status first, then proceed. Do not spin-poll (status → status → status with no new action between). The shell activity panel monitors non-terminal jobs automatically.',
|
|
363
|
+
'If production_start_job is returned as queued/waiting by the manager, report that it is waiting in the local queue and return control. Do not continue as if the production job has started.',
|
|
364
|
+
'For diagnostics, use /wiki run doctor when the user asks for doctor. Use /new <name> [path] to create/configure a new workspace. Use /wiki for index, or /wiki run index through the explicit backup hatch. Use /wiki run init only for explicit current-workspace llm-wiki init.',
|
|
365
|
+
'If an action requires tools or skills not available yet, explain the limitation and name the expected primitive.',
|
|
366
|
+
].join('\n');
|
|
367
|
+
|
|
368
|
+
return customPrompt ? `${customPrompt}\n\n${agentContext}` : agentContext;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function buildLimitedAgentResponse(state, reason = 'no workspace loaded with .wikirc.yaml') {
|
|
372
|
+
const workspace = state.session.workspace ?? 'no workspace selected';
|
|
373
|
+
const wikirc = state.session.wikirc?.profile ?? 'no profile loaded';
|
|
374
|
+
const language = state.session.language ?? 'en-US';
|
|
375
|
+
if (language.toLowerCase().startsWith('fr')) {
|
|
376
|
+
return [
|
|
377
|
+
`Donna est active. Workspace courant: ${workspace}.`,
|
|
378
|
+
`Profil wikirc courant: ${wikirc}.`,
|
|
379
|
+
'',
|
|
380
|
+
"Je suis le mode agent du shell: utilise `/agent` pour router les entrees libres vers ce graphe LangGraph, ou `/chat` pour revenir au chat direct.",
|
|
381
|
+
`Connexion LLM: mode limite (${reason}).`,
|
|
382
|
+
`Primitives disponibles maintenant: ${commandList(state.session)}.`,
|
|
383
|
+
'',
|
|
384
|
+
'Outils MCP connectes:',
|
|
385
|
+
formatMcpToolsForAgent(state.session.mcp),
|
|
386
|
+
'',
|
|
387
|
+
'Mode limite: workspace, Docker Compose, appels MCP, echappatoire /wiki, decouverte skills et mode headless sont branches.',
|
|
388
|
+
"Utilise `/help` pour voir les commandes deterministes disponibles.",
|
|
389
|
+
].join('\n');
|
|
390
|
+
}
|
|
391
|
+
return [
|
|
392
|
+
`Donna is active. Current workspace: ${workspace}.`,
|
|
393
|
+
`Current wikirc profile: ${wikirc}.`,
|
|
394
|
+
'',
|
|
395
|
+
'I am the shell agent mode: use `/agent` to route free text through this LangGraph graph, or `/chat` for direct chat.',
|
|
396
|
+
`LLM connection: limited mode (${reason}).`,
|
|
397
|
+
`Available primitives: ${commandList(state.session)}.`,
|
|
398
|
+
'',
|
|
399
|
+
'Connected MCP tools:',
|
|
400
|
+
formatMcpToolsForAgent(state.session.mcp),
|
|
401
|
+
'',
|
|
402
|
+
'Limited mode: workspace, Docker Compose tools, MCP calls, /wiki fallback, skill discovery, and headless mode are wired.',
|
|
403
|
+
'Use `/help` to see deterministic shell commands.',
|
|
404
|
+
].join('\n');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function createAgentGraph(options = {}) {
|
|
408
|
+
async function orchestratorNode(state) {
|
|
409
|
+
const llm = state.session.llm ?? options.llm ?? null;
|
|
410
|
+
|
|
411
|
+
if (!llm) {
|
|
412
|
+
return { response: buildLimitedAgentResponse(state), pendingToolCalls: null, readyToStream: false };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const iterations = state.toolIterations ?? 0;
|
|
416
|
+
if (iterations >= MAX_TOOL_ITERATIONS) {
|
|
417
|
+
return {
|
|
418
|
+
response: `[Donna] Tool-use cap reached after ${iterations} iterations.`,
|
|
419
|
+
pendingToolCalls: null,
|
|
420
|
+
readyToStream: false,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (iterations > 0) {
|
|
425
|
+
state.session._onStep?.(`[${iterations}/${MAX_TOOL_ITERATIONS}] synthesizing…`);
|
|
426
|
+
} else {
|
|
427
|
+
state.session._onStep?.('Agent: planning next action…');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const tools = [
|
|
431
|
+
SHELL_RUN_COMMAND_TOOL,
|
|
432
|
+
WIKI_PLAN_SET_TOOL,
|
|
433
|
+
WIKI_PLAN_DONE_TOOL,
|
|
434
|
+
...buildLlmTools(state.session.mcp),
|
|
435
|
+
];
|
|
436
|
+
const system = buildAgentSystemPrompt(state);
|
|
437
|
+
|
|
438
|
+
// On iteration 0: prior history is in state.messages, user input must be appended.
|
|
439
|
+
// On subsequent iterations: user message was already stored in state.messages by the
|
|
440
|
+
// iteration-0 return below, so use state.messages as-is.
|
|
441
|
+
const conversationMessages = iterations === 0
|
|
442
|
+
? [...(state.messages ?? []), { role: 'user', content: state.input }]
|
|
443
|
+
: (state.messages ?? []);
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
const useStreamWithTools = typeof llm.streamWithTools === 'function';
|
|
447
|
+
const result = useStreamWithTools
|
|
448
|
+
? await llm.streamWithTools({
|
|
449
|
+
system,
|
|
450
|
+
tools,
|
|
451
|
+
messages: conversationMessages,
|
|
452
|
+
onTextDelta: (delta) => {
|
|
453
|
+
emitAgentEvent(state.session, 'assistant_delta', 'llm', { delta });
|
|
454
|
+
state.session._onStream?.(delta);
|
|
455
|
+
},
|
|
456
|
+
signal: state.session._abortSignal,
|
|
457
|
+
})
|
|
458
|
+
: await llm.completeWithTools({
|
|
459
|
+
system,
|
|
460
|
+
tools,
|
|
461
|
+
messages: conversationMessages,
|
|
462
|
+
signal: state.session._abortSignal,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
if (result.tool_calls?.length > 0) {
|
|
466
|
+
state.session._onStreamReset?.();
|
|
467
|
+
state.session._onStep?.(`[${iterations + 1}/${MAX_TOOL_ITERATIONS}] ${result.tool_calls.length} MCP action${result.tool_calls.length > 1 ? 's' : ''} queued…`);
|
|
468
|
+
// On iteration 0 persist the user message too so it survives the loop.
|
|
469
|
+
const newMessages = iterations === 0
|
|
470
|
+
? [{ role: 'user', content: state.input }, result.message]
|
|
471
|
+
: [result.message];
|
|
472
|
+
return {
|
|
473
|
+
pendingToolCalls: result.tool_calls,
|
|
474
|
+
messages: newMessages,
|
|
475
|
+
toolIterations: iterations + 1,
|
|
476
|
+
readyToStream: false,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (useStreamWithTools) {
|
|
481
|
+
emitAgentEvent(state.session, 'assistant_message', 'llm', { content: result.content ?? '' });
|
|
482
|
+
// Text was streamed inline via session._onStream — no second LLM call needed.
|
|
483
|
+
const newMessages = iterations === 0
|
|
484
|
+
? [{ role: 'user', content: state.input }, result.message]
|
|
485
|
+
: [result.message];
|
|
486
|
+
return {
|
|
487
|
+
response: null,
|
|
488
|
+
pendingToolCalls: null,
|
|
489
|
+
readyToStream: false,
|
|
490
|
+
streamedInline: true,
|
|
491
|
+
messages: newMessages,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Fallback path (streamWithTools unavailable): hand off to runLine for streaming.
|
|
496
|
+
state.session._onStep?.('Agent: streaming final answer…');
|
|
497
|
+
if (typeof llm.stream === 'function') {
|
|
498
|
+
return {
|
|
499
|
+
response: null,
|
|
500
|
+
pendingToolCalls: null,
|
|
501
|
+
readyToStream: true,
|
|
502
|
+
streamContext: { system, messages: conversationMessages },
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
emitAgentEvent(state.session, 'assistant_message', 'llm', { content: result.content ?? '' });
|
|
506
|
+
return {
|
|
507
|
+
response: result.content ?? '',
|
|
508
|
+
pendingToolCalls: null,
|
|
509
|
+
readyToStream: false,
|
|
510
|
+
};
|
|
511
|
+
} catch (err) {
|
|
512
|
+
if (err.name === 'AbortError') throw err;
|
|
513
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
514
|
+
return { response: buildLimitedAgentResponse(state, `LLM indisponible: ${message}`), pendingToolCalls: null, readyToStream: false };
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function toolExecutorNode(state) {
|
|
519
|
+
const toolCalls = state.pendingToolCalls ?? [];
|
|
520
|
+
const toolResultMessages = [];
|
|
521
|
+
|
|
522
|
+
for (const call of toolCalls) {
|
|
523
|
+
const { server, tool } = parseToolCallName(call.function.name);
|
|
524
|
+
const argsSummary = summarizeToolArguments(call.function.arguments);
|
|
525
|
+
const isInternalWikiTool = server === 'wiki' && (tool === 'plan_set' || tool === 'plan_done');
|
|
526
|
+
const serverLabel = server === 'shell' ? 'Shell' : isInternalWikiTool ? 'Plan' : 'MCP';
|
|
527
|
+
const toolName = `${server}.${tool}`;
|
|
528
|
+
state.session._onStep?.(
|
|
529
|
+
`[${state.toolIterations}/${MAX_TOOL_ITERATIONS}] ${serverLabel} ${toolName}${argsSummary ? ` (${argsSummary})` : ''}`,
|
|
530
|
+
);
|
|
531
|
+
emitAgentEvent(state.session, 'tool_call_started', 'tool', {
|
|
532
|
+
callId: call.id,
|
|
533
|
+
name: toolName,
|
|
534
|
+
args: call.function.arguments ?? '{}',
|
|
535
|
+
summary: argsSummary || 'calling...',
|
|
536
|
+
});
|
|
537
|
+
// Immediate visible plan for any MCP call that doesn't yet have an _activity plan.
|
|
538
|
+
let minimalPlanActive = false;
|
|
539
|
+
if (!isInternalWikiTool && server !== 'shell' && !state.session.headlessPlan) {
|
|
540
|
+
minimalPlanActive = true;
|
|
541
|
+
emitAgentEvent(state.session, 'plan_set', 'tool', {
|
|
542
|
+
steps: [{ step: 1, id: null, description: toolName, status: 'running', _activityKey: null }],
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
let resultText;
|
|
546
|
+
let ok = true;
|
|
547
|
+
try {
|
|
548
|
+
let args = JSON.parse(call.function.arguments ?? '{}');
|
|
549
|
+
if (server === 'production' && tool === 'production_start_job' && state.session.workspace && !args.callerLabel) {
|
|
550
|
+
args = { ...args, callerLabel: `${state.session.workspace}/wiki-manager` };
|
|
551
|
+
}
|
|
552
|
+
if (server === 'shell' && tool === 'run_command') {
|
|
553
|
+
resultText = await runShellCommandTool(state.session, args.command);
|
|
554
|
+
} else if (isInternalWikiTool) {
|
|
555
|
+
resultText = handleWikiTool(state.session, tool, args);
|
|
556
|
+
} else if (server === 'production' && tool === 'production_start_job' && productionLockBusy(state.session)) {
|
|
557
|
+
const item = enqueueProductionJob(state.session, args, 'production lock busy');
|
|
558
|
+
resultText = buildQueuedResult(state.session, item);
|
|
559
|
+
if (minimalPlanActive) {
|
|
560
|
+
minimalPlanActive = false;
|
|
561
|
+
emitAgentEvent(state.session, 'plan_step_updated', 'tool', { step: 1, status: 'pending' });
|
|
562
|
+
}
|
|
563
|
+
} else {
|
|
564
|
+
args = withActiveWorkspaceForExternalTool(state.session, server, tool, args);
|
|
565
|
+
const result = await callMcpTool(state.session.mcp, server, tool, args, state.session._abortSignal);
|
|
566
|
+
resultText = formatMcpToolResult(result);
|
|
567
|
+
}
|
|
568
|
+
if (server === 'production') {
|
|
569
|
+
let payload = parseJsonText(resultText);
|
|
570
|
+
if (tool === 'production_start_job' && payload?.ok === false && payload?.error === 'workspace_busy') {
|
|
571
|
+
const item = enqueueProductionJob(state.session, args, 'workspace_busy');
|
|
572
|
+
resultText = buildQueuedResult(state.session, item, payload.activeJobId ?? null);
|
|
573
|
+
payload = parseJsonText(resultText);
|
|
574
|
+
if (minimalPlanActive) {
|
|
575
|
+
minimalPlanActive = false;
|
|
576
|
+
emitAgentEvent(state.session, 'plan_step_updated', 'tool', { step: 1, status: 'pending' });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const progressLabel = formatProductionProgress(payload);
|
|
580
|
+
if (progressLabel) state.session._onStep?.(progressLabel);
|
|
581
|
+
const activity = extractActivity(payload, { server, tool });
|
|
582
|
+
if (activity) {
|
|
583
|
+
emitAgentEvent(state.session, 'activity_upserted', 'tool', { activity });
|
|
584
|
+
} else {
|
|
585
|
+
rememberProductionProgress(state.session, payload, progressLabel);
|
|
586
|
+
}
|
|
587
|
+
} else if (!isInternalWikiTool && server !== 'shell') {
|
|
588
|
+
const payload = parseJsonText(resultText);
|
|
589
|
+
const activity = extractActivity(payload, { server, tool });
|
|
590
|
+
if (activity) emitAgentEvent(state.session, 'activity_upserted', 'tool', { activity });
|
|
591
|
+
const activityLabel = formatActivitySummary(server, tool, resultText);
|
|
592
|
+
if (activityLabel) state.session._onStep?.(activityLabel);
|
|
593
|
+
}
|
|
594
|
+
// Minimal plan was not replaced by a real _activity plan — mark done.
|
|
595
|
+
if (minimalPlanActive && state.session.headlessPlan?.[0]?._activityKey === null) {
|
|
596
|
+
emitAgentEvent(state.session, 'plan_step_updated', 'tool', { step: 1, status: 'done' });
|
|
597
|
+
}
|
|
598
|
+
} catch (err) {
|
|
599
|
+
if (err.name === 'AbortError' && state.session._abortSignal?.aborted) throw err;
|
|
600
|
+
ok = false;
|
|
601
|
+
resultText = `Error [${server}.${tool}]: ${err instanceof Error ? err.message : String(err)}`;
|
|
602
|
+
if (minimalPlanActive && state.session.headlessPlan?.[0]?._activityKey === null) {
|
|
603
|
+
emitAgentEvent(state.session, 'plan_step_updated', 'tool', { step: 1, status: 'failed' });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
emitAgentEvent(state.session, 'tool_call_result', 'tool', {
|
|
607
|
+
callId: call.id,
|
|
608
|
+
name: toolName,
|
|
609
|
+
ok,
|
|
610
|
+
result: resultText,
|
|
611
|
+
summary: ok ? 'done' : 'failed',
|
|
612
|
+
});
|
|
613
|
+
toolResultMessages.push({
|
|
614
|
+
role: 'tool',
|
|
615
|
+
tool_call_id: call.id,
|
|
616
|
+
content: resultText,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
messages: toolResultMessages,
|
|
622
|
+
pendingToolCalls: null,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function routeOrchestrator(state) {
|
|
627
|
+
if (state.pendingToolCalls?.length > 0) return 'tool_executor';
|
|
628
|
+
return END;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return new StateGraph(AgentState)
|
|
632
|
+
.addNode('orchestrator', orchestratorNode)
|
|
633
|
+
.addNode('tool_executor', toolExecutorNode)
|
|
634
|
+
.addEdge(START, 'orchestrator')
|
|
635
|
+
.addConditionalEdges('orchestrator', routeOrchestrator)
|
|
636
|
+
.addEdge('tool_executor', 'orchestrator')
|
|
637
|
+
.compile();
|
|
638
|
+
}
|