@dotdrelle/wiki-manager 0.6.34 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/bin/wiki-manager.js +2 -2
  2. package/docker-compose.yml +25 -1
  3. package/package.json +2 -2
  4. package/src/agent/graph.js +2 -19
  5. package/src/cli/wiki-manager.js +236 -129
  6. package/src/commands/slash.js +17 -1
  7. package/src/commands/slash.test.js +78 -0
  8. package/src/core/activity.js +14 -0
  9. package/src/core/agentEvents.js +24 -2
  10. package/src/core/agentLoop.js +164 -0
  11. package/src/core/agentLoop.test.js +85 -0
  12. package/src/core/cacert.js +4 -3
  13. package/src/core/compose.js +4 -3
  14. package/src/core/dockerCompose.test.js +14 -0
  15. package/src/core/documentIntake.test.js +7 -7
  16. package/src/core/env.js +6 -0
  17. package/src/core/jobQueue.js +18 -4
  18. package/src/core/mcp.js +1 -1
  19. package/src/core/queueStore.js +27 -0
  20. package/src/core/queueStore.test.js +31 -0
  21. package/src/core/startupCheck.js +1 -1
  22. package/src/core/startupCheck.test.js +66 -0
  23. package/src/core/wikirc.js +2 -2
  24. package/src/core/workspaces.js +8 -1
  25. package/src/runtime/auth.js +35 -0
  26. package/src/runtime/auth.test.js +21 -0
  27. package/src/runtime/client.js +108 -0
  28. package/src/runtime/lifecycle.js +60 -0
  29. package/src/runtime/queueStore.js +6 -0
  30. package/src/runtime/runner.js +73 -0
  31. package/src/runtime/server.js +160 -0
  32. package/src/runtime/server.test.js +53 -0
  33. package/src/runtime/store.js +292 -0
  34. package/src/runtime/store.test.js +103 -0
  35. package/src/runtime/supervisor.js +98 -0
  36. package/src/runtime/supervisor.test.js +99 -0
  37. package/src/shell/repl.js +132 -17
  38. package/src/shell/repl.test.js +38 -0
  39. package/src/shell/tui.tsx +4 -1
  40. package/src/shell/useAgent.ts +20 -2
  41. package/src/shell/useSession.ts +146 -11
  42. package/wiki-workspace +39 -30
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import test from 'node:test';
7
7
  import { handleSlashCommand } from './slash.js';
8
+ import { completionContext } from '../shell/repl.js';
8
9
 
