@dotdrelle/wiki-manager 0.15.97 → 0.15.99
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/package.json +2 -2
- package/src/agent/graph.js +45 -7
- package/src/agent/graph.test.js +45 -0
- package/src/cli/wiki-manager.js +47 -33
- package/src/commands/slash.js +7 -2
- package/src/contracts/schemas.js +8 -17
- package/src/contracts/schemas.test.js +15 -0
- package/src/core/agentEvents.js +45 -7
- package/src/core/agentEvents.test.js +65 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +1 -1
- package/src/core/runtimeEventAdapter.js +99 -1
- package/src/core/runtimeEventAdapter.test.js +92 -2
- package/src/core/skillCompiler.test.js +1 -1
- package/src/core/testGate.test.js +33 -0
- package/src/core/toolLoop.js +14 -2
- package/src/core/toolLoop.test.js +28 -0
- package/src/orchestrator/dispatcher.js +19 -0
- package/src/orchestrator/knowledgeSignals.js +260 -0
- package/src/orchestrator/knowledgeSignals.test.js +193 -0
- package/src/orchestrator/proactiveReviewScheduler.js +240 -0
- package/src/orchestrator/proactiveReviewScheduler.test.js +243 -0
- package/src/orchestrator/providers/deepAgentsProvider.js +134 -29
- package/src/orchestrator/providers/deepAgentsProvider.test.js +138 -3
- package/src/orchestrator/resultAggregator.js +115 -1
- package/src/orchestrator/resultAggregator.test.js +138 -0
- package/src/runtime/controlClassify.test.js +31 -0
- package/src/runtime/runner.js +13 -4
- package/src/runtime/runner.test.js +20 -0
- package/src/runtime/server.js +256 -4
- package/src/runtime/server.test.js +13 -1
- package/src/runtime/store.js +1 -1
- package/src/runtime/store.test.js +5 -1
- package/src/shell/openExternal.js +43 -0
- package/src/shell/repl.js +1 -1
- package/wiki-workspace +0 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { classifyControlMessage } from './server.js';
|
|
4
|
+
|
|
5
|
+
const running = { running: true };
|
|
6
|
+
|
|
7
|
+
// A bare "yes" answers the runtime's own last prompt. Anything MORE than a
|
|
8
|
+
// bare yes is a request, and the rule used to be prefix-anchored only: every
|
|
9
|
+
// message merely STARTING on a yes was classified `observe` and answered with
|
|
10
|
+
// a status report, shadowing the modify_run and enqueue_run branches below it.
|
|
11
|
+
test('a bare confirmation during a run is a status check', async () => {
|
|
12
|
+
for (const input of ['oui', 'OK', 'vas-y', "d'accord", 'yes.', 'entendu !']) {
|
|
13
|
+
const result = await classifyControlMessage(input, running);
|
|
14
|
+
assert.equal(result.kind, 'observe', `expected observe for ${JSON.stringify(input)}`);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('a plan change that merely opens on a yes is still a plan change', async () => {
|
|
19
|
+
const result = await classifyControlMessage(
|
|
20
|
+
'oui, ajoute une étape de polish après le build',
|
|
21
|
+
running,
|
|
22
|
+
);
|
|
23
|
+
assert.equal(result.kind, 'modify_run');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('a new task that opens on a yes reaches the model classifier, not the status branch', async () => {
|
|
27
|
+
const result = await classifyControlMessage("vas-y lance l'export", running, {
|
|
28
|
+
llm: { complete: async () => 'action' },
|
|
29
|
+
});
|
|
30
|
+
assert.equal(result.kind, 'enqueue_run');
|
|
31
|
+
});
|
package/src/runtime/runner.js
CHANGED
|
@@ -9,7 +9,7 @@ import { createBudgetManager, BudgetExceededError } from '../orchestrator/budget
|
|
|
9
9
|
import { createDispatcher } from '../orchestrator/dispatcher.js';
|
|
10
10
|
import { approvalCovered, approvalRequestForTask } from '../orchestrator/approvalPolicy.js';
|
|
11
11
|
import { blockedByFailedDependency, tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
|
|
12
|
-
import { isFailed, isPending, isSkipped, isSuccessful, isTerminal, isUnknownStatus } from '../orchestrator/taskStatuses.js';
|
|
12
|
+
import { isCancelled, isFailed, isPending, isSkipped, isSuccessful, isTerminal, isUnknownStatus } from '../orchestrator/taskStatuses.js';
|
|
13
13
|
import { assertValidatedFragment } from '../orchestrator/planValidator.js';
|
|
14
14
|
import { createResultAggregator } from '../orchestrator/resultAggregator.js';
|
|
15
15
|
import { describePlanConcurrency, drainActive, startReadyTasks } from '../orchestrator/scheduler.js';
|
|
@@ -308,6 +308,7 @@ export async function announceRunOutcome(session, { runId, ok, signal = null } =
|
|
|
308
308
|
if (plan.length === 0) return;
|
|
309
309
|
let failed = 0;
|
|
310
310
|
let cancelled = 0;
|
|
311
|
+
let skipped = 0;
|
|
311
312
|
let completed = 0;
|
|
312
313
|
let pending = 0;
|
|
313
314
|
let firstError = null;
|
|
@@ -319,8 +320,14 @@ export async function announceRunOutcome(session, { runId, ok, signal = null } =
|
|
|
319
320
|
step?.error?.message ?? step?.error?.code ?? step?.error
|
|
320
321
|
?? step?.result?.error?.message ?? step?.result?.error?.code ?? '',
|
|
321
322
|
).trim() || null;
|
|
322
|
-
} else if (
|
|
323
|
+
} else if (isCancelled(status)) {
|
|
323
324
|
cancelled += 1;
|
|
325
|
+
} else if (isSkipped(status)) {
|
|
326
|
+
// A chain step abandoned because an earlier required one failed. It fell
|
|
327
|
+
// through every bucket, so a 3-step chain that failed at step 1
|
|
328
|
+
// announced "0/3 réussie(s), 1 en erreur" and never mentioned the two
|
|
329
|
+
// steps nobody ran — precisely the silence the announce rule forbids.
|
|
330
|
+
skipped += 1;
|
|
324
331
|
} else if (isSuccessful(status)) {
|
|
325
332
|
completed += 1;
|
|
326
333
|
} else if (isPending(status) || !isTerminal(status)) {
|
|
@@ -333,13 +340,15 @@ export async function announceRunOutcome(session, { runId, ok, signal = null } =
|
|
|
333
340
|
}
|
|
334
341
|
}
|
|
335
342
|
const total = plan.length;
|
|
336
|
-
const finished = ok && failed === 0 && cancelled === 0 &&
|
|
343
|
+
const finished = ok && failed === 0 && cancelled === 0 && skipped === 0
|
|
344
|
+
&& pending === 0 && completed === total;
|
|
337
345
|
const factLine = finished
|
|
338
346
|
? `Plan terminé avec succès — ${completed}/${total} tâche(s) réussie(s).`
|
|
339
347
|
: `Plan non terminé — ${completed}/${total} tâche(s) réussie(s)` +
|
|
340
348
|
`${pending ? `, ${pending} en attente (approbation ou exécution)` : ''}` +
|
|
341
349
|
`${failed ? `, ${failed} en erreur` : ''}` +
|
|
342
|
-
`${cancelled ? `, ${cancelled} annulée(s)` : ''}
|
|
350
|
+
`${cancelled ? `, ${cancelled} annulée(s)` : ''}` +
|
|
351
|
+
`${skipped ? `, ${skipped} abandonnée(s) faute d'une étape précédente` : ''}.` +
|
|
343
352
|
`${firstError ? ` Première erreur : ${firstError}.` : ''}`;
|
|
344
353
|
let content = factLine;
|
|
345
354
|
const llm = session.llm;
|
|
@@ -1306,6 +1306,26 @@ test('announceRunOutcome never calls a plan with pending tasks a success', async
|
|
|
1306
1306
|
assert.doesNotMatch(message.payload.content, /succès/i);
|
|
1307
1307
|
});
|
|
1308
1308
|
|
|
1309
|
+
test('announceRunOutcome names the steps a failed chain abandoned', async () => {
|
|
1310
|
+
// `skipped` fell through every bucket: a 3-step chain failing at step 1
|
|
1311
|
+
// announced "0/3 réussie(s), 1 en erreur" and never mentioned the two steps
|
|
1312
|
+
// nobody ran. "When something is skipped, say so where the panels read."
|
|
1313
|
+
const session = {
|
|
1314
|
+
agentEvents: [],
|
|
1315
|
+
agentProjection: null,
|
|
1316
|
+
headlessPlan: [
|
|
1317
|
+
{ id: 'a', description: 'Sync', status: 'failed' },
|
|
1318
|
+
{ id: 'b', description: 'Ingest', status: 'skipped' },
|
|
1319
|
+
{ id: 'c', description: 'Build', status: 'skipped' },
|
|
1320
|
+
],
|
|
1321
|
+
};
|
|
1322
|
+
await announceRunOutcome(session, { runId: 'run-3', ok: false });
|
|
1323
|
+
const message = session.agentEvents.find((event) => event.type === 'assistant_message');
|
|
1324
|
+
assert.match(message.payload.content, /non terminé/i);
|
|
1325
|
+
assert.match(message.payload.content, /1 en erreur/);
|
|
1326
|
+
assert.match(message.payload.content, /2 abandonnée\(s\)/);
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1309
1329
|
test('announceRunOutcome reports success only when every task finished', async () => {
|
|
1310
1330
|
const session = {
|
|
1311
1331
|
agentEvents: [],
|
package/src/runtime/server.js
CHANGED
|
@@ -10,6 +10,21 @@ import { tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
|
|
|
10
10
|
import { isActive, isCancelled, isFailed, isSuccessful } from '../orchestrator/taskStatuses.js';
|
|
11
11
|
import { approvalClassForTask } from '../orchestrator/approvalPolicy.js';
|
|
12
12
|
import { RUNTIME_SHUTDOWN_ABORT_REASON } from '../orchestrator/dispatcher.js';
|
|
13
|
+
import {
|
|
14
|
+
PROACTIVE_REVIEW_CAPABILITY,
|
|
15
|
+
buildProactiveReviewObjective,
|
|
16
|
+
createProactiveReviewScheduler,
|
|
17
|
+
normalizeProactiveConfig,
|
|
18
|
+
} from '../orchestrator/proactiveReviewScheduler.js';
|
|
19
|
+
import {
|
|
20
|
+
conflictFingerprint,
|
|
21
|
+
detectConceptConflicts,
|
|
22
|
+
detectStaleKnowledge,
|
|
23
|
+
readConceptLeaves,
|
|
24
|
+
readSourceRegistry,
|
|
25
|
+
readWikiPages,
|
|
26
|
+
staleFingerprint,
|
|
27
|
+
} from '../orchestrator/knowledgeSignals.js';
|
|
13
28
|
import { matchSkillInvocation } from '../core/skillInvocation.js';
|
|
14
29
|
import { reconcileControlQueue } from './controlDrain.js';
|
|
15
30
|
import { cancelControlChain, cancelQueuedControlItem } from './controlCancellation.js';
|
|
@@ -74,6 +89,17 @@ export function startRuntimeServer({
|
|
|
74
89
|
exitOnShutdown = process.env.WIKI_MANAGER_RUNTIME_CHILD === '1',
|
|
75
90
|
} = {}) {
|
|
76
91
|
const clients = new Set();
|
|
92
|
+
// Deterministic dedup/cooldown/budget for proactive reviews; the run itself
|
|
93
|
+
// stays the normal control-lane path.
|
|
94
|
+
const proactiveScheduler = createProactiveReviewScheduler({
|
|
95
|
+
db: store?.db,
|
|
96
|
+
pendingReviews: store?.getProjection ? () => (store.getProjection().controlQueue ?? [])
|
|
97
|
+
.filter((item) => item.proactiveReview && ['queued', 'running'].includes(item.status))
|
|
98
|
+
.map((item) => item.proactiveReview) : null,
|
|
99
|
+
});
|
|
100
|
+
// runId -> the review record that started it, so the slot is released exactly
|
|
101
|
+
// once when the run reaches a terminal state (persisted or not).
|
|
102
|
+
const proactiveRuns = new Map();
|
|
77
103
|
// Compiled objectives are private execution material. They deliberately do
|
|
78
104
|
// not enter events, projections, SSE, audit output or the runs table.
|
|
79
105
|
// When this runtime process started — used by ensureRuntime to detect that
|
|
@@ -508,6 +534,12 @@ export function startRuntimeServer({
|
|
|
508
534
|
sendJson(response, 400, { error: 'Missing input.' });
|
|
509
535
|
return;
|
|
510
536
|
}
|
|
537
|
+
// What the reader actually typed. `input` below may be replaced by a
|
|
538
|
+
// system fact block for the model; the THREAD must still show the
|
|
539
|
+
// reader's own words — the replacement was persisted as the
|
|
540
|
+
// `user_message` and replayed as history on every later turn, which is
|
|
541
|
+
// exactly the "raw facts never enter the thread" rule it broke.
|
|
542
|
+
const displayInput = input;
|
|
511
543
|
// Read-only chat turns intentionally remain available while an agent
|
|
512
544
|
// run is active. Other interactive turns still become control
|
|
513
545
|
// messages so they cannot start a competing agent decision.
|
|
@@ -603,7 +635,12 @@ export function startRuntimeServer({
|
|
|
603
635
|
return result.body;
|
|
604
636
|
}
|
|
605
637
|
}
|
|
606
|
-
return turn(context, {
|
|
638
|
+
return turn(context, {
|
|
639
|
+
...body,
|
|
640
|
+
input,
|
|
641
|
+
displayInput,
|
|
642
|
+
mode: readOnlyChat ? 'chat' : body.mode,
|
|
643
|
+
}, {
|
|
607
644
|
signal: controller.signal,
|
|
608
645
|
turnId,
|
|
609
646
|
});
|
|
@@ -849,6 +886,26 @@ export function startRuntimeServer({
|
|
|
849
886
|
const loginAttemptPruneTimer = setInterval(() => pruneLoginAttempts(), 10 * 60 * 1000);
|
|
850
887
|
loginAttemptPruneTimer.unref?.();
|
|
851
888
|
|
|
889
|
+
// The corpus clock. `aged` is caused by TIME, and a workspace that stops
|
|
890
|
+
// ingesting — precisely the one whose knowledge ages — would otherwise never
|
|
891
|
+
// re-run the detector; conflicts and vanished paths are edit-caused, so the
|
|
892
|
+
// ingest moment covers them. This tick therefore reads only the source
|
|
893
|
+
// registry (staleOnly), and the opt-in gating happens inside the scan.
|
|
894
|
+
const corpusScanMs = Number(process.env.WIKI_MANAGER_CORPUS_SCAN_INTERVAL_MS ?? 15 * 60 * 1000);
|
|
895
|
+
const corpusScanTimer = Number.isFinite(corpusScanMs) && corpusScanMs > 0
|
|
896
|
+
? setInterval(() => {
|
|
897
|
+
void (async () => {
|
|
898
|
+
try {
|
|
899
|
+
const context = await resolvedGetContext(null);
|
|
900
|
+
if (!context?.session) return;
|
|
901
|
+
const workspace = context.workspace ?? context.session.workspace ?? null;
|
|
902
|
+
if (workspace) emitCorpusSignals(context, workspace, { staleOnly: true });
|
|
903
|
+
} catch { /* a clock tick never breaks the server */ }
|
|
904
|
+
})();
|
|
905
|
+
}, corpusScanMs)
|
|
906
|
+
: null;
|
|
907
|
+
corpusScanTimer?.unref?.();
|
|
908
|
+
|
|
852
909
|
return new Promise((resolve, reject) => {
|
|
853
910
|
server.once('error', reject);
|
|
854
911
|
server.listen(port, host, () => {
|
|
@@ -861,6 +918,7 @@ export function startRuntimeServer({
|
|
|
861
918
|
drainControl: (context) => drainControlQueue(context),
|
|
862
919
|
close: () => new Promise((closeResolve, closeReject) => {
|
|
863
920
|
clearInterval(loginAttemptPruneTimer);
|
|
921
|
+
if (corpusScanTimer) clearInterval(corpusScanTimer);
|
|
864
922
|
for (const client of clients) client.response.end();
|
|
865
923
|
clients.clear();
|
|
866
924
|
server.close((err) => (err ? closeReject(err) : closeResolve()));
|
|
@@ -1018,6 +1076,14 @@ export function startRuntimeServer({
|
|
|
1018
1076
|
announceControlLaunch(context.session, body.publicInput ?? body.input, runWorkspace);
|
|
1019
1077
|
}
|
|
1020
1078
|
}
|
|
1079
|
+
// The trigger hook and the proactive marker belong to THIS run on the
|
|
1080
|
+
// session; the review that opened it is remembered so its concurrency slot
|
|
1081
|
+
// is released exactly once, whatever terminal state it reaches.
|
|
1082
|
+
if (context.session) context.session._onKnowledgeTrigger = (payload) => handleKnowledgeTrigger(context, payload);
|
|
1083
|
+
if (body.proactiveReview) {
|
|
1084
|
+
if (context.session) context.session._proactiveReview = { ...body.proactiveReview, runId };
|
|
1085
|
+
proactiveRuns.set(runId, body.proactiveReview);
|
|
1086
|
+
}
|
|
1021
1087
|
const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
|
|
1022
1088
|
runPromise
|
|
1023
1089
|
.catch((err) => {
|
|
@@ -1028,6 +1094,12 @@ export function startRuntimeServer({
|
|
|
1028
1094
|
context.session?._onRuntimeError?.(err, runId);
|
|
1029
1095
|
})
|
|
1030
1096
|
.finally(() => {
|
|
1097
|
+
const proactive = proactiveRuns.get(runId);
|
|
1098
|
+
if (proactive) {
|
|
1099
|
+
proactiveRuns.delete(runId);
|
|
1100
|
+
proactiveScheduler.release(proactive.workspace);
|
|
1101
|
+
}
|
|
1102
|
+
if (context.session?._proactiveReview?.runId === runId) context.session._proactiveReview = null;
|
|
1031
1103
|
context.running = false;
|
|
1032
1104
|
context.currentAbortController = null;
|
|
1033
1105
|
context.currentRunId = null;
|
|
@@ -1062,6 +1134,7 @@ export function startRuntimeServer({
|
|
|
1062
1134
|
// may still grant the pending run explicitly through --auto-approve.
|
|
1063
1135
|
...(item.chainId ? { requireApproval: true } : {}),
|
|
1064
1136
|
...(item.capabilityPlan !== undefined ? { capabilityPlan: item.capabilityPlan } : {}),
|
|
1137
|
+
...(item.proactiveReview ? { proactiveReview: item.proactiveReview } : {}),
|
|
1065
1138
|
...(item.chainId
|
|
1066
1139
|
? {
|
|
1067
1140
|
skillChain: {
|
|
@@ -1082,6 +1155,179 @@ export function startRuntimeServer({
|
|
|
1082
1155
|
});
|
|
1083
1156
|
}
|
|
1084
1157
|
|
|
1158
|
+
/*
|
|
1159
|
+
A knowledge fact is not an order: it is an opportunity, and the workspace
|
|
1160
|
+
says (opt-in) whether it wants one. The decision is deterministic — dedup,
|
|
1161
|
+
cooldown, budget, concurrency — and a refusal is said out loud, never a
|
|
1162
|
+
silent no. An accepted trigger queues a read-only `agent.review` through the
|
|
1163
|
+
normal control lane; nothing here mutates, creates a worktree or sends
|
|
1164
|
+
anything.
|
|
1165
|
+
*/
|
|
1166
|
+
function handleKnowledgeTrigger(context, payload) {
|
|
1167
|
+
const session = context?.session ?? null;
|
|
1168
|
+
const workspace = String(payload?.workspace ?? context?.workspace ?? '');
|
|
1169
|
+
const trigger = String(payload?.trigger ?? '');
|
|
1170
|
+
// The corpus detectors are reads that only make sense when the corpus just
|
|
1171
|
+
// changed. They feed this same scheduler — never a second one — and cannot
|
|
1172
|
+
// recurse, since a conflict/stale fact does not re-run the detectors.
|
|
1173
|
+
if (trigger === 'knowledge.ingested' || trigger === 'knowledge.rebuilt') {
|
|
1174
|
+
emitCorpusSignals(context, workspace);
|
|
1175
|
+
}
|
|
1176
|
+
const config = session?.wikircConfig?.proactiveReviews ?? null;
|
|
1177
|
+
let decision;
|
|
1178
|
+
try {
|
|
1179
|
+
decision = proactiveScheduler.decide({
|
|
1180
|
+
workspace, trigger, sourceVersion: payload?.sourceVersion ?? null, config,
|
|
1181
|
+
});
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
emitRuntimeLog(session, `proactive-review: skipped — budget state unavailable: ${error.message}`);
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
if (decision.action !== 'review') {
|
|
1187
|
+
const inFlight = proactiveScheduler.snapshot(workspace).inFlightTrigger;
|
|
1188
|
+
// The conflict detector runs first, so it can take the only slot; saying
|
|
1189
|
+
// so turns an effect of statement order into a stated precedence.
|
|
1190
|
+
const precedence = decision.reason === 'concurrency' && inFlight
|
|
1191
|
+
? ` — a ${inFlight} review already holds the slot`
|
|
1192
|
+
: '';
|
|
1193
|
+
emitRuntimeLog(
|
|
1194
|
+
session,
|
|
1195
|
+
`proactive-review: skipped (${decision.reason}${precedence}) for ${workspace || 'workspace'} [${trigger}]`,
|
|
1196
|
+
);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
const evidence = payload?.evidence ?? null;
|
|
1200
|
+
const record = {
|
|
1201
|
+
id: `review-${randomUUID()}`,
|
|
1202
|
+
workspace,
|
|
1203
|
+
trigger,
|
|
1204
|
+
sourceVersion: decision.sourceVersion,
|
|
1205
|
+
createdAt: new Date().toISOString(),
|
|
1206
|
+
budget: proactiveScheduler.snapshot(workspace),
|
|
1207
|
+
// The deterministic facts, so the filed note says WHAT the scan knew —
|
|
1208
|
+
// not only that a review happened. Persisted with the item's marker.
|
|
1209
|
+
...(evidence ? { evidence } : {}),
|
|
1210
|
+
};
|
|
1211
|
+
const objective = buildProactiveReviewObjective({
|
|
1212
|
+
trigger,
|
|
1213
|
+
sourceVersion: decision.sourceVersion,
|
|
1214
|
+
evidence,
|
|
1215
|
+
});
|
|
1216
|
+
let item;
|
|
1217
|
+
try {
|
|
1218
|
+
item = enqueueControlRequest(context, objective, {
|
|
1219
|
+
publicInput: objective,
|
|
1220
|
+
// Routing is EXPLICIT. The objective names evidence paths, which could
|
|
1221
|
+
// contain another capability's alias (`report`, `plan`, `check`…), and
|
|
1222
|
+
// the free-text resolver returns null on two hits. Naming the
|
|
1223
|
+
// capability removes that risk entirely.
|
|
1224
|
+
capabilityPlan: { capability: PROACTIVE_REVIEW_CAPABILITY, operation: 'run' },
|
|
1225
|
+
proactiveReview: record,
|
|
1226
|
+
});
|
|
1227
|
+
} catch (error) {
|
|
1228
|
+
// A reservation that never became a queued item must be UNDONE: burning a
|
|
1229
|
+
// budget unit and marking the version seen would silently cancel an audit
|
|
1230
|
+
// that was never enqueued.
|
|
1231
|
+
proactiveScheduler.release(workspace, { undo: true, sourceVersion: decision.sourceVersion });
|
|
1232
|
+
emitRuntimeLog(
|
|
1233
|
+
session,
|
|
1234
|
+
`proactive-review: could not queue the review — ${error instanceof Error ? error.message : String(error)}`,
|
|
1235
|
+
);
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
emitRuntimeLog(
|
|
1239
|
+
session,
|
|
1240
|
+
`proactive-review: queued ${item.id} (${trigger}) for ${workspace || 'workspace'} — ${PROACTIVE_REVIEW_CAPABILITY}, read-only`,
|
|
1241
|
+
);
|
|
1242
|
+
void startNextControlRequest(context);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/*
|
|
1246
|
+
The live-corpus read: two homonym leaves under one concept folder are a
|
|
1247
|
+
conflict the ingest plan never sees (they are already written). Pure fs +
|
|
1248
|
+
string work, no model. A conflict set is a stable fingerprint, so the same
|
|
1249
|
+
conflict dedups instead of re-firing every time the corpus is touched.
|
|
1250
|
+
*/
|
|
1251
|
+
function emitConflictSignal(context, workspace, session, workspacePath) {
|
|
1252
|
+
const { conflicts, total, dropped } = detectConceptConflicts(readConceptLeaves(workspacePath));
|
|
1253
|
+
if (total === 0) return;
|
|
1254
|
+
if (dropped > 0) {
|
|
1255
|
+
emitRuntimeLog(
|
|
1256
|
+
session,
|
|
1257
|
+
`knowledge-signals: ${total} conflict(s) found, ${dropped} beyond the ceiling are not listed (the fingerprint still counts them)`,
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
handleKnowledgeTrigger(context, {
|
|
1261
|
+
workspace,
|
|
1262
|
+
trigger: 'knowledge.conflict_detected',
|
|
1263
|
+
// The fingerprint includes the full count, so a conflict beyond the cap
|
|
1264
|
+
// still moves the version and is not deduped away.
|
|
1265
|
+
sourceVersion: conflictFingerprint(conflicts, total),
|
|
1266
|
+
// The exact facts travel to the agent and the filed note.
|
|
1267
|
+
evidence: { kind: 'conflict', items: conflicts },
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function emitStaleSignal(context, workspace, session, workspacePath, config) {
|
|
1272
|
+
const { stale, total, dropped, counts } = detectStaleKnowledge(readSourceRegistry(workspacePath), {
|
|
1273
|
+
rootDir: workspacePath,
|
|
1274
|
+
staleAfterDays: config.staleAfterDays,
|
|
1275
|
+
wikiPages: readWikiPages(workspacePath),
|
|
1276
|
+
});
|
|
1277
|
+
if (total === 0) return;
|
|
1278
|
+
if (dropped > 0) {
|
|
1279
|
+
// Counted PER NATURE: the ceiling hides evidence of several kinds, and a
|
|
1280
|
+
// single "N source(s)" would be false for the others.
|
|
1281
|
+
const breakdown = [
|
|
1282
|
+
counts.aged ? `${counts.aged} aged` : null,
|
|
1283
|
+
counts.orphan ? `${counts.orphan} orphan page(s)` : null,
|
|
1284
|
+
counts.vanishedArchive ? `${counts.vanishedArchive} vanished archive(s)` : null,
|
|
1285
|
+
counts.vanishedPage ? `${counts.vanishedPage} vanished page(s)` : null,
|
|
1286
|
+
].filter(Boolean).join(', ');
|
|
1287
|
+
emitRuntimeLog(
|
|
1288
|
+
session,
|
|
1289
|
+
`knowledge-signals: stale knowledge — ${breakdown}; ${dropped} beyond the ceiling are not listed`,
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
handleKnowledgeTrigger(context, {
|
|
1293
|
+
workspace,
|
|
1294
|
+
trigger: 'knowledge.stale',
|
|
1295
|
+
sourceVersion: staleFingerprint(stale, total),
|
|
1296
|
+
evidence: { kind: 'stale', counts, items: stale },
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/*
|
|
1301
|
+
The live-corpus read, run when an ingest/rebuild completes AND on the
|
|
1302
|
+
runtime's clock. The clock matters for `aged`: it is caused by TIME, and a
|
|
1303
|
+
workspace that stops ingesting — precisely the one whose knowledge ages —
|
|
1304
|
+
would otherwise never re-run the detector. Conflicts and vanished paths are
|
|
1305
|
+
caused by edits, so the ingest moment is enough for them; `staleOnly` keeps
|
|
1306
|
+
the periodic tick from re-walking every leaf it does not need.
|
|
1307
|
+
*/
|
|
1308
|
+
function emitCorpusSignals(context, workspace, { staleOnly = false } = {}) {
|
|
1309
|
+
const session = context?.session ?? null;
|
|
1310
|
+
// Stay OFF the corpus until the workspace actually opted in: reading every
|
|
1311
|
+
// leaf synchronously on the event loop that also serves both chats' SSE is
|
|
1312
|
+
// a cost the default (disabled) must not pay.
|
|
1313
|
+
const config = normalizeProactiveConfig(session?.wikircConfig?.proactiveReviews);
|
|
1314
|
+
if (!config.enabled) return;
|
|
1315
|
+
const wantsConflicts = !staleOnly && config.triggers.includes('knowledge.conflict_detected');
|
|
1316
|
+
const wantsStale = config.triggers.includes('knowledge.stale');
|
|
1317
|
+
if (!wantsConflicts && !wantsStale) return;
|
|
1318
|
+
const workspacePath = session?.workspacePath;
|
|
1319
|
+
if (!workspacePath) return;
|
|
1320
|
+
try {
|
|
1321
|
+
if (wantsConflicts) emitConflictSignal(context, workspace, session, workspacePath);
|
|
1322
|
+
if (wantsStale) emitStaleSignal(context, workspace, session, workspacePath, config);
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
emitRuntimeLog(
|
|
1325
|
+
session,
|
|
1326
|
+
`knowledge-signals: a corpus scan failed — ${error instanceof Error ? error.message : String(error)}`,
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1085
1331
|
function takePrivateControlInput(session, item) {
|
|
1086
1332
|
const privateControlInputs = privateControlInputsFor(session);
|
|
1087
1333
|
const input = privateControlInputs.get(item.id) ?? item.input;
|
|
@@ -1581,7 +1827,7 @@ function announceControlLaunch(session, input, workspace) {
|
|
|
1581
1827
|
Une file est un passage de témoin : ce qui doit survivre au parent voyage avec
|
|
1582
1828
|
le message, pas dans l'état de celui qui l'a posté.
|
|
1583
1829
|
*/
|
|
1584
|
-
function enqueueControlRequest(context, input, { publicInput = null, capabilityPlan, chainId, chainSequence, skillName, skillExecution, skillStack, selectionKind, optional = false, continueOnFailure = false } = {}) {
|
|
1830
|
+
function enqueueControlRequest(context, input, { publicInput = null, capabilityPlan, chainId, chainSequence, skillName, skillExecution, skillStack, selectionKind, optional = false, continueOnFailure = false, proactiveReview = null } = {}) {
|
|
1585
1831
|
const now = new Date().toISOString();
|
|
1586
1832
|
const item = {
|
|
1587
1833
|
id: `control-${randomUUID()}`,
|
|
@@ -1598,6 +1844,9 @@ function enqueueControlRequest(context, input, { publicInput = null, capabilityP
|
|
|
1598
1844
|
...(skillExecution ? { skillExecution } : {}),
|
|
1599
1845
|
...(Array.isArray(skillStack) && skillStack.length ? { skillStack: [...skillStack] } : {}),
|
|
1600
1846
|
...(selectionKind ? { selectionKind } : {}),
|
|
1847
|
+
// The proactive marker rides on the ITEM, so it survives projection and a
|
|
1848
|
+
// runtime restart, and the drain can hand it back to the run it starts.
|
|
1849
|
+
...(proactiveReview ? { proactiveReview } : {}),
|
|
1601
1850
|
optional: optional === true,
|
|
1602
1851
|
continueOnFailure: continueOnFailure === true,
|
|
1603
1852
|
};
|
|
@@ -1789,7 +2038,7 @@ function asksForRunStatus(input) {
|
|
|
1789
2038
|
// semantic judgement about the workspace's domain, so it is never a keyword
|
|
1790
2039
|
// list here — it goes to the model, bounded, and falls back to the choice menu
|
|
1791
2040
|
// (`ambiguous`) rather than guessing when no model is available.
|
|
1792
|
-
async function classifyControlMessage(input, status, { forcedIntent = null, llm = null, session = null } = {}) {
|
|
2041
|
+
export async function classifyControlMessage(input, status, { forcedIntent = null, llm = null, session = null } = {}) {
|
|
1793
2042
|
// Caller (the /control message route) already trims and rejects empty input.
|
|
1794
2043
|
const lower = String(input ?? '').toLowerCase();
|
|
1795
2044
|
const intent = forcedIntent ? String(forcedIntent).toLowerCase() : null;
|
|
@@ -1823,7 +2072,10 @@ async function classifyControlMessage(input, status, { forcedIntent = null, llm
|
|
|
1823
2072
|
// active, the only thing the runtime can act on is a status check: treating
|
|
1824
2073
|
// the word as ordinary conversation made the read-only chat fallback lecture
|
|
1825
2074
|
// the user about switching modes instead of answering.
|
|
1826
|
-
|
|
2075
|
+
// Anchored at BOTH ends: "oui" is a confirmation, "oui, ajoute une étape de
|
|
2076
|
+
// polish" is a plan change. Without the end anchor this branch shadowed
|
|
2077
|
+
// modify_run and enqueue_run for every message merely STARTING on a yes.
|
|
2078
|
+
if (status.running && /^\s*(oui|yes|yep|ok|okay|vas[- ]?y|d'accord|daccord|entendu)\s*[.!…]*\s*$/i.test(lower)) {
|
|
1827
2079
|
return { kind: 'observe', confidence: 0.7, reason: 'confirmation_of_runtime_prompt' };
|
|
1828
2080
|
}
|
|
1829
2081
|
if (status.running && /\b(ajoute|add|change|modifie|modify|remplace|replace|retire|remove|skip|ignore|apr[eè]s|before|after|chaque|each|plan|step|t[aâ]che)\b/i.test(lower)) {
|
|
@@ -1747,6 +1747,7 @@ test('POST /turn hands a run status question to Donna with the runtime facts', a
|
|
|
1747
1747
|
};
|
|
1748
1748
|
let turns = 0;
|
|
1749
1749
|
let turnInput = '';
|
|
1750
|
+
let turnDisplayInput = '';
|
|
1750
1751
|
let turnMode = null;
|
|
1751
1752
|
let handle;
|
|
1752
1753
|
try {
|
|
@@ -1755,7 +1756,13 @@ test('POST /turn hands a run status question to Donna with the runtime facts', a
|
|
|
1755
1756
|
store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
|
|
1756
1757
|
getContext: async () => context,
|
|
1757
1758
|
run: async () => new Promise(() => {}),
|
|
1758
|
-
turn: async (_context, options) => {
|
|
1759
|
+
turn: async (_context, options) => {
|
|
1760
|
+
turns += 1;
|
|
1761
|
+
turnInput = options.input;
|
|
1762
|
+
turnDisplayInput = options.displayInput;
|
|
1763
|
+
turnMode = options.mode;
|
|
1764
|
+
return { ok: true };
|
|
1765
|
+
},
|
|
1759
1766
|
});
|
|
1760
1767
|
} catch (err) {
|
|
1761
1768
|
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
@@ -1778,6 +1785,11 @@ test('POST /turn hands a run status question to Donna with the runtime facts', a
|
|
|
1778
1785
|
assert.equal(turnMode, 'chat');
|
|
1779
1786
|
assert.match(turnInput, /Build TechSections/);
|
|
1780
1787
|
assert.match(turnInput, /runtime run, not a production job/i);
|
|
1788
|
+
// …and the THREAD still shows what the reader typed. `executeInteractiveTurn`
|
|
1789
|
+
// persists `displayInput` as the user_message; feeding it the fact block
|
|
1790
|
+
// put a raw English dump in the reader's own bubble and replayed it as
|
|
1791
|
+
// history on every later turn.
|
|
1792
|
+
assert.equal(turnDisplayInput, 'donne le status du job en cours');
|
|
1781
1793
|
} finally {
|
|
1782
1794
|
context.currentAbortController?.abort();
|
|
1783
1795
|
await handle.close();
|
package/src/runtime/store.js
CHANGED
|
@@ -25,7 +25,7 @@ export { defaultRuntimeStateDir };
|
|
|
25
25
|
// turn's LLM context. A persisted progress note would be re-read by the model
|
|
26
26
|
// on every later turn as if it were something the user said or Donna answered,
|
|
27
27
|
// growing the context with commentary about work already finished.
|
|
28
|
-
const NON_PERSISTED_EVENT_TYPES = new Set(['runtime_log', 'assistant_progress']);
|
|
28
|
+
const NON_PERSISTED_EVENT_TYPES = new Set(['runtime_log', 'assistant_progress', 'runtime_heartbeat']);
|
|
29
29
|
export const RUNTIME_STORE_SCHEMA_VERSION = 1;
|
|
30
30
|
const RUNTIME_RETENTION_DAYS = 30;
|
|
31
31
|
const TERMINAL_RUN_STATUSES = ['done', 'error', 'cancelled', 'interrupted'];
|
|
@@ -770,13 +770,17 @@ test('runtime store backfills sequence for legacy event rows', () => {
|
|
|
770
770
|
store.close();
|
|
771
771
|
});
|
|
772
772
|
|
|
773
|
-
test('runtime store does not persist runtime logs', () => {
|
|
773
|
+
test('runtime store does not persist runtime logs or liveness beats', () => {
|
|
774
774
|
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
775
775
|
const store = openRuntimeStore({ stateDir });
|
|
776
776
|
store.persistEvent(createAgentEvent('runtime_log', {
|
|
777
777
|
origin: 'runtime',
|
|
778
778
|
payload: { message: 'agentic-loop: turn 1/20' },
|
|
779
779
|
}));
|
|
780
|
+
store.persistEvent(createAgentEvent('runtime_heartbeat', {
|
|
781
|
+
origin: 'runtime_provider',
|
|
782
|
+
payload: { elapsedMs: 30_000 },
|
|
783
|
+
}));
|
|
780
784
|
|
|
781
785
|
assert.equal(store.listEvents().length, 0);
|
|
782
786
|
store.close();
|
|
@@ -51,3 +51,46 @@ export function openExternalUrl(value, { run = execFileSync } = {}) {
|
|
|
51
51
|
}
|
|
52
52
|
return null;
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
// A Chromium `--app=<url>` window is chromeless from the start — no tab
|
|
56
|
+
// strip, no address bar — and, when the origin serves a manifest (llm-wiki's
|
|
57
|
+
// `/manifest.webmanifest`), it picks up the site's name/icon/theme-color and
|
|
58
|
+
// window-controls-overlay exactly like a formally "installed" PWA, with no
|
|
59
|
+
// prior install step required. `/openui`'s whole point is a desktop-feeling
|
|
60
|
+
// window, so it tries this before falling back to `openExternalUrl`'s plain
|
|
61
|
+
// tab. Same candidate/fallback shape as `openerCandidates` above, and the same
|
|
62
|
+
// contract: returns the opened URL, or null when no Chromium browser answered.
|
|
63
|
+
function appModeCandidates() {
|
|
64
|
+
if (process.platform === 'darwin') {
|
|
65
|
+
return [
|
|
66
|
+
['open', ['-na', 'Google Chrome', '--args']],
|
|
67
|
+
['open', ['-na', 'Microsoft Edge', '--args']],
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
if (process.platform === 'win32') {
|
|
71
|
+
return [
|
|
72
|
+
['cmd', ['/c', 'start', '', 'chrome']],
|
|
73
|
+
['cmd', ['/c', 'start', '', 'msedge']],
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
return [
|
|
77
|
+
['google-chrome', []],
|
|
78
|
+
['chromium', []],
|
|
79
|
+
['chromium-browser', []],
|
|
80
|
+
['microsoft-edge', []],
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function openAppWindowUrl(value, { run = execFileSync } = {}) {
|
|
85
|
+
const url = normalizeExternalUrl(value);
|
|
86
|
+
if (!url) return null;
|
|
87
|
+
for (const [command, args] of appModeCandidates()) {
|
|
88
|
+
try {
|
|
89
|
+
run(command, [...args, `--app=${url}`], { stdio: 'ignore', timeout: 5_000 });
|
|
90
|
+
return url;
|
|
91
|
+
} catch {
|
|
92
|
+
// Missing binary or non-zero exit: try the next candidate.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
package/src/shell/repl.js
CHANGED
|
@@ -94,7 +94,7 @@ const COMMAND_COMPLETION_DESCRIPTIONS = {
|
|
|
94
94
|
'/clear': 'Clear the conversation screen.',
|
|
95
95
|
'/chat': 'Switch free text to direct LLM chat without tools.',
|
|
96
96
|
'/agent': 'Switch to agent mode, or run one agent request with /agent <question>.',
|
|
97
|
-
'/openui': 'Open the workspace web UI
|
|
97
|
+
'/openui': 'Open the workspace web UI as a chromeless desktop window (Chrome/Edge), or a browser tab as fallback.',
|
|
98
98
|
'/run': 'Inspect, cancel, kill runtime runs, or start a capability run.',
|
|
99
99
|
'/approve': 'Approve a pending runtime run or tool.',
|
|
100
100
|
};
|
package/wiki-workspace
CHANGED
|
@@ -138,7 +138,6 @@ wiki CLI commands reachable through `wiki <workspace> run`:
|
|
|
138
138
|
index Create or update the local vector index
|
|
139
139
|
refresh Regenerate only the stale deliverables
|
|
140
140
|
lint Static checks: dead links, orphans, stale deliverables
|
|
141
|
-
add-skill <source> Install a workspace method (directory, .zip, HTTPS .zip)
|
|
142
141
|
config Show the effective .wikirc.yaml
|
|
143
142
|
doctor, ingest, build, export Also available directly: wiki <workspace> <command>
|
|
144
143
|
|