@dotdrelle/wiki-manager 0.12.10 → 0.12.12
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 +9 -6
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/agent/graph.js +110 -23
- package/src/agent/graph.test.js +135 -0
- package/src/cli/wiki-manager.js +70 -4
- package/src/commands/slash.js +49 -9
- package/src/contracts/schemas.js +33 -0
- package/src/contracts/schemas.test.js +14 -0
- package/src/core/agentEvents.js +6 -0
- package/src/core/agentEvents.test.js +26 -0
- package/src/core/agentLoop.js +21 -16
- package/src/core/agentLoop.test.js +59 -9
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +2 -3
- package/src/core/mcp.test.js +0 -12
- package/src/core/plan.js +11 -4
- package/src/orchestrator/scheduler.js +24 -0
- package/src/orchestrator/scheduler.test.js +40 -1
- package/src/runtime/client.js +2 -1
- package/src/runtime/lifecycle.js +1 -1
- package/src/runtime/runner.js +116 -6
- package/src/runtime/runner.test.js +36 -1
- package/src/runtime/supervisor.js +61 -0
- package/src/runtime/supervisor.test.js +31 -0
- package/src/shell/LeftPane.tsx +13 -3
- package/src/shell/repl.js +12 -4
- package/src/shell/tui.tsx +26 -3
|
@@ -713,7 +713,9 @@ test('runRuntimeParallelPlan fails cleanly when scheduler budget is exceeded', a
|
|
|
713
713
|
assert.equal(result.budgetExceeded, true);
|
|
714
714
|
assert.equal(result.reason, 'max_tasks_exceeded');
|
|
715
715
|
assert.ok(session.agentEvents.some((event) => event.type === 'run_error' && event.payload?.budget?.reason === 'max_tasks_exceeded'));
|
|
716
|
-
|
|
716
|
+
// run_error now cancels leftover pending steps: a dead run must not leave
|
|
717
|
+
// ghost work in the panels or reappear at the next relaunch.
|
|
718
|
+
assert.equal(session.headlessPlan[0].status, 'cancelled');
|
|
717
719
|
});
|
|
718
720
|
|
|
719
721
|
test('runRuntimeParallelPlan retries a retryable task on a fallback agent', async () => {
|
|
@@ -915,3 +917,36 @@ async function waitFor(predicate, timeoutMs = 500) {
|
|
|
915
917
|
}
|
|
916
918
|
assert.fail('condition was not met before timeout');
|
|
917
919
|
}
|
|
920
|
+
|
|
921
|
+
test('runtime runs are seeded with the chat that preceded them', async () => {
|
|
922
|
+
// "Donna n'a pas de contexte" was literally true: runs started with an
|
|
923
|
+
// empty history, so the model reinvented the missing context. The seed
|
|
924
|
+
// carries the prior exchanges, minus the run's own triggering message.
|
|
925
|
+
const { conversationSeed } = await import('./runner.js');
|
|
926
|
+
const session = {
|
|
927
|
+
agentProjection: {
|
|
928
|
+
conversation: [
|
|
929
|
+
{ role: 'user', content: 'peux-tu configurer le CME ?' },
|
|
930
|
+
{ role: 'assistant', content: 'CME configuré sur confluent.meteo.fr. Veux-tu ingérer les documents en attente ?' },
|
|
931
|
+
{ role: 'user', content: 'oui lance l\'ingestion' },
|
|
932
|
+
],
|
|
933
|
+
},
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
const seed = conversationSeed(session, "oui lance l'ingestion");
|
|
937
|
+
assert.deepEqual(seed.map((message) => message.role), ['user', 'assistant']);
|
|
938
|
+
assert.match(seed[1].content, /confluent\.meteo\.fr/);
|
|
939
|
+
|
|
940
|
+
// Long entries are clipped, empty/technical roles dropped.
|
|
941
|
+
const noisy = {
|
|
942
|
+
agentProjection: {
|
|
943
|
+
conversation: [
|
|
944
|
+
{ role: 'command', content: 'Runtime is idle.' },
|
|
945
|
+
{ role: 'assistant', content: 'x'.repeat(5000) },
|
|
946
|
+
],
|
|
947
|
+
},
|
|
948
|
+
};
|
|
949
|
+
const clipped = conversationSeed(noisy, 'autre demande');
|
|
950
|
+
assert.equal(clipped.length, 1);
|
|
951
|
+
assert.equal(clipped[0].content.length, 2000);
|
|
952
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { openSync, readSync, closeSync, fstatSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, normalize, resolve } from 'node:path';
|
|
1
3
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
2
4
|
import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
3
5
|
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
@@ -144,9 +146,19 @@ export async function pollActivitiesOnce(session, {
|
|
|
144
146
|
}
|
|
145
147
|
session._activityLogCursors[key] = logTail.at(-1);
|
|
146
148
|
}
|
|
149
|
+
// The job log is terse — the actual narrative (per-document plans,
|
|
150
|
+
// LLM calls with token counts, apply operations) lives in the
|
|
151
|
+
// engine's trace file, whose path the status exposes. Tail it from
|
|
152
|
+
// the host and surface the significant events.
|
|
153
|
+
if (progress.traceFile) {
|
|
154
|
+
for (const line of readNewTraceLines(session, key, progress.traceFile)) {
|
|
155
|
+
emitRuntimeLog(session, `trace: ${line}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
147
158
|
if (polledActivity.terminal) {
|
|
148
159
|
delete session._activityLogKeys[key];
|
|
149
160
|
delete session._activityLogCursors?.[key];
|
|
161
|
+
delete session._activityTraceCursors?.[key];
|
|
150
162
|
await startNextQueuedJob(session, {
|
|
151
163
|
addLog: (message) => emitRuntimeLog(session, message),
|
|
152
164
|
});
|
|
@@ -210,3 +222,52 @@ export async function cancelActiveActivityJobs(session, { callTool = callMcpTool
|
|
|
210
222
|
}
|
|
211
223
|
return cancelled;
|
|
212
224
|
}
|
|
225
|
+
|
|
226
|
+
// Trace events worth surfacing in the chat-side log: plan/apply milestones,
|
|
227
|
+
// LLM calls (with token counts), warnings and errors. The raw trace is far
|
|
228
|
+
// chattier — streaming everything would recreate the noise the dedupe killed.
|
|
229
|
+
const TRACE_EVENT_PATTERN = /\b(llm:start|llm:end|llm:json|llm:error|ingest:plan|ingest:operations|ingest:apply|ingest:source|build:template|retrieval:|embedding:|WARN|ERROR)\b/;
|
|
230
|
+
const TRACE_MAX_BYTES_PER_POLL = 64 * 1024;
|
|
231
|
+
const TRACE_MAX_LINES_PER_POLL = 12;
|
|
232
|
+
|
|
233
|
+
export function readNewTraceLines(session, key, traceFile) {
|
|
234
|
+
const workspacePath = session?.workspacePath;
|
|
235
|
+
if (!workspacePath) return [];
|
|
236
|
+
// The trace path comes from an agent payload: never let it escape the
|
|
237
|
+
// workspace directory.
|
|
238
|
+
const resolved = resolve(workspacePath, normalize(String(traceFile)));
|
|
239
|
+
if (isAbsolute(String(traceFile)) || !resolved.startsWith(resolve(workspacePath))) return [];
|
|
240
|
+
let fd;
|
|
241
|
+
try {
|
|
242
|
+
fd = openSync(resolved, 'r');
|
|
243
|
+
const size = fstatSync(fd).size;
|
|
244
|
+
session._activityTraceCursors ??= {};
|
|
245
|
+
let offset = session._activityTraceCursors[key];
|
|
246
|
+
// First sighting (or file rotation): start near the end, not at byte 0 —
|
|
247
|
+
// replaying a long history would flood the panel.
|
|
248
|
+
if (offset == null || offset > size) offset = Math.max(0, size - 4096);
|
|
249
|
+
if (size <= offset) return [];
|
|
250
|
+
const length = Math.min(size - offset, TRACE_MAX_BYTES_PER_POLL);
|
|
251
|
+
const buffer = Buffer.alloc(length);
|
|
252
|
+
readSync(fd, buffer, 0, length, offset);
|
|
253
|
+
const chunk = buffer.toString('utf8');
|
|
254
|
+
// Only advance past COMPLETE lines so a partially-written line is
|
|
255
|
+
// re-read whole on the next poll.
|
|
256
|
+
const lastNewline = chunk.lastIndexOf('\n');
|
|
257
|
+
if (lastNewline === -1) return [];
|
|
258
|
+
session._activityTraceCursors[key] = offset + Buffer.byteLength(chunk.slice(0, lastNewline + 1), 'utf8');
|
|
259
|
+
return chunk
|
|
260
|
+
.slice(0, lastNewline)
|
|
261
|
+
.split('\n')
|
|
262
|
+
.map((line) => line.trim())
|
|
263
|
+
.filter((line) => line && TRACE_EVENT_PATTERN.test(line))
|
|
264
|
+
.slice(0, TRACE_MAX_LINES_PER_POLL)
|
|
265
|
+
// Strip the ISO timestamp + elapsed prefix: the runtime log adds its
|
|
266
|
+
// own clock, and the double timestamp ate half the panel width.
|
|
267
|
+
.map((line) => line.replace(/^\S+\s+\+[\d.]+(?:ms|s|m|h)\s+(INFO|WARN|ERROR)\s+/, (_m, level) => (level === 'INFO' ? '' : `${level} `)));
|
|
268
|
+
} catch {
|
|
269
|
+
return [];
|
|
270
|
+
} finally {
|
|
271
|
+
if (fd !== undefined) closeSync(fd);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -284,3 +284,34 @@ async function waitFor(predicate, timeoutMs = 250) {
|
|
|
284
284
|
}
|
|
285
285
|
assert.fail('Timed out waiting for condition.');
|
|
286
286
|
}
|
|
287
|
+
|
|
288
|
+
test('readNewTraceLines streams significant trace events with a per-activity cursor', async () => {
|
|
289
|
+
const { readNewTraceLines } = await import('./supervisor.js');
|
|
290
|
+
const { mkdtempSync, writeFileSync, appendFileSync, rmSync } = await import('node:fs');
|
|
291
|
+
const { tmpdir } = await import('node:os');
|
|
292
|
+
const { join } = await import('node:path');
|
|
293
|
+
const workspacePath = mkdtempSync(join(tmpdir(), 'trace-tail-'));
|
|
294
|
+
try {
|
|
295
|
+
const rel = '.wiki/logs/ingest-test.log';
|
|
296
|
+
const abs = join(workspacePath, rel);
|
|
297
|
+
(await import('node:fs')).mkdirSync(join(workspacePath, '.wiki/logs'), { recursive: true });
|
|
298
|
+
writeFileSync(abs, '2026-07-10T13:00:00.000Z +10ms INFO llm:start label=ingest_plan promptChars=42000\n');
|
|
299
|
+
const session = { workspacePath };
|
|
300
|
+
|
|
301
|
+
const first = readNewTraceLines(session, 'k1', rel);
|
|
302
|
+
assert.deepEqual(first, ['llm:start label=ingest_plan promptChars=42000']);
|
|
303
|
+
|
|
304
|
+
// No new content → nothing re-emitted.
|
|
305
|
+
assert.deepEqual(readNewTraceLines(session, 'k1', rel), []);
|
|
306
|
+
|
|
307
|
+
appendFileSync(abs, '2026-07-10T13:01:00.000Z +70s INFO noise: irrelevant heartbeat\n2026-07-10T13:02:00.000Z +130s WARN embedding:neutralized-input status=413\n');
|
|
308
|
+
const second = readNewTraceLines(session, 'k1', rel);
|
|
309
|
+
assert.deepEqual(second, ['WARN embedding:neutralized-input status=413']);
|
|
310
|
+
|
|
311
|
+
// Path traversal attempts are refused.
|
|
312
|
+
assert.deepEqual(readNewTraceLines(session, 'k1', '../../etc/passwd'), []);
|
|
313
|
+
assert.deepEqual(readNewTraceLines(session, 'k1', '/etc/passwd'), []);
|
|
314
|
+
} finally {
|
|
315
|
+
rmSync(workspacePath, { recursive: true, force: true });
|
|
316
|
+
}
|
|
317
|
+
});
|
package/src/shell/LeftPane.tsx
CHANGED
|
@@ -272,9 +272,16 @@ function renderMarkdownLines(lines: Array<{ text: string; isCode: boolean }>, ro
|
|
|
272
272
|
for (let index = 0; index < lines.length; index += 1) {
|
|
273
273
|
const { text, isCode } = lines[index];
|
|
274
274
|
if (isCode) {
|
|
275
|
-
|
|
276
|
-
|
|
275
|
+
const blockStarts = index === 0 || !lines[index - 1].isCode;
|
|
276
|
+
const blockEnds = index === lines.length - 1 || !lines[index + 1].isCode;
|
|
277
|
+
// Blank line before/after the block + 2-space inner padding: fenced
|
|
278
|
+
// blocks used to render as a dense background slab glued to the text.
|
|
279
|
+
if (blockStarts) output.push({ segments: [{ text: ' ', color: '#D6DEE8' }] });
|
|
280
|
+
const innerWidth = Math.max(8, columns - 4);
|
|
281
|
+
output.push(...wrapLine(text || ' ', innerWidth).map((piece) => ({
|
|
282
|
+
segments: [{ text: ` ${(piece || ' ').padEnd(innerWidth)} `, color: '#D6DEE8', bg: '#1A2235' }],
|
|
277
283
|
})));
|
|
284
|
+
if (blockEnds) output.push({ segments: [{ text: ' ', color: '#D6DEE8' }] });
|
|
278
285
|
continue;
|
|
279
286
|
}
|
|
280
287
|
|
|
@@ -375,7 +382,10 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
|
|
|
375
382
|
let inFence = false;
|
|
376
383
|
const lines: Array<{ text: string; isCode: boolean }> = [];
|
|
377
384
|
for (const line of raw.split('\n')) {
|
|
378
|
-
|
|
385
|
+
// LLM responses often indent fenced blocks as part of a list. Accept
|
|
386
|
+
// leading whitespace so ```bash / ~~~ fences are rendered as code
|
|
387
|
+
// instead of leaking their Markdown markers into the conversation.
|
|
388
|
+
if (/^\s*(`{3,}|~{3,})/.test(line)) { inFence = !inFence; continue; }
|
|
379
389
|
lines.push({ text: line, isCode: inFence });
|
|
380
390
|
}
|
|
381
391
|
return [
|
package/src/shell/repl.js
CHANGED
|
@@ -18,7 +18,15 @@ import { listWorkspaces } from '../core/workspaces.js';
|
|
|
18
18
|
import { fetchRuntimeState, postRuntimeApprove, postRuntimeCancel, postRuntimeControl, postRuntimeRun, postRuntimeShutdown, streamRuntimeEvents } from '../runtime/client.js';
|
|
19
19
|
import { versionWithBuild } from '../core/buildInfo.js';
|
|
20
20
|
|
|
21
|
-
marked
|
|
21
|
+
// Code blocks: marked-terminal's default paints a dense background block
|
|
22
|
+
// glued to the surrounding text. Indent the content, keep a plain style and
|
|
23
|
+
// guarantee a blank line before/after so fenced blocks breathe in the chat.
|
|
24
|
+
const CODE_INDENT = ' ';
|
|
25
|
+
const CODE_TINT = '\u001b[38;5;152m'; // soft blue-grey, readable on dark bg
|
|
26
|
+
const CODE_RESET = '\u001b[0m';
|
|
27
|
+
marked.use(markedTerminal({
|
|
28
|
+
code: (code) => `\n${String(code).split('\n').map((line) => `${CODE_INDENT}${CODE_TINT}${line}${CODE_RESET}`).join('\n')}\n`,
|
|
29
|
+
}));
|
|
22
30
|
// marked-terminal's text renderer extracts token.text (raw string) instead of
|
|
23
31
|
// calling parseInline(token.tokens), so inline Markdown inside list items is
|
|
24
32
|
// silently dropped. Patch it to call parseInline when tokens are available.
|
|
@@ -75,7 +83,7 @@ const COMMAND_COMPLETION_DESCRIPTIONS = {
|
|
|
75
83
|
'/chat': 'Switch free text to direct LLM chat without tools.',
|
|
76
84
|
'/agent': 'Switch free text to the LangGraph agent with tools.',
|
|
77
85
|
'/openui': 'Open the workspace web UI in the browser.',
|
|
78
|
-
'/run': 'Inspect, cancel,
|
|
86
|
+
'/run': 'Inspect, cancel, kill runtime runs, or start a capability run.',
|
|
79
87
|
'/approve': 'Approve a pending runtime run or tool.',
|
|
80
88
|
};
|
|
81
89
|
|
|
@@ -141,7 +149,7 @@ export function createSession() {
|
|
|
141
149
|
wikircConfig: null,
|
|
142
150
|
language: null,
|
|
143
151
|
mcp: null,
|
|
144
|
-
commands: ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'wiki', 'skills', 'upload', 'uploads', 'clear', 'chat', 'agent', 'openui', 'run', 'queue', 'approve'],
|
|
152
|
+
commands: ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'wiki', 'skills', 'upload', 'uploads', 'clear', 'chat', 'agent', 'openui', 'run', 'cancel', 'queue', 'approve'],
|
|
145
153
|
chatMode: true,
|
|
146
154
|
llm: null,
|
|
147
155
|
activities: {},
|
|
@@ -246,7 +254,7 @@ function completionValuesFor(parts, inputBuffer, session) {
|
|
|
246
254
|
if (command === '/upload' && parts[1] === 'convert' && tokenIndex === 2) return ['pending'];
|
|
247
255
|
if (command === '/uploads' && tokenIndex === 1) return ['clean', 'list'];
|
|
248
256
|
if (command === '/uploads' && previousToken === 'clean') return ['--older-than'];
|
|
249
|
-
if (command === '/run' && tokenIndex === 1) return ['status', 'cancel', 'kill'];
|
|
257
|
+
if (command === '/run' && tokenIndex === 1) return ['status', 'cancel', 'kill', 'capability'];
|
|
250
258
|
if (command === '/queue' && tokenIndex === 1) return ['cancel', 'clear'];
|
|
251
259
|
if (command === '/queue' && previousToken === 'cancel') {
|
|
252
260
|
return (session.jobQueue ?? [])
|
package/src/shell/tui.tsx
CHANGED
|
@@ -116,6 +116,29 @@ function App(props: {
|
|
|
116
116
|
// sync at every call site.
|
|
117
117
|
const [screen, setScreen] = createSignal<'startup' | 'setup' | 'main'>('startup');
|
|
118
118
|
let ctrlCTimer: ReturnType<typeof setTimeout> | null = null;
|
|
119
|
+
let exiting = false;
|
|
120
|
+
// Single exit path: the owned-runtime shutdown MUST happen here, on the
|
|
121
|
+
// user's actual exit gesture. render() resolves at MOUNT, so code placed
|
|
122
|
+
// after `await runOpenTuiShell(...)` runs while the shell is still on
|
|
123
|
+
// screen — 0.12.9 shipped that and killed the runtime mid-session.
|
|
124
|
+
const exitShell = () => {
|
|
125
|
+
if (exiting) return;
|
|
126
|
+
exiting = true;
|
|
127
|
+
void (async () => {
|
|
128
|
+
const messages: string[] = [];
|
|
129
|
+
try {
|
|
130
|
+
if (props.runtime?.url) {
|
|
131
|
+
const { shutdownOwnedRuntime } = await import('../runtime/lifecycle.js');
|
|
132
|
+
await shutdownOwnedRuntime(props.runtime, { log: (message: string) => { messages.push(message); } });
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// Best effort: never block the exit on runtime cleanup.
|
|
136
|
+
}
|
|
137
|
+
renderer.destroy();
|
|
138
|
+
// Print AFTER destroy so the note survives on the restored terminal.
|
|
139
|
+
for (const message of messages) console.log(`[wiki-manager] ${message}`);
|
|
140
|
+
})();
|
|
141
|
+
};
|
|
119
142
|
let copyHintTimer: ReturnType<typeof setTimeout> | null = null;
|
|
120
143
|
let selectionCopyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
121
144
|
let startupKeyboardEventId = 0;
|
|
@@ -141,7 +164,7 @@ function App(props: {
|
|
|
141
164
|
return;
|
|
142
165
|
}
|
|
143
166
|
void state.submitInput(value).then((result) => {
|
|
144
|
-
if (result?.exit)
|
|
167
|
+
if (result?.exit) exitShell();
|
|
145
168
|
});
|
|
146
169
|
};
|
|
147
170
|
|
|
@@ -263,7 +286,7 @@ function App(props: {
|
|
|
263
286
|
return;
|
|
264
287
|
}
|
|
265
288
|
if (exitHint()) {
|
|
266
|
-
|
|
289
|
+
exitShell();
|
|
267
290
|
return;
|
|
268
291
|
}
|
|
269
292
|
setExitHint(true);
|
|
@@ -378,7 +401,7 @@ function App(props: {
|
|
|
378
401
|
height={dimensions().height}
|
|
379
402
|
keyboardEvent={startupKeyboardEvent()}
|
|
380
403
|
onSelect={openAction}
|
|
381
|
-
onQuit={() =>
|
|
404
|
+
onQuit={() => exitShell()}
|
|
382
405
|
/>
|
|
383
406
|
</Show>
|
|
384
407
|
);
|