9
10
  test('/workspace delete removes files and clears current session context after confirmation', async () => {
10
11
  const root = await mkdtemp(join(tmpdir(), 'wiki-manager-delete-workspace-'));
@@ -66,3 +67,80 @@ test('/new without a name shows usage', async () => {
66
67
 
67
68
  assert.match(result.output ?? '', /Usage/i);
68
69
  });
70
+
71
+ test('/use loads only workspaces and /config use switches wikirc profiles', async () => {
72
+ const root = await mkdtemp(join(tmpdir(), 'wiki-manager-use-profile-'));
73
+ const registryRoot = join(root, 'registry');
74
+ const registryPath = join(registryRoot, 'demo');
75
+ const workspacePath = join(root, 'workspace');
76
+ mkdirSync(registryPath, { recursive: true });
77
+ mkdirSync(workspacePath, { recursive: true });
78
+ writeFileSync(join(registryPath, '.env'), [
79
+ 'WORKSPACE_NAME=demo',
80
+ `WIKI_WORKSPACE_PATH=${workspacePath}`,
81
+ '',
82
+ ].join('\n'), 'utf8');
83
+ writeFileSync(join(workspacePath, '.wikirc.yaml'), [
84
+ 'language: fr',
85
+ 'llm:',
86
+ ' provider: default-provider',
87
+ ' model: default-model',
88
+ '',
89
+ ].join('\n'), 'utf8');
90
+ writeFileSync(join(workspacePath, '.wikirc.yaml.vpn'), [
91
+ 'language: fr',
92
+ 'llm:',
93
+ ' provider: vpn-provider',
94
+ ' model: vpn-model',
95
+ '',
96
+ ].join('\n'), 'utf8');
97
+
98
+ const previousDir = process.env.WIKI_WORKSPACES_DIR;
99
+ process.env.WIKI_WORKSPACES_DIR = registryRoot;
100
+
101
+ try {
102
+ const session = {};
103
+ const listResult = await handleSlashCommand('/use', {
104
+ packageJson: { version: 'test' },
105
+ session,
106
+ });
107
+ assert.match(listResult.output ?? '', /Workspaces/);
108
+ assert.match(listResult.output ?? '', /demo\tavailable/);
109
+ assert.doesNotMatch(listResult.output ?? '', /vpn\t\.wikirc\.yaml\.vpn/);
110
+
111
+ const useResult = await handleSlashCommand('/use demo', {
112
+ packageJson: { version: 'test' },
113
+ session,
114
+ });
115
+
116
+ assert.equal(session.workspace, 'demo');
117
+ assert.equal(session.wikirc.profile, 'default');
118
+ assert.match(useResult.output ?? '', /profile: default/);
119
+ assert.match(useResult.output ?? '', /\* default\t\.wikirc\.yaml/);
120
+ assert.match(useResult.output ?? '', /vpn\t\.wikirc\.yaml\.vpn/);
121
+ assert.match(useResult.output ?? '', /Switch config: \/config use <profile>/);
122
+
123
+ const invalidUse = await handleSlashCommand('/use demo vpn', {
124
+ packageJson: { version: 'test' },
125
+ session,
126
+ });
127
+ assert.match(invalidUse.output ?? '', /Usage: \/use <workspace>/);
128
+ assert.equal(session.wikirc.profile, 'default');
129
+
130
+ const result = await handleSlashCommand('/config use vpn', {
131
+ packageJson: { version: 'test' },
132
+ session,
133
+ });
134
+ assert.equal(session.workspace, 'demo');
135
+ assert.equal(session.wikirc.profile, 'vpn');
136
+ assert.match(result.output ?? '', /profile=vpn/);
137
+
138
+ const completion = completionContext('/use ', { commands: ['use'] });
139
+ assert.deepEqual(completion?.matches, ['demo']);
140
+ const configCompletion = completionContext('/config use ', session);
141
+ assert.deepEqual(configCompletion?.matches, ['default', 'vpn']);
142
+ } finally {
143
+ if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
144
+ else process.env.WIKI_WORKSPACES_DIR = previousDir;
145
+ }
146
+ });
@@ -222,6 +222,20 @@ export function formatActivitySummary(source, action, resultText) {
222
222
  return usefulLine ? `${source}.${action}: ${usefulLine.slice(0, 120)}` : null;
223
223
  }
224
224
 
225
+ export function activitySnapshot(session) {
226
+ return new Set(sessionActivities(session).map((a) => a.key));
227
+ }
228
+
229
+ export function newNonTerminalActivities(snapshotBefore, session) {
230
+ return sessionActivities(session).filter((a) => !snapshotBefore.has(a.key) && !a.terminal);
231
+ }
232
+
233
+ export function terminalFailures(activities) {
234
+ return activities.filter(
235
+ (a) => a.terminal && ['failed', 'error', 'cancelled', 'canceled'].includes(String(a.status).toLowerCase()),
236
+ );
237
+ }
238
+
225
239
  export function formatActivityError(source, action, err) {
226
240
  const message = err instanceof Error ? err.message : String(err);
227
241
  const lines = message
@@ -7,6 +7,18 @@ const SESSION_PROJECTION_EVENTS = new Set([
7
7
  'plan_step_updated',
8
8
  'activity_upserted',
9
9
  'run_error',
10
+ 'run_cancelled',
11
+ 'runtime_log',
12
+ ]);
13
+
14
+ // Events that can mutate state.plan in applyEvent() — only these warrant the
15
+ // before/after plan comparison below (runtime_log fires far more often and
16
+ // never touches the plan).
17
+ const PLAN_MUTATING_EVENTS = new Set([
18
+ 'run_started',
19
+ 'plan_set',
20
+ 'plan_step_updated',
21
+ 'activity_upserted',
10
22
  ]);
11
23
 
12
24
  export function createAgentEvent(type, { origin = 'system', payload = {}, runId = null, turnId = null } = {}) {
@@ -23,7 +35,8 @@ export function createAgentEvent(type, { origin = 'system', payload = {}, runId
23
35
 
24
36
  export function dispatchAgentEvent(session, event) {
25
37
  const normalized = event.id && event.ts ? event : createAgentEvent(event.type, event);
26
- const previousPlan = JSON.stringify(session.headlessPlan ?? null);
38
+ const tracksPlan = PLAN_MUTATING_EVENTS.has(normalized.type);
39
+ const previousPlan = tracksPlan ? JSON.stringify(session.headlessPlan ?? null) : null;
27
40
  session.agentEvents ??= [];
28
41
  session.agentEvents.push(normalized);
29
42
  session._agentProjectionState ??= createProjectionState();
@@ -31,10 +44,11 @@ export function dispatchAgentEvent(session, event) {
31
44
  session.agentProjection = publicProjection(session._agentProjectionState);
32
45
  if (SESSION_PROJECTION_EVENTS.has(normalized.type)) {
33
46
  applyAgentProjectionToSession(session, session.agentProjection);
34
- if (JSON.stringify(session.headlessPlan ?? null) !== previousPlan) {
47
+ if (tracksPlan && JSON.stringify(session.headlessPlan ?? null) !== previousPlan) {
35
48
  session._onPlanUpdate?.();
36
49
  }
37
50
  }
51
+ session._onAgentEvent?.(normalized, session.agentProjection);
38
52
  return normalized;
39
53
  }
40
54
 
@@ -133,10 +147,18 @@ function applyEvent(state, event) {
133
147
  case 'run_done':
134
148
  state.status = 'done';
135
149
  return;
150
+ case 'run_cancelled':
151
+ state.status = 'cancelled';
152
+ state.logs.push(String(event.payload?.message ?? 'Agent run cancelled.'));
153
+ return;
136
154
  case 'run_error':
137
155
  state.status = 'error';
138
156
  state.logs.push(String(event.payload?.message ?? 'Agent run failed.'));
139
157
  return;
158
+ case 'runtime_log':
159
+ state.logs.push(String(event.payload?.message ?? ''));
160
+ state.logs = state.logs.slice(-200);
161
+ return;
140
162
  default:
141
163
  return;
142
164
  }
@@ -0,0 +1,164 @@
1
+ import { buildAgentSystemPrompt, buildLimitedAgentResponse } from '../agent/graph.js';
2
+ import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
3
+ import { activitySnapshot, newNonTerminalActivities } from './activity.js';
4
+ import { extractHeadlessPlan, formatCompletedActivities, formatPlanStatus } from './plan.js';
5
+
6
+ export function abortError(message = 'Agent run cancelled.') {
7
+ const err = new Error(message);
8
+ err.name = 'AbortError';
9
+ return err;
10
+ }
11
+
12
+ export function throwIfAborted(signal, message) {
13
+ if (signal?.aborted) throw abortError(message);
14
+ }
15
+
16
+ export async function runAgentTurn(agent, session, input, {
17
+ messages = [],
18
+ signal = null,
19
+ } = {}) {
20
+ let streamedContent = '';
21
+ session._onStream = (delta) => { streamedContent += delta; };
22
+ session._onStreamReset = () => { streamedContent = ''; };
23
+ let result;
24
+ try {
25
+ result = await agent.invoke({ input, session, messages });
26
+ } finally {
27
+ delete session._onStream;
28
+ delete session._onStreamReset;
29
+ }
30
+ if (result.streamedInline) {
31
+ return streamedContent.trim() || buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
32
+ }
33
+ if (result.response != null) return result.response;
34
+ if (result.readyToStream && session.llm?.stream) {
35
+ const { system, messages: streamMessages = [] } = result.streamContext ?? {};
36
+ let content = '';
37
+ for await (const delta of session.llm.stream({
38
+ system: system ?? buildAgentSystemPrompt({ input, session }),
39
+ messages: streamMessages,
40
+ signal,
41
+ })) {
42
+ content += delta;
43
+ }
44
+ return content.trim() || buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
45
+ }
46
+ return buildLimitedAgentResponse({ input, session });
47
+ }
48
+
49
+ export async function runAgenticLoop(agent, session, initialInput, {
50
+ maxTurns,
51
+ timeoutMs,
52
+ signal = null,
53
+ runId = null,
54
+ runTurn = runAgentTurn,
55
+ waitForActivities,
56
+ planOrigin = 'llm',
57
+ onTurnStart = null,
58
+ onTurnResponse = null,
59
+ onAssistantMessage = null,
60
+ onPlanExtracted = null,
61
+ onPlanAlreadySet = null,
62
+ onComplete = null,
63
+ onPendingSteps = null,
64
+ onActivitiesStarted = null,
65
+ onActivitiesCompleted = null,
66
+ onMaxTurns = null,
67
+ abortMessage = 'Agent run cancelled.',
68
+ } = {}) {
69
+ if (!waitForActivities) throw new Error('runAgenticLoop requires waitForActivities.');
70
+ const conversationHistory = [];
71
+ let currentInput = initialInput;
72
+
73
+ for (let turn = 1; turn <= maxTurns; turn += 1) {
74
+ throwIfAborted(signal, abortMessage);
75
+ onTurnStart?.({ turn, maxTurns });
76
+
77
+ const snapshot = activitySnapshot(session);
78
+ const response = await runTurn(agent, session, currentInput, {
79
+ messages: conversationHistory,
80
+ signal,
81
+ });
82
+ onTurnResponse?.({ turn, response });
83
+ onAssistantMessage?.({ response, runId });
84
+
85
+ conversationHistory.push(
86
+ { role: 'user', content: currentInput },
87
+ { role: 'assistant', content: response },
88
+ );
89
+
90
+ if (turn === 1) {
91
+ if (session.headlessPlan === null) {
92
+ const extractedPlan = extractHeadlessPlan(response);
93
+ if (extractedPlan) {
94
+ dispatchAgentEvent(session, createAgentEvent('plan_set', {
95
+ origin: planOrigin,
96
+ runId,
97
+ payload: { steps: extractedPlan },
98
+ }));
99
+ onPlanExtracted?.({ steps: session.headlessPlan ?? extractedPlan, fallback: true });
100
+ }
101
+ } else {
102
+ onPlanAlreadySet?.({ steps: session.headlessPlan });
103
+ }
104
+ }
105
+
106
+ const newPending = newNonTerminalActivities(snapshot, session);
107
+ if (newPending.length === 0) {
108
+ const pendingSteps = (session.headlessPlan ?? []).filter((step) => step.status === 'pending');
109
+ if (pendingSteps.length === 0) {
110
+ onComplete?.();
111
+ return { ok: true };
112
+ }
113
+ onPendingSteps?.({ pendingSteps });
114
+ currentInput = pendingStepsPrompt(initialInput, session.headlessPlan);
115
+ continue;
116
+ }
117
+
118
+ onActivitiesStarted?.({ activities: newPending });
119
+ const waitResult = await waitForActivities(session, newPending, { timeoutMs, signal });
120
+ if (!waitResult.ok) {
121
+ return {
122
+ ok: false,
123
+ timedOut: Boolean(waitResult.timedOut),
124
+ completed: waitResult.completed ?? [],
125
+ waitResult,
126
+ };
127
+ }
128
+
129
+ const completed = waitResult.completed ?? [];
130
+ const summary = formatCompletedActivities(completed);
131
+ onActivitiesCompleted?.({ completed, summary });
132
+ currentInput = completedActivitiesPrompt(initialInput, session.headlessPlan, summary);
133
+ }
134
+
135
+ onMaxTurns?.({ maxTurns });
136
+ return { ok: false, maxTurns: true };
137
+ }
138
+
139
+ function pendingStepsPrompt(initialInput, plan) {
140
+ return [
141
+ 'Original task:',
142
+ initialInput,
143
+ '',
144
+ `Plan status:\n${formatPlanStatus(plan)}`,
145
+ '',
146
+ 'No new background activity was started in the previous turn.',
147
+ 'Continue the original plan. Start the next pending step only.',
148
+ 'If required information is missing and cannot be inferred, stop with a clear blocker.',
149
+ ].join('\n');
150
+ }
151
+
152
+ function completedActivitiesPrompt(initialInput, plan, summary) {
153
+ return [
154
+ 'Original task:',
155
+ initialInput,
156
+ '',
157
+ plan ? `Plan status:\n${formatPlanStatus(plan)}\n` : null,
158
+ 'Completed activities:',
159
+ summary,
160
+ '',
161
+ 'Continue the original plan. Start the next required step only.',
162
+ 'If all steps are complete, provide the final summary.',
163
+ ].filter(Boolean).join('\n');
164
+ }
@@ -0,0 +1,85 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
4
+ import { runAgenticLoop } from './agentLoop.js';
5
+
6
+ test('runAgenticLoop waits for new activities and continues with a completion summary', async () => {
7
+ const session = {
8
+ activities: {},
9
+ headlessPlan: null,
10
+ };
11
+ const inputs = [];
12
+ const callbacks = [];
13
+ const agent = {
14
+ async invoke({ input, session: turnSession }) {
15
+ inputs.push(input);
16
+ if (inputs.length === 1) {
17
+ dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
18
+ payload: {
19
+ activity: {
20
+ id: 'job-1',
21
+ source: 'production',
22
+ kind: 'job',
23
+ label: 'production job',
24
+ status: 'running',
25
+ terminal: false,
26
+ },
27
+ },
28
+ }));
29
+ return { response: 'Started production job.' };
30
+ }
31
+ return { response: 'All done.' };
32
+ },
33
+ };
34
+
35
+ const result = await runAgenticLoop(agent, session, 'Build workspace', {
36
+ maxTurns: 3,
37
+ timeoutMs: 1000,
38
+ waitForActivities: async (turnSession, startedActivities) => {
39
+ assert.equal(startedActivities.length, 1);
40
+ dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
41
+ payload: {
42
+ activity: {
43
+ id: 'job-1',
44
+ source: 'production',
45
+ kind: 'job',
46
+ label: 'production job',
47
+ status: 'done',
48
+ terminal: true,
49
+ },
50
+ },
51
+ }));
52
+ if (turnSession.headlessPlan?.[0]) turnSession.headlessPlan[0].status = 'done';
53
+ return { ok: true, completed: Object.values(turnSession.activities) };
54
+ },
55
+ onActivitiesStarted: ({ activities }) => callbacks.push(`started:${activities.length}`),
56
+ onActivitiesCompleted: ({ summary }) => callbacks.push(summary),
57
+ });
58
+
59
+ assert.equal(result.ok, true);
60
+ assert.equal(inputs.length, 2);
61
+ assert.match(inputs[1], /Completed activities:/);
62
+ assert.match(inputs[1], /production job-1: done/);
63
+ assert.deepEqual(callbacks, ['started:1', '- production job-1: done']);
64
+ });
65
+
66
+ test('runAgenticLoop extracts a fallback numbered plan from first response', async () => {
67
+ const session = {
68
+ activities: {},
69
+ headlessPlan: null,
70
+ };
71
+ const result = await runAgenticLoop({
72
+ async invoke() {
73
+ return { response: '1. Collect sources\n2. Build page' };
74
+ },
75
+ }, session, 'Plan task', {
76
+ maxTurns: 1,
77
+ timeoutMs: 1000,
78
+ waitForActivities: async () => assert.fail('No activities should be waited for.'),
79
+ });
80
+
81
+ assert.equal(result.ok, false);
82
+ assert.equal(result.maxTurns, true);
83
+ assert.equal(session.headlessPlan.length, 2);
84
+ assert.equal(session.headlessPlan[0].description, 'Collect sources');
85
+ });
@@ -40,8 +40,7 @@ function composeOverrideContent(cacertPath, services) {
40
40
  services: serviceEntries,
41
41
  };
42
42
  return [
43
- '# Generated by wiki-manager. Safe to delete.',
44
- '# Rewritten when --cacert is used with a Docker Compose command.',
43
+ '# Generated by wiki-manager. Edit freely; delete to regenerate.',
45
44
  YAML.stringify(doc).trimEnd(),
46
45
  '',
47
46
  ].join('\n');
@@ -61,6 +60,8 @@ export function ensureCacertComposeOverride(composeFilePath, fileName = 'cacert.
61
60
  const runtimeDir = managerRuntimeDir();
62
61
  mkdirSync(runtimeDir, { recursive: true });
63
62
  const overridePath = join(runtimeDir, fileName);
64
- writeFileSync(overridePath, composeOverrideContent(cacertPath, services), 'utf8');
63
+ if (!existsSync(overridePath)) {
64
+ writeFileSync(overridePath, composeOverrideContent(cacertPath, services), 'utf8');
65
+ }
65
66
  return (_cacertOverrideCache = overridePath);
66
67
  }
@@ -9,7 +9,7 @@ import { managerRoot } from './workspaces.js';
9
9
 
10
10
  const execFileAsync = promisify(execFile);
11
11
 
12
- export const COMPOSE_SERVICES = ['serve', 'mcp-http', 'production-mcp'];
12
+ export const COMPOSE_SERVICES = ['serve', 'mcp-http', 'agent-runtime', 'production-mcp'];
13
13
  const SERVICE_DESCRIPTION_LABEL = 'wiki-manager.description';
14
14
 
15
15
  const DEFAULT_SERVICE_ALIASES = {
@@ -17,6 +17,7 @@ const DEFAULT_SERVICE_ALIASES = {
17
17
  ui: ['serve'],
18
18
  wiki: ['mcp-http'],
19
19
  mcp: ['mcp-http'],
20
+ runtime: ['agent-runtime'],
20
21
  production: ['production-mcp'],
21
22
  };
22
23
 
@@ -113,6 +114,7 @@ function composeEnv(session) {
113
114
  ...cacertEnv(),
114
115
  WORKSPACE_NAME: session.workspace,
115
116
  WIKI_WORKSPACE_PATH: session.workspacePath,
117
+ ...(session.wikirc?.fileName && { WIKI_CONFIG_PATH: session.wikirc.fileName }),
116
118
  };
117
119
  }
118
120
 
@@ -303,8 +305,7 @@ export async function runWikiCli(session, args, options = {}) {
303
305
  if (!Array.isArray(args) || args.length === 0) {
304
306
  throw new Error('Usage: /wiki run <args...>');
305
307
  }
306
- const configEnv = session.wikirc?.fileName ? ['-e', `WIKI_CONFIG_PATH=${session.wikirc.fileName}`] : [];
307
- return runCompose(session, ['run', '--rm', ...configEnv, 'wiki', ...args], {
308
+ return runCompose(session, ['run', '--rm', 'wiki', ...args], {
308
309
  timeout: options.timeout ?? 180_000,
309
310
  maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
310
311
  onOutput: options.onOutput,
@@ -0,0 +1,14 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import YAML from 'yaml';
5
+
6
+ test('agent-runtime compose command passes runtime args to the image entrypoint', async () => {
7
+ const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
8
+ const compose = YAML.parse(raw);
9
+ const service = compose.services['agent-runtime'];
10
+
11
+ assert.equal(service.image, 'dotdrelle/llm-wiki-manager:latest');
12
+ assert.equal(service.command, 'runtime --host 0.0.0.0 --port 7788 --state-dir /state');
13
+ assert.ok(!service.command.startsWith('wiki-manager '));
14
+ });
@@ -16,16 +16,16 @@ test('document intake stores uploads without requiring documents MCP', async ()
16
16
  const source = path.join(root, 'rapport.pdf');
17
17
  await writeFile(source, 'fake pdf content');
18
18
  const session = {
19
- workspace: 'juno',
19
+ workspace: 'my-project',
20
20
  mcp: {},
21
21
  };
22
22
 
23
23
  try {
24
24
  const { record, converted } = await storeAndMaybeConvertDocument(session, source);
25
25
  assert.equal(converted, false);
26
- assert.equal(record.workspace, 'juno');
26
+ assert.equal(record.workspace, 'my-project');
27
27
  assert.equal(record.status, 'stored');
28
- assert.equal(record.agentPath.startsWith('/documents/input/juno/'), true);
28
+ assert.equal(record.agentPath.startsWith('/documents/input/my-project/'), true);
29
29
  assert.equal(await readFile(record.storedPath, 'utf8'), 'fake pdf content');
30
30
 
31
31
  const uploads = await listDocumentUploads(session);
@@ -45,7 +45,7 @@ test('document intake accepts quoted absolute paths with spaces', async () => {
45
45
  const source = path.join(root, 'Screenshot 2026-06-21 at 10.03.36.png');
46
46
  await writeFile(source, 'fake png content');
47
47
  const session = {
48
- workspace: 'juno',
48
+ workspace: 'my-project',
49
49
  mcp: {},
50
50
  };
51
51
 
@@ -66,7 +66,7 @@ test('document intake accepts double-quoted absolute paths with spaces', async (
66
66
  const source = path.join(root, 'scan avec espace.pdf');
67
67
  await writeFile(source, 'fake pdf content');
68
68
  const session = {
69
- workspace: 'juno',
69
+ workspace: 'my-project',
70
70
  mcp: {},
71
71
  };
72
72
 
@@ -87,7 +87,7 @@ test('document intake replaces an existing upload with the same original filenam
87
87
  process.env.AGENTS_DATA_DIR = agentsDataDir;
88
88
  const source = path.join(root, 'rapport.pdf');
89
89
  const session = {
90
- workspace: 'juno',
90
+ workspace: 'my-project',
91
91
  mcp: {},
92
92
  };
93
93
 
@@ -98,7 +98,7 @@ test('document intake replaces an existing upload with the same original filenam
98
98
  await mkdir(path.dirname(outputPath), { recursive: true });
99
99
  await writeFile(outputPath, 'old markdown');
100
100
 
101
- const manifest = path.join(agentsDataDir, 'documents', 'uploads', 'juno.jsonl');
101
+ const manifest = path.join(agentsDataDir, 'documents', 'uploads', 'my-project.jsonl');
102
102
  const firstRecord = JSON.parse(await readFile(manifest, 'utf8'));
103
103
  await writeFile(manifest, `${JSON.stringify({ ...firstRecord, outputPath })}\n`, 'utf8');
104
104
 
package/src/core/env.js CHANGED
@@ -15,6 +15,12 @@ export function managerRuntimeDir() {
15
15
  return join(managerStateDir(), '.wiki-manager');
16
16
  }
17
17
 
18
+ export function defaultRuntimeStateDir() {
19
+ return process.env.WIKI_MANAGER_STATE_DIR
20
+ ? resolve(process.env.WIKI_MANAGER_STATE_DIR)
21
+ : managerRuntimeDir();
22
+ }
23
+
18
24
  export function managerEnvFile() {
19
25
  return process.env.WIKI_MANAGER_ENV_FILE
20
26
  ? resolve(process.env.WIKI_MANAGER_ENV_FILE)
@@ -1,6 +1,7 @@
1
1
  import { extractActivity, parseJsonText, sessionActivities } from './activity.js';
2
2
  import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
3
3
  import { callMcpTool, formatMcpToolResult } from './mcp.js';
4
+ import { queueStoreFor } from './queueStore.js';
4
5
 
5
6
  const TERMINAL = new Set(['done', 'failed', 'cancelled', 'canceled', 'complete', 'completed', 'success', 'error']);
6
7
 
@@ -8,6 +9,10 @@ function now() {
8
9
  return new Date().toISOString();
9
10
  }
10
11
 
12
+ function notifyQueueUpdate(session) {
13
+ queueStoreFor(session).changed();
14
+ }
15
+
11
16
  function shortId() {
12
17
  return `q-${Math.random().toString(16).slice(2, 6)}`;
13
18
  }
@@ -17,8 +22,7 @@ function terminalStatus(status) {
17
22
  }
18
23
 
19
24
  export function ensureJobQueue(session) {
20
- session.jobQueue ??= [];
21
- return session.jobQueue;
25
+ return queueStoreFor(session).list();
22
26
  }
23
27
 
24
28
  export function productionLockBusy(session) {
@@ -41,6 +45,7 @@ export function enqueueProductionJob(session, args = {}, reason = 'waiting') {
41
45
  createdAt: now(),
42
46
  };
43
47
  ensureJobQueue(session).push(item);
48
+ notifyQueueUpdate(session);
44
49
  return item;
45
50
  }
46
51
 
@@ -85,10 +90,11 @@ export function formatQueue(session) {
85
90
  export function clearFinishedQueueItems(session) {
86
91
  const queue = ensureJobQueue(session);
87
92
  const before = queue.length;
88
- session.jobQueue = queue.filter((item) =>
93
+ const next = queue.filter((item) =>
89
94
  item.workspace !== session.workspace || !terminalStatus(item.status),
90
95
  );
91
- return before - session.jobQueue.length;
96
+ queueStoreFor(session).replace(next);
97
+ return before - next.length;
92
98
  }
93
99
 
94
100
  function findQueueItem(session, id) {
@@ -102,6 +108,7 @@ export async function cancelQueueItem(session, id) {
102
108
  const label = item.status === 'starting' ? 'starting' : 'queued';
103
109
  item.status = 'cancelled';
104
110
  item.finishedAt = now();
111
+ notifyQueueUpdate(session);
105
112
  return { ok: true, message: `Cancelled ${label} job ${id}.` };
106
113
  }
107
114
  if (item.status === 'running') {
@@ -109,6 +116,7 @@ export async function cancelQueueItem(session, id) {
109
116
  await callMcpTool(session.mcp, 'production', 'production_cancel_job', { jobId: item.jobId });
110
117
  item.status = 'cancelled';
111
118
  item.finishedAt = now();
119
+ notifyQueueUpdate(session);
112
120
  return { ok: true, message: `Cancellation requested for ${id} (${item.jobId}).` };
113
121
  }
114
122
  return { ok: false, message: `No cancel tool available for ${id}.` };
@@ -140,6 +148,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
140
148
 
141
149
  item.status = 'starting';
142
150
  item.startedAt = now();
151
+ notifyQueueUpdate(session);
143
152
  hooks.refresh?.();
144
153
  hooks.addLog?.(`queue: starting ${item.id} ${queueSummary(item.args)}`);
145
154
 
@@ -154,6 +163,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
154
163
  if (payload?.ok === false && payload?.error === 'workspace_busy') {
155
164
  item.status = 'waiting';
156
165
  item.reason = 'workspace_busy';
166
+ notifyQueueUpdate(session);
157
167
  hooks.addLog?.(`queue: ${item.id} still waiting, production lock busy`);
158
168
  hooks.refresh?.();
159
169
  return item;
@@ -164,6 +174,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
164
174
  item.jobId = activity.id;
165
175
  item.activityKey = activity.key;
166
176
  item.finishedAt = activity.terminal ? now() : undefined;
177
+ notifyQueueUpdate(session);
167
178
  dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
168
179
  origin: 'queue',
169
180
  payload: { activity },
@@ -171,6 +182,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
171
182
  } else {
172
183
  item.status = 'done';
173
184
  item.finishedAt = now();
185
+ notifyQueueUpdate(session);
174
186
  }
175
187
  hooks.addLog?.(`queue: ${item.id} ${item.status}${item.jobId ? ` job=${item.jobId}` : ''}`);
176
188
  hooks.refresh?.();
@@ -179,6 +191,7 @@ export async function startNextQueuedJob(session, hooks = {}) {
179
191
  item.status = 'failed';
180
192
  item.error = err instanceof Error ? err.message : String(err);
181
193
  item.finishedAt = now();
194
+ notifyQueueUpdate(session);
182
195
  hooks.addLog?.(`queue: ${item.id} failed · ${item.error}`);
183
196
  hooks.refresh?.();
184
197
  return item;
@@ -193,5 +206,6 @@ export function syncQueueWithActivity(session, activity) {
193
206
  item.activityKey = activity.key;
194
207
  item.finishedAt = activity.terminal ? now() : item.finishedAt;
195
208
  item.error = activity.error ?? item.error;
209
+ notifyQueueUpdate(session);
196
210
  return item;
197
211
  }
package/src/core/mcp.js CHANGED
@@ -211,7 +211,7 @@ async function mcpRequest(endpoint, method, params, signal, options = {}) {
211
211
  params: {
212
212
  protocolVersion: '2025-06-18',
213
213
  capabilities: {},
214
- clientInfo: { name: 'wiki-manager', version: '0.6.34' },
214
+ clientInfo: { name: 'wiki-manager', version: '0.7.3' },
215
215
  },
216
216
  }),
217
217
  });