@dotdrelle/wiki-manager 0.15.98 → 0.15.100

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.
@@ -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
@@ -860,6 +886,26 @@ export function startRuntimeServer({
860
886
  const loginAttemptPruneTimer = setInterval(() => pruneLoginAttempts(), 10 * 60 * 1000);
861
887
  loginAttemptPruneTimer.unref?.();
862
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
+
863
909
  return new Promise((resolve, reject) => {
864
910
  server.once('error', reject);
865
911
  server.listen(port, host, () => {
@@ -872,6 +918,7 @@ export function startRuntimeServer({
872
918
  drainControl: (context) => drainControlQueue(context),
873
919
  close: () => new Promise((closeResolve, closeReject) => {
874
920
  clearInterval(loginAttemptPruneTimer);
921
+ if (corpusScanTimer) clearInterval(corpusScanTimer);
875
922
  for (const client of clients) client.response.end();
876
923
  clients.clear();
877
924
  server.close((err) => (err ? closeReject(err) : closeResolve()));
@@ -1029,6 +1076,14 @@ export function startRuntimeServer({
1029
1076
  announceControlLaunch(context.session, body.publicInput ?? body.input, runWorkspace);
1030
1077
  }
1031
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
+ }
1032
1087
  const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
1033
1088
  runPromise
1034
1089
  .catch((err) => {
@@ -1039,6 +1094,12 @@ export function startRuntimeServer({
1039
1094
  context.session?._onRuntimeError?.(err, runId);
1040
1095
  })
1041
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;
1042
1103
  context.running = false;
1043
1104
  context.currentAbortController = null;
1044
1105
  context.currentRunId = null;
@@ -1073,6 +1134,7 @@ export function startRuntimeServer({
1073
1134
  // may still grant the pending run explicitly through --auto-approve.
1074
1135
  ...(item.chainId ? { requireApproval: true } : {}),
1075
1136
  ...(item.capabilityPlan !== undefined ? { capabilityPlan: item.capabilityPlan } : {}),
1137
+ ...(item.proactiveReview ? { proactiveReview: item.proactiveReview } : {}),
1076
1138
  ...(item.chainId
1077
1139
  ? {
1078
1140
  skillChain: {
@@ -1093,6 +1155,179 @@ export function startRuntimeServer({
1093
1155
  });
1094
1156
  }
1095
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
+
1096
1331
  function takePrivateControlInput(session, item) {
1097
1332
  const privateControlInputs = privateControlInputsFor(session);
1098
1333
  const input = privateControlInputs.get(item.id) ?? item.input;
@@ -1592,7 +1827,7 @@ function announceControlLaunch(session, input, workspace) {
1592
1827
  Une file est un passage de témoin : ce qui doit survivre au parent voyage avec
1593
1828
  le message, pas dans l'état de celui qui l'a posté.
1594
1829
  */
1595
- 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 } = {}) {
1596
1831
  const now = new Date().toISOString();
1597
1832
  const item = {
1598
1833
  id: `control-${randomUUID()}`,
@@ -1609,6 +1844,9 @@ function enqueueControlRequest(context, input, { publicInput = null, capabilityP
1609
1844
  ...(skillExecution ? { skillExecution } : {}),
1610
1845
  ...(Array.isArray(skillStack) && skillStack.length ? { skillStack: [...skillStack] } : {}),
1611
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 } : {}),
1612
1850
  optional: optional === true,
1613
1851
  continueOnFailure: continueOnFailure === true,
1614
1852
  };
@@ -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();
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