@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.
@@ -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
- assert.equal(session.headlessPlan[0].status, 'pending');
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
+ });
@@ -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
- output.push(...wrapLine(text || ' ', columns).map((piece) => ({
276
- segments: [{ text: piece || ' ', color: '#D6DEE8', bg: '#1A2235' }],
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
- if (/^(`{2,3}|~{2,3})/.test(line)) { inFence = !inFence; continue; }
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.use(markedTerminal());
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, or kill runtime runs.',
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) renderer.destroy();
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
- renderer.destroy();
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={() => renderer.destroy()}
404
+ onQuit={() => exitShell()}
382
405
  />
383
406
  </Show>
384
407
  );