@dotdrelle/wiki-manager 0.7.3 → 0.8.2

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.
@@ -0,0 +1,24 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+
5
+ test('wiki-workspace autostarts host runtime before workspace services', async () => {
6
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
7
+
8
+ assert.match(script, /ensure_runtime_up\(\) \{/);
9
+ assert.match(script, /WIKI_MANAGER_RUNTIME_AUTOSTART:-1/);
10
+ assert.match(script, /Starting host agent-runtime/);
11
+ assert.match(script, /start_workspace_services\(\) \{\n ensure_runtime_up\n compose_for_workspace "\$1" up -d serve mcp-http production-mcp/);
12
+ assert.match(script, /start_workspace_services "\$workspace"\n\n local serve_port prod_port/);
13
+ assert.match(script, /start_workspace_services "\$workspace"\n local serve_port production_port/);
14
+ assert.match(script, /ensure_runtime_up\n printf 'Starting mcp-http/);
15
+ });
16
+
17
+ test('wiki-workspace checks runtime pid command before killing', async () => {
18
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
19
+
20
+ assert.match(script, /runtime_pid_command\(\) \{/);
21
+ assert.match(script, /runtime_pid_matches\(\) \{/);
22
+ assert.match(script, /if ! runtime_pid_matches; then\n printf 'refusing to stop pid/);
23
+ assert.match(script, /kill "\$\(cat "\$pid_file"\)"/);
24
+ });
@@ -0,0 +1,113 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
+
4
+ const DEFAULT_APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
5
+
6
+ export function createApprovalManager(session, {
7
+ defaultTimeoutMs = DEFAULT_APPROVAL_TIMEOUT_MS,
8
+ } = {}) {
9
+ const pending = new Map();
10
+
11
+ async function requestApproval(request = {}) {
12
+ const scope = request.scope === 'tool' ? 'tool' : 'run';
13
+ const approvalId = request.approvalId ?? randomUUID();
14
+ const runId = request.runId ?? session._currentRunIdentity?.runId ?? null;
15
+ const timeoutMs = Number.isFinite(Number(request.timeoutMs))
16
+ ? Math.max(1, Number(request.timeoutMs))
17
+ : defaultTimeoutMs;
18
+ const eventType = scope === 'tool' ? 'tool_pending_approval' : 'run_pending_approval';
19
+ const approvedType = scope === 'tool' ? 'tool_approved' : 'run_approved';
20
+
21
+ dispatchAgentEvent(session, createAgentEvent(eventType, {
22
+ origin: 'runtime',
23
+ runId,
24
+ payload: {
25
+ approvalId,
26
+ runId,
27
+ itemId: request.itemId ?? null,
28
+ reason: request.reason ?? null,
29
+ tool: request.tool ?? null,
30
+ plan: request.plan ?? null,
31
+ timeoutMs,
32
+ },
33
+ }));
34
+
35
+ await new Promise((resolve, reject) => {
36
+ const timer = setTimeout(() => {
37
+ pending.delete(approvalId);
38
+ const err = new Error(`Approval timed out: ${approvalId}`);
39
+ err.name = 'ApprovalError';
40
+ reject(err);
41
+ }, timeoutMs);
42
+ const cleanup = () => {
43
+ clearTimeout(timer);
44
+ request.signal?.removeEventListener('abort', onAbort);
45
+ };
46
+ const onAbort = () => {
47
+ pending.delete(approvalId);
48
+ cleanup();
49
+ const err = new Error(`Approval cancelled: ${approvalId}`);
50
+ err.name = 'AbortError';
51
+ reject(err);
52
+ };
53
+ pending.set(approvalId, {
54
+ approvalId,
55
+ scope,
56
+ runId,
57
+ itemId: request.itemId ?? null,
58
+ resolve: () => {
59
+ pending.delete(approvalId);
60
+ cleanup();
61
+ resolve();
62
+ },
63
+ });
64
+ if (request.signal?.aborted) {
65
+ onAbort();
66
+ return;
67
+ }
68
+ request.signal?.addEventListener('abort', onAbort, { once: true });
69
+ });
70
+
71
+ dispatchAgentEvent(session, createAgentEvent(approvedType, {
72
+ origin: 'runtime',
73
+ runId,
74
+ payload: {
75
+ approvalId,
76
+ runId,
77
+ itemId: request.itemId ?? null,
78
+ },
79
+ }));
80
+ return { approved: true, approvalId, runId, itemId: request.itemId ?? null };
81
+ }
82
+
83
+ function approve({ approvalId = null, runId = null, itemId = null } = {}) {
84
+ const entry = approvalId
85
+ ? pending.get(approvalId)
86
+ : [...pending.values()].find((item) =>
87
+ (runId && item.runId === runId) || (itemId && item.itemId === itemId),
88
+ );
89
+ if (!entry) return { approved: false, reason: 'approval not found' };
90
+ entry.resolve();
91
+ return {
92
+ approved: true,
93
+ approvalId: entry.approvalId,
94
+ runId: entry.runId,
95
+ itemId: entry.itemId,
96
+ };
97
+ }
98
+
99
+ function list() {
100
+ return [...pending.entries()].map(([approvalId, item]) => ({
101
+ approvalId,
102
+ scope: item.scope,
103
+ runId: item.runId,
104
+ itemId: item.itemId,
105
+ }));
106
+ }
107
+
108
+ return {
109
+ requestApproval,
110
+ approve,
111
+ list,
112
+ };
113
+ }
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import test from 'node:test';
6
6
  import { resolveRuntimeAuthToken } from './auth.js';
7
+ import { assertRuntimeNode, runtimeNodeExecutable } from './lifecycle.js';
7
8
 
8
9
  test('resolveRuntimeAuthToken: loopback host does not require token', () => {
9
10
  const result = resolveRuntimeAuthToken({ host: '127.0.0.1', explicitToken: null });
@@ -19,3 +20,10 @@ test('resolveRuntimeAuthToken: exposed host generates and reuses token', () => {
19
20
  assert.equal(second.source, 'file');
20
21
  assert.equal(second.token, first.token);
21
22
  });
23
+
24
+ test('runtime lifecycle uses a Node executable with node:sqlite support', async () => {
25
+ const runtimeNode = await assertRuntimeNode(runtimeNodeExecutable());
26
+
27
+ assert.ok(runtimeNode.executable);
28
+ assert.ok(Number(runtimeNode.version.split('.')[0]) >= 22);
29
+ });
@@ -4,6 +4,12 @@ function base(url) {
4
4
  return url.replace(/\/$/, '');
5
5
  }
6
6
 
7
+ function runtimeEndpoint(url, path, workspace = null) {
8
+ const endpoint = new URL(`${base(url)}${path}`);
9
+ if (workspace) endpoint.searchParams.set('workspace', workspace);
10
+ return endpoint.toString();
11
+ }
12
+
7
13
  export function runtimeUrlFromEnv() {
8
14
  return process.env.WIKI_MANAGER_RUNTIME_URL ?? 'http://127.0.0.1:7788';
9
15
  }
@@ -11,8 +17,9 @@ export function runtimeUrlFromEnv() {
11
17
  export async function fetchRuntimeState({
12
18
  url = runtimeUrlFromEnv(),
13
19
  token = runtimeToken(),
20
+ workspace = null,
14
21
  } = {}) {
15
- const response = await fetch(`${base(url)}/state`, {
22
+ const response = await fetch(runtimeEndpoint(url, '/state', workspace), {
16
23
  headers: runtimeHeaders(token),
17
24
  });
18
25
  if (!response.ok) throw new Error(`Runtime state failed: HTTP ${response.status}`);
@@ -22,8 +29,9 @@ export async function fetchRuntimeState({
22
29
  export async function checkRuntimeHealth({
23
30
  url = runtimeUrlFromEnv(),
24
31
  token = runtimeToken(),
32
+ workspace = null,
25
33
  } = {}) {
26
- const response = await fetch(`${base(url)}/health`, {
34
+ const response = await fetch(runtimeEndpoint(url, '/health', workspace), {
27
35
  headers: runtimeHeaders(token),
28
36
  });
29
37
  if (!response.ok) return null;
@@ -34,14 +42,16 @@ export async function postRuntimeRun(input, {
34
42
  url = runtimeUrlFromEnv(),
35
43
  token = runtimeToken(),
36
44
  workspace = null,
45
+ evaluate = undefined,
46
+ replans = undefined,
37
47
  } = {}) {
38
- const response = await fetch(`${base(url)}/run`, {
48
+ const response = await fetch(runtimeEndpoint(url, '/run', workspace), {
39
49
  method: 'POST',
40
50
  headers: {
41
51
  ...runtimeHeaders(token),
42
52
  'Content-Type': 'application/json',
43
53
  },
44
- body: JSON.stringify({ input, workspace }),
54
+ body: JSON.stringify(Object.assign({ input, workspace }, evaluate !== undefined && { evaluate }, replans !== undefined && { replans })),
45
55
  });
46
56
  if (!response.ok) throw new Error(`Runtime run failed: HTTP ${response.status}`);
47
57
  return response.json();
@@ -50,8 +60,9 @@ export async function postRuntimeRun(input, {
50
60
  export async function postRuntimeCancel({
51
61
  url = runtimeUrlFromEnv(),
52
62
  token = runtimeToken(),
63
+ workspace = null,
53
64
  } = {}) {
54
- const response = await fetch(`${base(url)}/cancel`, {
65
+ const response = await fetch(runtimeEndpoint(url, '/cancel', workspace), {
55
66
  method: 'POST',
56
67
  headers: runtimeHeaders(token),
57
68
  });
@@ -59,6 +70,40 @@ export async function postRuntimeCancel({
59
70
  return response.json();
60
71
  }
61
72
 
73
+ export async function postRuntimeResume({
74
+ url = runtimeUrlFromEnv(),
75
+ token = runtimeToken(),
76
+ workspace = null,
77
+ } = {}) {
78
+ const response = await fetch(runtimeEndpoint(url, '/resume', workspace), {
79
+ method: 'POST',
80
+ headers: runtimeHeaders(token),
81
+ });
82
+ if (!response.ok) throw new Error(`Runtime resume failed: HTTP ${response.status}`);
83
+ return response.json();
84
+ }
85
+
86
+ export async function postRuntimeApprove({
87
+ url = runtimeUrlFromEnv(),
88
+ token = runtimeToken(),
89
+ workspace = null,
90
+ runId = null,
91
+ itemId = null,
92
+ approvalId = null,
93
+ } = {}) {
94
+ const endpoint = runtimeEndpoint(url, '/approve', workspace);
95
+ const parsed = new URL(endpoint);
96
+ if (runId) parsed.searchParams.set('runId', runId);
97
+ if (itemId) parsed.searchParams.set('itemId', itemId);
98
+ if (approvalId) parsed.searchParams.set('approvalId', approvalId);
99
+ const response = await fetch(parsed.toString(), {
100
+ method: 'POST',
101
+ headers: runtimeHeaders(token),
102
+ });
103
+ if (!response.ok) throw new Error(`Runtime approve failed: HTTP ${response.status}`);
104
+ return response.json();
105
+ }
106
+
62
107
  function runtimeHeaders(token) {
63
108
  return token ? { Authorization: `Bearer ${token}` } : {};
64
109
  }
@@ -67,8 +112,9 @@ export async function* streamRuntimeEvents({
67
112
  url = runtimeUrlFromEnv(),
68
113
  token = runtimeToken(),
69
114
  signal = null,
115
+ workspace = null,
70
116
  } = {}) {
71
- const response = await fetch(`${base(url)}/events/stream`, {
117
+ const response = await fetch(runtimeEndpoint(url, '/events/stream', workspace), {
72
118
  headers: { ...runtimeHeaders(token), Accept: 'text/event-stream' },
73
119
  signal,
74
120
  });
@@ -1,4 +1,4 @@
1
- import { spawn } from 'node:child_process';
1
+ import { execFile, spawn } from 'node:child_process';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { checkRuntimeHealth, runtimeUrlFromEnv } from './client.js';
@@ -9,6 +9,29 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
  const managerRoot = resolve(__dirname, '../..');
10
10
  const binPath = resolve(managerRoot, 'bin/wiki-manager.js');
11
11
 
12
+ export function runtimeNodeExecutable() {
13
+ return process.versions.bun
14
+ ? (process.env.WIKI_MANAGER_NODE_BIN ?? 'node')
15
+ : process.execPath;
16
+ }
17
+
18
+ export async function assertRuntimeNode(executable = runtimeNodeExecutable()) {
19
+ const version = await new Promise((resolveVersion, reject) => {
20
+ execFile(executable, ['-p', 'process.versions.node'], (err, stdout) => {
21
+ if (err) {
22
+ reject(new Error(`Runtime requires Node.js 22+; could not execute ${executable}. Set WIKI_MANAGER_NODE_BIN to a Node.js 22 binary.`));
23
+ return;
24
+ }
25
+ resolveVersion(String(stdout).trim());
26
+ });
27
+ });
28
+ const major = Number(String(version).split('.')[0]);
29
+ if (!Number.isInteger(major) || major < 22) {
30
+ throw new Error(`Runtime requires Node.js 22+ for node:sqlite; ${executable} is Node ${version}. Set WIKI_MANAGER_NODE_BIN to a Node.js 22 binary.`);
31
+ }
32
+ return { executable, version };
33
+ }
34
+
12
35
  export async function ensureRuntime({
13
36
  host = process.env.WIKI_MANAGER_RUNTIME_HOST ?? '0.0.0.0',
14
37
  port = Number(process.env.WIKI_MANAGER_RUNTIME_PORT ?? 7788),
@@ -21,7 +44,8 @@ export async function ensureRuntime({
21
44
  const existing = await runtimeHealthOrNull(url, auth.token);
22
45
  if (existing) return { url, started: false, health: existing, token: auth.token, tokenPath: auth.tokenPath };
23
46
 
24
- const child = spawn(process.execPath, [
47
+ const runtimeNode = await assertRuntimeNode();
48
+ const child = spawn(runtimeNode.executable, [
25
49
  binPath,
26
50
  'runtime',
27
51
  '--host',
@@ -45,7 +69,7 @@ export async function ensureRuntime({
45
69
  let health = null;
46
70
  while (Date.now() < deadline) {
47
71
  health = await runtimeHealthOrNull(url, auth.token);
48
- if (health) return { url, started: true, health, pid: child.pid, token: auth.token, tokenPath: auth.tokenPath };
72
+ if (health) return { url, started: true, health, pid: child.pid, token: auth.token, tokenPath: auth.tokenPath, node: runtimeNode };
49
73
  await new Promise((resolveDelay) => setTimeout(resolveDelay, 150));
50
74
  }
51
75
  throw new Error(`Runtime did not become healthy at ${url}`);
@@ -1,6 +1,6 @@
1
1
  import { createQueueStore } from '../core/queueStore.js';
2
2
 
3
- export function createSqliteQueueStore(store, session) {
4
- session.jobQueue = store.listQueue();
5
- return createQueueStore(session, { persist: () => store.saveQueue(session.jobQueue) });
3
+ export function createSqliteQueueStore(store, session, { workspace = session.workspace ?? null } = {}) {
4
+ session.jobQueue = store.listQueue({ workspace });
5
+ return createQueueStore(session, { persist: () => store.saveQueue(session.jobQueue, { workspace }) });
6
6
  }
@@ -1,8 +1,11 @@
1
1
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
2
2
  import { sessionActivities, terminalFailures } from '../core/activity.js';
3
3
  import { runAgenticLoop, throwIfAborted } from '../core/agentLoop.js';
4
+ import { formatPlanStatus } from '../core/plan.js';
4
5
  import { emitRuntimeLog, pollActivitiesOnce } from './supervisor.js';
5
6
 
7
+ const DEFAULT_MAX_REPLANS = 2;
8
+
6
9
  async function waitForRuntimeActivities(session, startedActivities, { timeoutMs, signal, pollBusy }) {
7
10
  const deadline = Date.now() + timeoutMs;
8
11
  const trackedKeys = new Set(startedActivities.map((activity) => activity.key));
@@ -71,3 +74,340 @@ export async function runRuntimeAgenticLoop(agent, session, initialInput, { sign
71
74
  },
72
75
  });
73
76
  }
77
+
78
+ export async function runRuntimeAgenticWorkflow(agent, session, input, {
79
+ initialInput = null,
80
+ signal = null,
81
+ timeoutMs,
82
+ maxTurns,
83
+ runId,
84
+ pollBusy,
85
+ evaluate = true,
86
+ maxReplans = resolveMaxReplans(),
87
+ } = {}) {
88
+ let currentInput = initialInput ?? input;
89
+ let replansLeft = Math.max(0, Math.floor(Number(maxReplans) || 0));
90
+
91
+ while (true) {
92
+ const result = await runRuntimeAgenticLoop(agent, session, currentInput, {
93
+ signal,
94
+ timeoutMs,
95
+ maxTurns,
96
+ runId,
97
+ pollBusy,
98
+ });
99
+ if (!result.ok) {
100
+ const trigger = replanTriggerFromLoopResult(result);
101
+ if (trigger && replansLeft > 0) {
102
+ const replanned = await replanRuntimeRun(session, input, trigger, {
103
+ runId,
104
+ signal,
105
+ replansLeft: replansLeft - 1,
106
+ });
107
+ if (replanned.ok) {
108
+ replansLeft -= 1;
109
+ currentInput = buildReplannedRunPrompt(input, trigger, replanned.steps);
110
+ continue;
111
+ }
112
+ }
113
+ dispatchAgentEvent(session, createAgentEvent('run_error', {
114
+ origin: 'runtime',
115
+ runId,
116
+ payload: {
117
+ runId,
118
+ message: runtimeLoopErrorMessage(result),
119
+ },
120
+ }));
121
+ return { ok: false, result };
122
+ }
123
+
124
+ const evaluation = await evaluateRuntimeRun(session, input, { runId, signal, evaluate });
125
+ if (evaluation) {
126
+ dispatchAgentEvent(session, createAgentEvent('run_evaluated', {
127
+ origin: 'runtime',
128
+ runId,
129
+ payload: {
130
+ runId,
131
+ ok: evaluation.ok,
132
+ reason: evaluation.reason,
133
+ suggestedAction: evaluation.suggestedAction ?? null,
134
+ },
135
+ }));
136
+ if (!evaluation.ok) {
137
+ if (replansLeft > 0) {
138
+ const trigger = {
139
+ kind: 'evaluation',
140
+ reason: evaluation.reason,
141
+ suggestedAction: evaluation.suggestedAction ?? null,
142
+ };
143
+ const replanned = await replanRuntimeRun(session, input, trigger, {
144
+ runId,
145
+ signal,
146
+ replansLeft: replansLeft - 1,
147
+ });
148
+ if (replanned.ok) {
149
+ replansLeft -= 1;
150
+ currentInput = buildReplannedRunPrompt(input, trigger, replanned.steps);
151
+ continue;
152
+ }
153
+ }
154
+ dispatchAgentEvent(session, createAgentEvent('run_error', {
155
+ origin: 'runtime',
156
+ runId,
157
+ payload: {
158
+ runId,
159
+ message: `Runtime evaluator rejected the run: ${evaluation.reason}`,
160
+ suggestedAction: evaluation.suggestedAction ?? null,
161
+ },
162
+ }));
163
+ return { ok: false, evaluation, evaluationRejected: true };
164
+ }
165
+ }
166
+
167
+ dispatchAgentEvent(session, createAgentEvent('run_done', {
168
+ origin: 'runtime',
169
+ runId,
170
+ payload: { runId },
171
+ }));
172
+ return { ok: true, evaluation };
173
+ }
174
+ }
175
+
176
+ export async function finishRuntimeRun(session, input, {
177
+ runId,
178
+ signal = null,
179
+ evaluate = true,
180
+ } = {}) {
181
+ const evaluation = await evaluateRuntimeRun(session, input, { runId, signal, evaluate });
182
+ if (evaluation) {
183
+ dispatchAgentEvent(session, createAgentEvent('run_evaluated', {
184
+ origin: 'runtime',
185
+ runId,
186
+ payload: {
187
+ runId,
188
+ ok: evaluation.ok,
189
+ reason: evaluation.reason,
190
+ suggestedAction: evaluation.suggestedAction ?? null,
191
+ },
192
+ }));
193
+ if (!evaluation.ok) {
194
+ dispatchAgentEvent(session, createAgentEvent('run_error', {
195
+ origin: 'runtime',
196
+ runId,
197
+ payload: {
198
+ runId,
199
+ message: `Runtime evaluator rejected the run: ${evaluation.reason}`,
200
+ suggestedAction: evaluation.suggestedAction ?? null,
201
+ },
202
+ }));
203
+ return { ok: false, evaluation, evaluationRejected: true };
204
+ }
205
+ }
206
+ dispatchAgentEvent(session, createAgentEvent('run_done', {
207
+ origin: 'runtime',
208
+ runId,
209
+ payload: { runId },
210
+ }));
211
+ return { ok: true, evaluation };
212
+ }
213
+
214
+ export async function evaluateRuntimeRun(session, input, {
215
+ runId = null,
216
+ signal = null,
217
+ evaluate = true,
218
+ } = {}) {
219
+ if (!shouldEvaluate(evaluate)) return null;
220
+ const llm = session.llm;
221
+ if (!llm || typeof llm.completeWithTools !== 'function') {
222
+ return fallbackEvaluation('Evaluator unavailable: no LLM completeWithTools client.');
223
+ }
224
+ try {
225
+ emitRuntimeLog(session, 'runtime: evaluating completed run');
226
+ const result = await llm.completeWithTools({
227
+ system: [
228
+ 'You are a strict evaluator for an agentic runtime run.',
229
+ 'Inspect whether the original task was accomplished using the final plan and recent conversation.',
230
+ 'Return only JSON with this exact shape: {"ok":boolean,"reason":"...","suggestedAction":string|null}.',
231
+ 'Use ok=false only when a concrete missing action, failed requirement, or wrong result is visible.',
232
+ ].join('\n'),
233
+ tools: [],
234
+ messages: [{ role: 'user', content: buildEvaluationPrompt(input, session, { runId }) }],
235
+ signal,
236
+ });
237
+ return normalizeEvaluation(parseJsonFenced(result.content, 'evaluator response'));
238
+ } catch (err) {
239
+ return fallbackEvaluation(`Evaluator unavailable: ${err instanceof Error ? err.message : String(err)}`);
240
+ }
241
+ }
242
+
243
+ function shouldEvaluate(value) {
244
+ if (value === false) return false;
245
+ const env = String(process.env.WIKI_MANAGER_EVALUATOR ?? '').trim().toLowerCase();
246
+ return !['0', 'false', 'off', 'no'].includes(env);
247
+ }
248
+
249
+ function buildEvaluationPrompt(input, session, { runId = null } = {}) {
250
+ const recentConversation = formatRecentConversation(session);
251
+ const activities = sessionActivities(session)
252
+ .slice(-12)
253
+ .map((activity) => `- ${activity.label ?? activity.id}: ${activity.status}${activity.error ? ` (${activity.error})` : ''}`)
254
+ .join('\n');
255
+ return [
256
+ runId ? `Run id: ${runId}` : null,
257
+ 'Original task:',
258
+ input || '(unknown)',
259
+ '',
260
+ session.headlessPlan ? `Final plan:\n${formatPlanStatus(session.headlessPlan)}` : 'Final plan: none',
261
+ activities ? `Recent activities:\n${activities}` : null,
262
+ recentConversation ? `Recent conversation:\n${recentConversation}` : null,
263
+ '',
264
+ 'Return JSON only.',
265
+ ].filter(Boolean).join('\n');
266
+ }
267
+
268
+ function parseJsonFenced(content, label = 'JSON response') {
269
+ const text = String(content ?? '').trim();
270
+ if (!text) throw new Error(`empty ${label}`);
271
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
272
+ return JSON.parse(fenced ? fenced[1].trim() : text);
273
+ }
274
+
275
+ function normalizeEvaluation(value) {
276
+ return {
277
+ ok: value?.ok === true,
278
+ reason: String(value?.reason ?? '').trim() || (value?.ok === true ? 'Task completed.' : 'Evaluator rejected the run.'),
279
+ suggestedAction: value?.suggestedAction == null ? null : String(value.suggestedAction),
280
+ };
281
+ }
282
+
283
+ function fallbackEvaluation(reason) {
284
+ return {
285
+ ok: true,
286
+ reason,
287
+ suggestedAction: null,
288
+ };
289
+ }
290
+
291
+ export async function replanRuntimeRun(session, input, trigger, {
292
+ runId = null,
293
+ signal = null,
294
+ replansLeft = 0,
295
+ } = {}) {
296
+ const llm = session.llm;
297
+ if (!llm || typeof llm.completeWithTools !== 'function') {
298
+ return { ok: false, reason: 'Replanner unavailable: no LLM completeWithTools client.' };
299
+ }
300
+ try {
301
+ emitRuntimeLog(session, 'runtime: replanning remaining work');
302
+ const result = await llm.completeWithTools({
303
+ system: [
304
+ 'You are a replanner for an agentic runtime run.',
305
+ 'Given the original objective, current plan, and failure reason, return only the remaining steps required.',
306
+ 'Do not include steps that are already done.',
307
+ 'Return only JSON with this exact shape: {"steps":["..."]}.',
308
+ ].join('\n'),
309
+ tools: [],
310
+ messages: [{ role: 'user', content: buildReplanPrompt(input, session, trigger) }],
311
+ signal,
312
+ });
313
+ const steps = normalizeReplan(parseJsonFenced(result.content, 'replan response').steps);
314
+ if (steps.length === 0) throw new Error('empty replan');
315
+ dispatchAgentEvent(session, createAgentEvent('run_replanned', {
316
+ origin: 'runtime',
317
+ runId,
318
+ payload: {
319
+ runId,
320
+ reason: trigger.reason,
321
+ plan: steps,
322
+ replansLeft,
323
+ },
324
+ }));
325
+ dispatchAgentEvent(session, createAgentEvent('plan_set', {
326
+ origin: 'runtime',
327
+ runId,
328
+ payload: {
329
+ steps: steps.map((description, index) => ({
330
+ step: index + 1,
331
+ description,
332
+ status: 'pending',
333
+ })),
334
+ },
335
+ }));
336
+ return { ok: true, steps };
337
+ } catch (err) {
338
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
339
+ }
340
+ }
341
+
342
+ function replanTriggerFromLoopResult(result) {
343
+ const failures = terminalFailures(result.completed ?? []);
344
+ const failure = failures[0];
345
+ if (!failure) return null;
346
+ return {
347
+ kind: 'activity_error',
348
+ reason: `${failure.label ?? failure.id ?? 'Activity'} ended with ${failure.status}${failure.error ? `: ${failure.error}` : ''}`,
349
+ suggestedAction: failure.error ?? null,
350
+ activity: failure,
351
+ };
352
+ }
353
+
354
+ function runtimeLoopErrorMessage(result) {
355
+ if (result.timedOut) return 'Runtime agentic loop timed out.';
356
+ if (result.maxTurns) return 'Runtime agentic loop reached max turns.';
357
+ const trigger = replanTriggerFromLoopResult(result);
358
+ return trigger?.reason ?? 'Runtime agentic loop failed.';
359
+ }
360
+
361
+ function buildReplanPrompt(input, session, trigger) {
362
+ const recentConversation = formatRecentConversation(session);
363
+ return [
364
+ 'Original task:',
365
+ input || '(unknown)',
366
+ '',
367
+ session.headlessPlan ? `Current plan:\n${formatPlanStatus(session.headlessPlan)}` : 'Current plan: none',
368
+ '',
369
+ `Failure source: ${trigger.kind}`,
370
+ `Failure reason: ${trigger.reason}`,
371
+ trigger.suggestedAction ? `Suggested action: ${trigger.suggestedAction}` : null,
372
+ recentConversation ? `Recent conversation:\n${recentConversation}` : null,
373
+ '',
374
+ 'Return only the remaining steps still required. Exclude already completed steps.',
375
+ ].filter(Boolean).join('\n');
376
+ }
377
+
378
+ function buildReplannedRunPrompt(input, trigger, steps) {
379
+ return [
380
+ 'Continue a replanned runtime run.',
381
+ '',
382
+ 'Original task:',
383
+ input || '(unknown)',
384
+ '',
385
+ `Replan reason: ${trigger.reason}`,
386
+ '',
387
+ 'New partial plan:',
388
+ ...steps.map((step, index) => `${index + 1}. ${step}`),
389
+ '',
390
+ 'Execute only the first pending replanned step. Do not repeat completed work.',
391
+ ].join('\n');
392
+ }
393
+
394
+ function normalizeReplan(steps) {
395
+ if (!Array.isArray(steps)) return [];
396
+ return steps
397
+ .map((step) => String(step ?? '').trim())
398
+ .filter(Boolean)
399
+ .slice(0, 12);
400
+ }
401
+
402
+ function formatRecentConversation(session, n = 12) {
403
+ const conversation = session.agentProjection?.conversation ?? [];
404
+ return conversation
405
+ .slice(-n)
406
+ .map((message) => `${message.role}: ${message.content}`)
407
+ .join('\n');
408
+ }
409
+
410
+ function resolveMaxReplans(value = process.env.WIKI_MANAGER_REPLANNER_MAX_REPLANS) {
411
+ const parsed = Number(value);
412
+ return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : DEFAULT_MAX_REPLANS;
413
+ }