sentinelayer-cli 0.8.0 → 0.8.1

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 (153) hide show
  1. package/README.md +13 -0
  2. package/package.json +4 -4
  3. package/src/agents/ai-governance/index.js +12 -0
  4. package/src/agents/ai-governance/tools/base.js +171 -0
  5. package/src/agents/ai-governance/tools/eval-regression.js +47 -0
  6. package/src/agents/ai-governance/tools/hitl-audit.js +81 -0
  7. package/src/agents/ai-governance/tools/index.js +52 -0
  8. package/src/agents/ai-governance/tools/prompt-drift.js +42 -0
  9. package/src/agents/ai-governance/tools/provenance-check.js +69 -0
  10. package/src/agents/backend/index.js +12 -0
  11. package/src/agents/backend/tools/base.js +189 -0
  12. package/src/agents/backend/tools/circuit-breaker-check.js +123 -0
  13. package/src/agents/backend/tools/idempotency-audit.js +105 -0
  14. package/src/agents/backend/tools/index.js +87 -0
  15. package/src/agents/backend/tools/retry-audit.js +132 -0
  16. package/src/agents/backend/tools/timeout-audit.js +144 -0
  17. package/src/agents/code-quality/index.js +12 -0
  18. package/src/agents/code-quality/tools/base.js +159 -0
  19. package/src/agents/code-quality/tools/complexity-measure.js +197 -0
  20. package/src/agents/code-quality/tools/coupling-analysis.js +81 -0
  21. package/src/agents/code-quality/tools/cycle-detect.js +49 -0
  22. package/src/agents/code-quality/tools/dep-graph.js +196 -0
  23. package/src/agents/code-quality/tools/index.js +89 -0
  24. package/src/agents/data-layer/index.js +12 -0
  25. package/src/agents/data-layer/tools/base.js +181 -0
  26. package/src/agents/data-layer/tools/index-audit.js +165 -0
  27. package/src/agents/data-layer/tools/index.js +83 -0
  28. package/src/agents/data-layer/tools/migration-scan.js +135 -0
  29. package/src/agents/data-layer/tools/query-explain.js +120 -0
  30. package/src/agents/data-layer/tools/tenancy-scan.js +166 -0
  31. package/src/agents/documentation/index.js +12 -0
  32. package/src/agents/documentation/tools/api-diff.js +91 -0
  33. package/src/agents/documentation/tools/base.js +151 -0
  34. package/src/agents/documentation/tools/dead-link-check.js +58 -0
  35. package/src/agents/documentation/tools/docstring-coverage.js +78 -0
  36. package/src/agents/documentation/tools/index.js +52 -0
  37. package/src/agents/documentation/tools/readme-freshness.js +61 -0
  38. package/src/agents/envelope/fix-cycle.js +45 -0
  39. package/src/agents/envelope/index.js +31 -0
  40. package/src/agents/envelope/loop.js +150 -0
  41. package/src/agents/envelope/pulse.js +18 -0
  42. package/src/agents/envelope/stream.js +40 -0
  43. package/src/agents/infrastructure/index.js +12 -0
  44. package/src/agents/infrastructure/tools/base.js +171 -0
  45. package/src/agents/infrastructure/tools/checkov-run.js +32 -0
  46. package/src/agents/infrastructure/tools/drift-detect.js +59 -0
  47. package/src/agents/infrastructure/tools/iam-least-priv-check.js +78 -0
  48. package/src/agents/infrastructure/tools/index.js +52 -0
  49. package/src/agents/infrastructure/tools/tflint-run.js +31 -0
  50. package/src/agents/jules/loop.js +7 -4
  51. package/src/agents/jules/swarm/sub-agent.js +5 -1
  52. package/src/agents/jules/tools/auth-audit.js +10 -1
  53. package/src/agents/mode.js +113 -0
  54. package/src/agents/observability/index.js +12 -0
  55. package/src/agents/observability/tools/alert-audit.js +39 -0
  56. package/src/agents/observability/tools/base.js +181 -0
  57. package/src/agents/observability/tools/dashboard-gap.js +42 -0
  58. package/src/agents/observability/tools/index.js +54 -0
  59. package/src/agents/observability/tools/log-schema-check.js +74 -0
  60. package/src/agents/observability/tools/span-coverage.js +74 -0
  61. package/src/agents/persona-visuals.js +38 -0
  62. package/src/agents/release/index.js +12 -0
  63. package/src/agents/release/tools/base.js +181 -0
  64. package/src/agents/release/tools/changelog-diff.js +86 -0
  65. package/src/agents/release/tools/feature-flag-audit.js +126 -0
  66. package/src/agents/release/tools/index.js +61 -0
  67. package/src/agents/release/tools/rollback-verify.js +129 -0
  68. package/src/agents/release/tools/semver-check.js +109 -0
  69. package/src/agents/reliability/index.js +12 -0
  70. package/src/agents/reliability/tools/backpressure-check.js +129 -0
  71. package/src/agents/reliability/tools/base.js +181 -0
  72. package/src/agents/reliability/tools/chaos-probe.js +109 -0
  73. package/src/agents/reliability/tools/graceful-degradation-check.js +114 -0
  74. package/src/agents/reliability/tools/health-check-audit.js +111 -0
  75. package/src/agents/reliability/tools/index.js +87 -0
  76. package/src/agents/run-persona.js +109 -0
  77. package/src/agents/security/index.js +12 -0
  78. package/src/agents/security/tools/authz-audit.js +134 -0
  79. package/src/agents/security/tools/base.js +190 -0
  80. package/src/agents/security/tools/crypto-review.js +175 -0
  81. package/src/agents/security/tools/index.js +97 -0
  82. package/src/agents/security/tools/sast-scan.js +175 -0
  83. package/src/agents/security/tools/secrets-scan.js +216 -0
  84. package/src/agents/supply-chain/index.js +12 -0
  85. package/src/agents/supply-chain/tools/attestation-check.js +42 -0
  86. package/src/agents/supply-chain/tools/base.js +151 -0
  87. package/src/agents/supply-chain/tools/index.js +52 -0
  88. package/src/agents/supply-chain/tools/lockfile-integrity.js +73 -0
  89. package/src/agents/supply-chain/tools/package-verify.js +56 -0
  90. package/src/agents/supply-chain/tools/sbom-diff.js +34 -0
  91. package/src/agents/testing/index.js +12 -0
  92. package/src/agents/testing/tools/base.js +202 -0
  93. package/src/agents/testing/tools/coverage-gap.js +144 -0
  94. package/src/agents/testing/tools/flake-detect.js +125 -0
  95. package/src/agents/testing/tools/index.js +85 -0
  96. package/src/agents/testing/tools/mutation-test.js +143 -0
  97. package/src/agents/testing/tools/snapshot-diff.js +103 -0
  98. package/src/auth/gate.js +65 -37
  99. package/src/cli.js +1 -1
  100. package/src/commands/chat.js +3 -10
  101. package/src/commands/legacy-args.js +10 -0
  102. package/src/commands/omargate.js +36 -2
  103. package/src/commands/persona.js +46 -1
  104. package/src/commands/scan.js +3 -10
  105. package/src/commands/session.js +654 -6
  106. package/src/commands/spec.js +3 -10
  107. package/src/coord/events-log.js +141 -0
  108. package/src/coord/handshake.js +719 -0
  109. package/src/coord/index.js +35 -0
  110. package/src/coord/paths.js +84 -0
  111. package/src/coord/priority.js +62 -0
  112. package/src/coord/tarjan.js +157 -0
  113. package/src/cost/tokenizer.js +160 -0
  114. package/src/cost/tracker.js +61 -0
  115. package/src/daemon/artifact-lineage.js +362 -0
  116. package/src/daemon/assignment-ledger.js +117 -0
  117. package/src/daemon/ast-drift.js +496 -0
  118. package/src/daemon/ingest-refresh.js +69 -2
  119. package/src/ingest/engine.js +15 -0
  120. package/src/ingest/ownership.js +380 -0
  121. package/src/legacy-cli.js +68 -1
  122. package/src/orchestrator/kai-chen.js +126 -0
  123. package/src/review/ai-review.js +3 -10
  124. package/src/review/compliance-pack.js +389 -0
  125. package/src/review/investor-dd-config.js +54 -0
  126. package/src/review/investor-dd-file-loop.js +303 -0
  127. package/src/review/investor-dd-file-router.js +406 -0
  128. package/src/review/investor-dd-html-report.js +233 -0
  129. package/src/review/investor-dd-notification.js +120 -0
  130. package/src/review/investor-dd-orchestrator.js +405 -0
  131. package/src/review/investor-dd-persona-runner.js +275 -0
  132. package/src/review/live-validator.js +253 -0
  133. package/src/review/omargate-orchestrator.js +90 -2
  134. package/src/review/persona-prompts.js +244 -56
  135. package/src/review/reconciliation-rules.js +329 -0
  136. package/src/review/reproducibility-chain.js +136 -0
  137. package/src/review/scan-modes.js +102 -3
  138. package/src/session/agent-registry.js +7 -0
  139. package/src/session/analytics.js +479 -0
  140. package/src/session/daemon.js +609 -14
  141. package/src/session/file-locks.js +666 -0
  142. package/src/session/paths.js +4 -0
  143. package/src/session/recap.js +567 -0
  144. package/src/session/redact.js +82 -0
  145. package/src/session/runtime-bridge.js +24 -1
  146. package/src/session/scoring.js +406 -0
  147. package/src/session/setup-guides.js +304 -0
  148. package/src/session/store.js +318 -2
  149. package/src/session/stream.js +9 -1
  150. package/src/session/sync.js +753 -0
  151. package/src/session/tasks.js +1054 -0
  152. package/src/session/templates.js +188 -0
  153. package/src/swarm/runtime.js +1 -8
@@ -1,7 +1,20 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
+ import fsp from "node:fs/promises";
3
4
 
5
+ import { invokeViaProxy } from "../ai/proxy.js";
4
6
  import { createAgentEvent } from "../events/schema.js";
7
+ import {
8
+ buildDocumentsFromBlackboardEntries,
9
+ buildLocalHybridIndex,
10
+ buildSharedMemoryCorpus,
11
+ queryLocalHybridIndex,
12
+ } from "../memory/retrieval.js";
13
+ import {
14
+ endSession as endTelemetrySession,
15
+ recordLlmUsage,
16
+ startSession as startTelemetrySession,
17
+ } from "../telemetry/session-tracker.js";
5
18
  import {
6
19
  detectStaleAgents,
7
20
  heartbeatAgent,
@@ -9,18 +22,36 @@ import {
9
22
  registerAgent,
10
23
  unregisterAgent,
11
24
  } from "./agent-registry.js";
25
+ import {
26
+ DEFAULT_FILE_LOCK_TTL_SECONDS,
27
+ lockFile,
28
+ unlockFile,
29
+ } from "./file-locks.js";
12
30
  import { resolveSessionPaths } from "./paths.js";
31
+ import {
32
+ DEFAULT_RECAP_INACTIVITY_MS,
33
+ DEFAULT_RECAP_INTERVAL_MS,
34
+ emitPeriodicRecap,
35
+ } from "./recap.js";
13
36
  import { stopRuntimeRunsForSession } from "./runtime-bridge.js";
37
+ import { pollHumanMessages } from "./sync.js";
14
38
  import { getSession, renewSession } from "./store.js";
15
39
  import { appendToStream, readStream, tailStream } from "./stream.js";
40
+ import { handleTaskDirective } from "./tasks.js";
16
41
 
17
42
  const DAEMON_TICK_INTERVAL_MS = 30_000;
18
- const HELP_REQUEST_TIMEOUT_MS = 30_000;
43
+ const HELP_REQUEST_TIMEOUT_MS = 1_200;
44
+ const HELP_MODEL_TIMEOUT_MS = 3_000;
45
+ const HELP_CONTEXT_EVENT_TAIL = 50;
46
+ const HELP_CONTEXT_RESULT_LIMIT = 6;
47
+ const HELP_BLACKBOARD_ENTRY_LIMIT = 40;
19
48
  const FILE_CONFLICT_WINDOW_MS = 60_000;
20
49
  const RENEWAL_WINDOW_MS = 60 * 60 * 1000;
21
50
  const RENEWAL_THRESHOLD_EVENTS = 10;
22
51
  const RENEWAL_LEAD_MS = 60 * 60 * 1000;
23
52
  const DEFAULT_STALE_AGENT_SECONDS = 90;
53
+ const DEFAULT_RECAP_INTERVAL_MS_OVERRIDE = DEFAULT_RECAP_INTERVAL_MS;
54
+ const DEFAULT_RECAP_INACTIVITY_MS_OVERRIDE = DEFAULT_RECAP_INACTIVITY_MS;
24
55
 
25
56
  const SENTI_MODEL = "gpt-5.4-mini";
26
57
  const SENTI_IDENTITY = Object.freeze({
@@ -136,7 +167,11 @@ function createSentiState({
136
167
  staleAgentSeconds,
137
168
  helpRequestTimeoutMs,
138
169
  tickIntervalMs,
170
+ recapIntervalMs,
171
+ recapInactivityMs,
139
172
  helpResponder,
173
+ llmInvoker,
174
+ telemetrySessionId,
140
175
  }) {
141
176
  return {
142
177
  daemonKey,
@@ -147,7 +182,11 @@ function createSentiState({
147
182
  staleAgentSeconds,
148
183
  helpRequestTimeoutMs,
149
184
  tickIntervalMs,
185
+ recapIntervalMs,
186
+ recapInactivityMs,
150
187
  helpResponder,
188
+ llmInvoker,
189
+ telemetrySessionId,
151
190
  running: true,
152
191
  tickTimer: null,
153
192
  helpAbortController: new AbortController(),
@@ -157,6 +196,9 @@ function createSentiState({
157
196
  conflictAlertAt: new Map(),
158
197
  lastTickAt: null,
159
198
  lastTickSummary: null,
199
+ recapEmitter: null,
200
+ humanMessageCursor: null,
201
+ humanMessagePollInFlight: false,
160
202
  };
161
203
  }
162
204
 
@@ -188,6 +230,105 @@ async function hasHelpResponseFromPeer(
188
230
  });
189
231
  }
190
232
 
233
+ function normalizeUsageNumber(value) {
234
+ const normalized = Number(value);
235
+ if (!Number.isFinite(normalized) || normalized < 0) {
236
+ return 0;
237
+ }
238
+ return normalized;
239
+ }
240
+
241
+ function buildStreamContextDocuments(events = []) {
242
+ return (events || [])
243
+ .map((event, index) => {
244
+ const payload = event && typeof event.payload === "object" ? event.payload : {};
245
+ const text = [
246
+ normalizeString(event.event),
247
+ normalizeString(event.agent?.id || event.agentId),
248
+ normalizeString(payload.message),
249
+ normalizeString(payload.response),
250
+ normalizeString(payload.alert),
251
+ normalizeString(payload.reason),
252
+ normalizeString(payload.file),
253
+ ]
254
+ .filter(Boolean)
255
+ .join(" ")
256
+ .trim();
257
+ if (!text) {
258
+ return null;
259
+ }
260
+ return {
261
+ documentId: `stream:${index + 1}:${normalizeIsoTimestamp(event.ts, new Date().toISOString())}`,
262
+ sourceType: "session-stream",
263
+ sourcePath: "",
264
+ severity: "P3",
265
+ updatedAt: normalizeIsoTimestamp(event.ts, new Date().toISOString()),
266
+ text,
267
+ metadata: {
268
+ category: "session-stream",
269
+ event: normalizeString(event.event),
270
+ agentId: normalizeString(event.agent?.id || event.agentId),
271
+ },
272
+ };
273
+ })
274
+ .filter(Boolean);
275
+ }
276
+
277
+ async function loadLatestBlackboardEntries(targetPath, { limit = HELP_BLACKBOARD_ENTRY_LIMIT } = {}) {
278
+ const memoryDirectory = path.join(targetPath, ".sentinelayer", "memory");
279
+ let entries = [];
280
+ try {
281
+ entries = await fsp.readdir(memoryDirectory, { withFileTypes: true });
282
+ } catch {
283
+ return [];
284
+ }
285
+
286
+ const files = entries
287
+ .filter((entry) => entry.isFile() && entry.name.startsWith("blackboard-") && entry.name.endsWith(".json"))
288
+ .map((entry) => entry.name)
289
+ .sort((left, right) => right.localeCompare(left));
290
+ for (const fileName of files) {
291
+ const filePath = path.join(memoryDirectory, fileName);
292
+ try {
293
+ const payload = JSON.parse(await fsp.readFile(filePath, "utf-8"));
294
+ if (!Array.isArray(payload.entries)) {
295
+ continue;
296
+ }
297
+ return payload.entries.slice(-Math.max(1, Math.floor(Number(limit) || HELP_BLACKBOARD_ENTRY_LIMIT)));
298
+ } catch {
299
+ // Ignore malformed artifacts and continue searching older files.
300
+ }
301
+ }
302
+ return [];
303
+ }
304
+
305
+ function buildFallbackHelpResponse({ requestMessage = "", synopsis = "context unavailable", contextHints = [] } = {}) {
306
+ const topHints = contextHints.slice(0, 2).join(" | ");
307
+ if (topHints) {
308
+ return `I saw your help_request ("${requestMessage}"). Quick context: ${synopsis}. Top hints: ${topHints}. Share the failing file or stack frame and I can route next steps.`;
309
+ }
310
+ return `I saw your help_request ("${requestMessage}"). Quick context: ${synopsis}. Share the failing file or stack frame and I can route next steps.`;
311
+ }
312
+
313
+ async function runWithTimeout(promise, timeoutMs, timeoutMessage) {
314
+ let timeoutHandle = null;
315
+ try {
316
+ const timeoutPromise = new Promise((_, reject) => {
317
+ timeoutHandle = setTimeout(() => {
318
+ reject(new Error(timeoutMessage));
319
+ }, timeoutMs);
320
+ if (typeof timeoutHandle.unref === "function") {
321
+ timeoutHandle.unref();
322
+ }
323
+ });
324
+ return await Promise.race([promise, timeoutPromise]);
325
+ } finally {
326
+ if (timeoutHandle) {
327
+ clearTimeout(timeoutHandle);
328
+ }
329
+ }
330
+ }
331
+
191
332
  async function buildHelpResponseMessage(
192
333
  daemonState,
193
334
  requestEvent,
@@ -195,6 +336,11 @@ async function buildHelpResponseMessage(
195
336
  targetPath = process.cwd(),
196
337
  } = {}
197
338
  ) {
339
+ const requestMessage =
340
+ normalizeString(requestEvent?.payload?.message) ||
341
+ normalizeString(requestEvent?.payload?.request) ||
342
+ "help request received";
343
+
198
344
  if (typeof daemonState.helpResponder === "function") {
199
345
  const custom = await daemonState.helpResponder({
200
346
  daemonState,
@@ -203,19 +349,170 @@ async function buildHelpResponseMessage(
203
349
  });
204
350
  const normalizedCustom = normalizeString(custom);
205
351
  if (normalizedCustom) {
206
- return normalizedCustom;
352
+ return {
353
+ message: normalizedCustom,
354
+ usage: {
355
+ inputTokens: 0,
356
+ outputTokens: 0,
357
+ costUsd: 0,
358
+ model: daemonState.model,
359
+ provider: "custom-responder",
360
+ latencyMs: 0,
361
+ },
362
+ fallbackPath: false,
363
+ fallbackReason: "",
364
+ contextSignals: {
365
+ documentCount: 0,
366
+ memoryHits: 0,
367
+ blackboardEntries: 0,
368
+ recentEvents: 0,
369
+ },
370
+ };
207
371
  }
208
372
  }
209
373
 
210
- const session = await getSession(daemonState.sessionId, {
211
- targetPath,
212
- });
374
+ const session = await getSession(daemonState.sessionId, { targetPath });
213
375
  const synopsis = session ? formatCodebaseSynopsis(session) : "codebase context unavailable";
214
- const requestMessage =
215
- normalizeString(requestEvent?.payload?.message) ||
216
- normalizeString(requestEvent?.payload?.request) ||
217
- "help request received";
218
- return `I saw your help_request ("${requestMessage}"). Quick context: ${synopsis}. Share the failing file or stack frame and I can route next steps.`;
376
+ const outputRoot = path.join(targetPath, ".sentinelayer");
377
+
378
+ const [recentEvents, blackboardEntries, sharedMemory] = await Promise.all([
379
+ readStream(daemonState.sessionId, {
380
+ targetPath,
381
+ tail: HELP_CONTEXT_EVENT_TAIL,
382
+ }).catch(() => []),
383
+ loadLatestBlackboardEntries(targetPath, {
384
+ limit: HELP_BLACKBOARD_ENTRY_LIMIT,
385
+ }),
386
+ buildSharedMemoryCorpus({
387
+ outputRoot,
388
+ targetPath,
389
+ ingest: session?.codebaseContext || {},
390
+ maxAuditRuns: 2,
391
+ }).catch(() => ({
392
+ documents: [],
393
+ sourceCounts: {},
394
+ })),
395
+ ]);
396
+
397
+ const documents = [
398
+ ...(sharedMemory.documents || []),
399
+ ...buildStreamContextDocuments(recentEvents),
400
+ ...buildDocumentsFromBlackboardEntries(blackboardEntries),
401
+ ];
402
+ const localIndex = buildLocalHybridIndex(documents);
403
+ const memoryQuery = queryLocalHybridIndex(localIndex, {
404
+ query: requestMessage,
405
+ limit: HELP_CONTEXT_RESULT_LIMIT,
406
+ minScore: 0.05,
407
+ });
408
+ const memoryHits = memoryQuery.results || [];
409
+ const contextHints = memoryHits
410
+ .slice(0, HELP_CONTEXT_RESULT_LIMIT)
411
+ .map((result) => {
412
+ const source = normalizeString(result.sourceType) || "memory";
413
+ const snippet = normalizeString(result.snippet || "").replace(/\s+/g, " ").trim();
414
+ if (!snippet) {
415
+ return "";
416
+ }
417
+ return `${source}: ${snippet}`;
418
+ })
419
+ .filter(Boolean);
420
+
421
+ const systemPrompt = [
422
+ "You are Senti, SentinelLayer's session daemon.",
423
+ "Answer the requesting agent with concise, actionable engineering guidance.",
424
+ "Prioritize concrete next steps and reference available context snippets.",
425
+ "Never invent repository files or runtime behavior.",
426
+ ].join(" ");
427
+ const userPrompt = [
428
+ `Agent request: ${requestMessage}`,
429
+ `Codebase synopsis: ${synopsis}`,
430
+ "Context snippets:",
431
+ contextHints.length > 0 ? contextHints.map((line, index) => `${index + 1}. ${line}`).join("\n") : "none",
432
+ "Respond in 2-4 short sentences.",
433
+ ].join("\n");
434
+
435
+ const startedAt = Date.now();
436
+ let llmText = "";
437
+ let fallbackPath = false;
438
+ let fallbackReason = "";
439
+ let usage = {
440
+ inputTokens: 0,
441
+ outputTokens: 0,
442
+ costUsd: 0,
443
+ model: daemonState.model,
444
+ provider: "local-fallback",
445
+ latencyMs: 0,
446
+ };
447
+
448
+ const llmTimeoutMs = Math.max(
449
+ 80,
450
+ Math.min(
451
+ HELP_MODEL_TIMEOUT_MS,
452
+ normalizePositiveInteger(daemonState.helpRequestTimeoutMs, HELP_REQUEST_TIMEOUT_MS) * 2
453
+ )
454
+ );
455
+
456
+ try {
457
+ const llmResult = await runWithTimeout(
458
+ Promise.resolve(
459
+ daemonState.llmInvoker({
460
+ model: daemonState.model,
461
+ systemPrompt,
462
+ prompt: userPrompt,
463
+ maxTokens: 320,
464
+ temperature: 0.1,
465
+ })
466
+ ),
467
+ llmTimeoutMs,
468
+ "Senti model response timeout."
469
+ );
470
+ llmText = normalizeString(llmResult?.text);
471
+ usage = {
472
+ inputTokens: normalizeUsageNumber(llmResult?.usage?.inputTokens),
473
+ outputTokens: normalizeUsageNumber(llmResult?.usage?.outputTokens),
474
+ costUsd: normalizeUsageNumber(llmResult?.usage?.costUsd),
475
+ model: normalizeString(llmResult?.usage?.model) || daemonState.model,
476
+ provider: normalizeString(llmResult?.usage?.provider) || "sentinelayer",
477
+ latencyMs: normalizeUsageNumber(llmResult?.usage?.latencyMs),
478
+ };
479
+ if (!llmText) {
480
+ fallbackPath = true;
481
+ fallbackReason = "Senti model returned an empty response.";
482
+ }
483
+ } catch (error) {
484
+ fallbackPath = true;
485
+ fallbackReason = normalizeString(error?.message || error) || "Senti model invocation failed.";
486
+ }
487
+
488
+ if (!usage.latencyMs) {
489
+ usage.latencyMs = Math.max(1, Date.now() - startedAt);
490
+ }
491
+ recordLlmUsage({
492
+ sessionId: daemonState.telemetrySessionId,
493
+ inputTokens: usage.inputTokens,
494
+ outputTokens: usage.outputTokens,
495
+ costUsd: usage.costUsd,
496
+ });
497
+
498
+ const message = llmText ||
499
+ buildFallbackHelpResponse({
500
+ requestMessage,
501
+ synopsis,
502
+ contextHints,
503
+ });
504
+ return {
505
+ message,
506
+ usage,
507
+ fallbackPath,
508
+ fallbackReason,
509
+ contextSignals: {
510
+ documentCount: documents.length,
511
+ memoryHits: memoryHits.length,
512
+ blackboardEntries: blackboardEntries.length,
513
+ recentEvents: recentEvents.length,
514
+ },
515
+ };
219
516
  }
220
517
 
221
518
  async function maybeRespondToHelpRequest(
@@ -238,23 +535,47 @@ async function maybeRespondToHelpRequest(
238
535
  if (hasPeerResponse) {
239
536
  return null;
240
537
  }
241
- const responseMessage = await buildHelpResponseMessage(daemonState, requestEvent, {
538
+ const response = await buildHelpResponseMessage(daemonState, requestEvent, {
242
539
  targetPath,
243
540
  });
244
- return emitSentiEvent(
541
+ const nowIso = new Date().toISOString();
542
+ const responseEvent = await emitSentiEvent(
245
543
  daemonState.sessionId,
246
544
  "help_response",
247
545
  {
248
546
  requestId,
249
547
  targetAgentId: normalizeString(requestEvent.agent?.id) || null,
250
- response: responseMessage,
548
+ response: response.message,
549
+ sourceEvent: "help_request",
550
+ contextSignals: response.contextSignals,
551
+ },
552
+ {
553
+ targetPath,
554
+ nowIso,
555
+ }
556
+ );
557
+ await emitSentiEvent(
558
+ daemonState.sessionId,
559
+ "model_span",
560
+ {
251
561
  sourceEvent: "help_request",
562
+ requestId,
563
+ model: response.usage.model || daemonState.model,
564
+ provider: response.usage.provider || "sentinelayer",
565
+ inputTokens: response.usage.inputTokens,
566
+ outputTokens: response.usage.outputTokens,
567
+ costUsd: response.usage.costUsd,
568
+ latencyMs: response.usage.latencyMs,
569
+ fallbackPath: Boolean(response.fallbackPath),
570
+ fallbackReason: response.fallbackReason || null,
571
+ contextSignals: response.contextSignals,
252
572
  },
253
573
  {
254
574
  targetPath,
255
- nowIso: new Date().toISOString(),
575
+ nowIso,
256
576
  }
257
577
  );
578
+ return responseEvent;
258
579
  }
259
580
 
260
581
  function queueHelpResponse(daemonState, requestEvent) {
@@ -309,6 +630,176 @@ async function runHelpWatcher(daemonState) {
309
630
  }
310
631
  }
311
632
 
633
+ function splitFileAndIntent(raw = "") {
634
+ const normalized = normalizeString(raw);
635
+ if (!normalized) {
636
+ return {
637
+ filePath: "",
638
+ intent: "",
639
+ };
640
+ }
641
+ const separatorMatch = /\s(?:—|–|-)\s/.exec(normalized);
642
+ if (!separatorMatch) {
643
+ return {
644
+ filePath: normalizeString(normalized),
645
+ intent: "",
646
+ };
647
+ }
648
+ const separatorIndex = Number(separatorMatch.index || 0);
649
+ return {
650
+ filePath: normalizeString(normalized.slice(0, separatorIndex)),
651
+ intent: normalizeString(normalized.slice(separatorIndex + separatorMatch[0].length)),
652
+ };
653
+ }
654
+
655
+ function parseSessionDirective(event = {}) {
656
+ if (normalizeString(event.event) !== "session_message") {
657
+ return null;
658
+ }
659
+ const message = normalizeString(event.payload?.message);
660
+ if (!message) {
661
+ return null;
662
+ }
663
+ const directive = /^(lock|unlock)\s*:\s*(.+)$/i.exec(message);
664
+ if (!directive) {
665
+ return null;
666
+ }
667
+ const action = normalizeString(directive[1]).toLowerCase();
668
+ const body = normalizeString(directive[2]);
669
+ const parsed = splitFileAndIntent(body);
670
+ if (!parsed.filePath) {
671
+ return null;
672
+ }
673
+ return {
674
+ action,
675
+ filePath: parsed.filePath,
676
+ intent: parsed.intent,
677
+ };
678
+ }
679
+
680
+ async function maybeHandleSessionDirective(daemonState, event) {
681
+ const agentId = normalizeString(event.agent?.id);
682
+ if (!agentId || agentId === SENTI_IDENTITY.id) {
683
+ return null;
684
+ }
685
+ const nowIso = normalizeIsoTimestamp(event.ts, new Date().toISOString());
686
+ const fileDirective = parseSessionDirective(event);
687
+ if (fileDirective) {
688
+ if (fileDirective.action === "lock") {
689
+ const result = await lockFile(
690
+ daemonState.sessionId,
691
+ agentId,
692
+ fileDirective.filePath,
693
+ {
694
+ intent: fileDirective.intent,
695
+ ttlSeconds: DEFAULT_FILE_LOCK_TTL_SECONDS,
696
+ targetPath: daemonState.targetPath,
697
+ nowIso,
698
+ }
699
+ );
700
+ if (!result.locked) {
701
+ await emitSentiEvent(
702
+ daemonState.sessionId,
703
+ "daemon_alert",
704
+ {
705
+ alert: "file_lock_denied",
706
+ file: result.file || fileDirective.filePath,
707
+ requestedBy: agentId,
708
+ heldBy: result.heldBy || null,
709
+ since: result.since || null,
710
+ suggestion: `${fileDirective.filePath} is locked by ${result.heldBy || "another agent"} (${result.since || "recently"}). Coordinate before editing.`,
711
+ },
712
+ {
713
+ targetPath: daemonState.targetPath,
714
+ nowIso,
715
+ }
716
+ );
717
+ }
718
+ return result;
719
+ }
720
+ if (fileDirective.action === "unlock") {
721
+ const result = await unlockFile(
722
+ daemonState.sessionId,
723
+ agentId,
724
+ fileDirective.filePath,
725
+ {
726
+ reason: "session_message_unlock",
727
+ targetPath: daemonState.targetPath,
728
+ nowIso,
729
+ }
730
+ );
731
+ if (!result.unlocked && result.reason === "held_by_other_agent") {
732
+ await emitSentiEvent(
733
+ daemonState.sessionId,
734
+ "daemon_alert",
735
+ {
736
+ alert: "file_unlock_denied",
737
+ file: result.file || fileDirective.filePath,
738
+ requestedBy: agentId,
739
+ heldBy: result.heldBy || null,
740
+ since: result.since || null,
741
+ suggestion: `${fileDirective.filePath} is locked by ${result.heldBy || "another agent"}. Only the lock holder can release it.`,
742
+ },
743
+ {
744
+ targetPath: daemonState.targetPath,
745
+ nowIso,
746
+ }
747
+ );
748
+ }
749
+ return result;
750
+ }
751
+ }
752
+
753
+ try {
754
+ return await handleTaskDirective(daemonState.sessionId, event, {
755
+ targetPath: daemonState.targetPath,
756
+ nowIso,
757
+ });
758
+ } catch (error) {
759
+ await emitSentiEvent(
760
+ daemonState.sessionId,
761
+ "daemon_alert",
762
+ {
763
+ alert: "task_directive_error",
764
+ requestedBy: agentId,
765
+ reason: normalizeString(error?.message) || "Task directive failed.",
766
+ message: normalizeString(event.payload?.message) || null,
767
+ },
768
+ {
769
+ targetPath: daemonState.targetPath,
770
+ nowIso,
771
+ }
772
+ );
773
+ return null;
774
+ }
775
+ }
776
+
777
+ async function runSessionDirectiveWatcher(daemonState) {
778
+ const signal = daemonState.helpAbortController.signal;
779
+ try {
780
+ for await (const event of tailStream(daemonState.sessionId, {
781
+ targetPath: daemonState.targetPath,
782
+ signal,
783
+ since: daemonState.startedAt,
784
+ replayTail: 0,
785
+ pollMs: 100,
786
+ })) {
787
+ if (!daemonState.running) {
788
+ return;
789
+ }
790
+ if (normalizeString(event.event) !== "session_message") {
791
+ continue;
792
+ }
793
+ await maybeHandleSessionDirective(daemonState, event);
794
+ }
795
+ } catch (error) {
796
+ if (error && typeof error === "object" && error.name === "AbortError") {
797
+ return;
798
+ }
799
+ throw error;
800
+ }
801
+ }
802
+
312
803
  function buildConflictSignature(agentA, agentB, filePath) {
313
804
  const pair = [normalizeString(agentA), normalizeString(agentB)].filter(Boolean).sort().join("|");
314
805
  return `${pair}::${normalizeString(filePath).replace(/\\/g, "/")}`;
@@ -327,6 +818,12 @@ function createHealthSummaryBase(nowIso, session, agents) {
327
818
  staleAgents: [],
328
819
  conflictAlerts: [],
329
820
  renewed: null,
821
+ humanMessages: {
822
+ relayed: 0,
823
+ dropped: 0,
824
+ cursor: null,
825
+ reason: "",
826
+ },
330
827
  };
331
828
  }
332
829
 
@@ -490,6 +987,66 @@ async function maybeRenewActiveSession(
490
987
  };
491
988
  }
492
989
 
990
+ async function pollAndRelayHumanMessages(
991
+ daemonState,
992
+ summary,
993
+ nowIso = new Date().toISOString()
994
+ ) {
995
+ if (daemonState.humanMessagePollInFlight) {
996
+ summary.humanMessages.reason = "poll_in_flight";
997
+ return;
998
+ }
999
+
1000
+ daemonState.humanMessagePollInFlight = true;
1001
+ try {
1002
+ const polled = await pollHumanMessages(daemonState.sessionId, {
1003
+ targetPath: daemonState.targetPath,
1004
+ since: daemonState.humanMessageCursor,
1005
+ });
1006
+ if (!polled.ok) {
1007
+ summary.humanMessages.reason = normalizeString(polled.reason) || "poll_failed";
1008
+ summary.humanMessages.cursor = daemonState.humanMessageCursor;
1009
+ return;
1010
+ }
1011
+
1012
+ const relayedEvents = [];
1013
+ for (const event of polled.events || []) {
1014
+ const persisted = await appendToStream(daemonState.sessionId, event, {
1015
+ targetPath: daemonState.targetPath,
1016
+ });
1017
+ relayedEvents.push(persisted);
1018
+ }
1019
+ daemonState.humanMessageCursor = normalizeString(polled.cursor) || daemonState.humanMessageCursor;
1020
+
1021
+ summary.humanMessages.relayed = relayedEvents.length;
1022
+ summary.humanMessages.dropped = Array.isArray(polled.dropped) ? polled.dropped.length : 0;
1023
+ summary.humanMessages.cursor = daemonState.humanMessageCursor;
1024
+ summary.humanMessages.reason = "";
1025
+
1026
+ if (relayedEvents.length > 0) {
1027
+ await emitSentiEvent(
1028
+ daemonState.sessionId,
1029
+ "daemon_alert",
1030
+ {
1031
+ alert: "human_directive_received",
1032
+ relayedCount: relayedEvents.length,
1033
+ droppedCount: summary.humanMessages.dropped,
1034
+ },
1035
+ {
1036
+ targetPath: daemonState.targetPath,
1037
+ nowIso,
1038
+ }
1039
+ );
1040
+ }
1041
+ } catch (error) {
1042
+ summary.humanMessages.reason =
1043
+ normalizeString(error?.message) || "poll_relay_failed";
1044
+ summary.humanMessages.cursor = daemonState.humanMessageCursor;
1045
+ } finally {
1046
+ daemonState.humanMessagePollInFlight = false;
1047
+ }
1048
+ }
1049
+
493
1050
  export async function runSentiHealthTick(
494
1051
  sessionId,
495
1052
  {
@@ -522,7 +1079,11 @@ export async function runSentiHealthTick(
522
1079
  staleAgentSeconds,
523
1080
  helpRequestTimeoutMs: HELP_REQUEST_TIMEOUT_MS,
524
1081
  tickIntervalMs: DAEMON_TICK_INTERVAL_MS,
1082
+ recapIntervalMs: DEFAULT_RECAP_INTERVAL_MS_OVERRIDE,
1083
+ recapInactivityMs: DEFAULT_RECAP_INACTIVITY_MS_OVERRIDE,
525
1084
  helpResponder: null,
1085
+ llmInvoker: invokeViaProxy,
1086
+ telemetrySessionId: null,
526
1087
  });
527
1088
  const normalizedNow = normalizeIsoTimestamp(nowIso, new Date().toISOString());
528
1089
  const activeAgents = await listAgents(normalizedSessionId, {
@@ -542,6 +1103,7 @@ export async function runSentiHealthTick(
542
1103
  await emitStaleAndRecoveryAlerts(resolvedDaemonState, summary, staleAgents, normalizedNow);
543
1104
  await emitConflictAlerts(resolvedDaemonState, summary, filteredAgents, normalizedNow);
544
1105
  await maybeRenewActiveSession(resolvedDaemonState, summary, session, normalizedNow);
1106
+ await pollAndRelayHumanMessages(resolvedDaemonState, summary, normalizedNow);
545
1107
  return summary;
546
1108
  }
547
1109
 
@@ -554,7 +1116,10 @@ export async function startSenti(
554
1116
  tickIntervalMs = DAEMON_TICK_INTERVAL_MS,
555
1117
  staleAgentSeconds = DEFAULT_STALE_AGENT_SECONDS,
556
1118
  helpRequestTimeoutMs = HELP_REQUEST_TIMEOUT_MS,
1119
+ recapIntervalMs = DEFAULT_RECAP_INTERVAL_MS_OVERRIDE,
1120
+ recapInactivityMs = DEFAULT_RECAP_INACTIVITY_MS_OVERRIDE,
557
1121
  helpResponder = null,
1122
+ llmInvoker = invokeViaProxy,
558
1123
  } = {}
559
1124
  ) {
560
1125
  const normalizedSessionId = normalizeString(sessionId);
@@ -584,7 +1149,16 @@ export async function startSenti(
584
1149
  staleAgentSeconds,
585
1150
  DEFAULT_STALE_AGENT_SECONDS
586
1151
  );
1152
+ const normalizedRecapIntervalMs = normalizePositiveInteger(
1153
+ recapIntervalMs,
1154
+ DEFAULT_RECAP_INTERVAL_MS_OVERRIDE
1155
+ );
1156
+ const normalizedRecapInactivityMs = normalizePositiveInteger(
1157
+ recapInactivityMs,
1158
+ DEFAULT_RECAP_INACTIVITY_MS_OVERRIDE
1159
+ );
587
1160
  const nowIso = new Date().toISOString();
1161
+ const telemetrySession = startTelemetrySession(`session daemon ${normalizedSessionId}`);
588
1162
  const daemonState = createSentiState({
589
1163
  daemonKey,
590
1164
  sessionId: normalizedSessionId,
@@ -594,7 +1168,11 @@ export async function startSenti(
594
1168
  staleAgentSeconds: normalizedStaleSeconds,
595
1169
  helpRequestTimeoutMs: normalizedHelpTimeoutMs,
596
1170
  tickIntervalMs: normalizedTickIntervalMs,
1171
+ recapIntervalMs: normalizedRecapIntervalMs,
1172
+ recapInactivityMs: normalizedRecapInactivityMs,
597
1173
  helpResponder,
1174
+ llmInvoker: typeof llmInvoker === "function" ? llmInvoker : invokeViaProxy,
1175
+ telemetrySessionId: telemetrySession?.id || null,
598
1176
  });
599
1177
 
600
1178
  await upsertSentiAgent(normalizedSessionId, {
@@ -661,6 +1239,10 @@ export async function startSenti(
661
1239
  clearTimeout(timer);
662
1240
  }
663
1241
  daemonState.pendingHelpTimers.clear();
1242
+ if (daemonState.recapEmitter && daemonState.recapEmitter.isRunning()) {
1243
+ daemonState.recapEmitter.stop("daemon_stop");
1244
+ daemonState.recapEmitter = null;
1245
+ }
664
1246
 
665
1247
  let runtimeStopSummary = null;
666
1248
  try {
@@ -700,6 +1282,9 @@ export async function startSenti(
700
1282
  }
701
1283
  );
702
1284
  ACTIVE_SENTI_DAEMONS.delete(daemonKey);
1285
+ if (daemonState.telemetrySessionId) {
1286
+ endTelemetrySession({ sessionId: daemonState.telemetrySessionId });
1287
+ }
703
1288
  return {
704
1289
  stopped: true,
705
1290
  daemonKey,
@@ -729,6 +1314,8 @@ export async function startSenti(
729
1314
  lastTickAt: daemonState.lastTickAt,
730
1315
  staleAlertedAgents: [...daemonState.staleAlertedAgents],
731
1316
  pendingHelpRequests: daemonState.pendingHelpTimers.size,
1317
+ recapRunning: Boolean(daemonState.recapEmitter?.isRunning?.()),
1318
+ humanMessageCursor: daemonState.humanMessageCursor,
732
1319
  }),
733
1320
  };
734
1321
 
@@ -736,6 +1323,12 @@ export async function startSenti(
736
1323
  ACTIVE_SENTI_DAEMONS.set(daemonKey, daemonState);
737
1324
 
738
1325
  void runHelpWatcher(daemonState).catch(() => {});
1326
+ void runSessionDirectiveWatcher(daemonState).catch(() => {});
1327
+ daemonState.recapEmitter = emitPeriodicRecap(normalizedSessionId, {
1328
+ targetPath: normalizedTargetPath,
1329
+ intervalMs: daemonState.recapIntervalMs,
1330
+ inactivityMs: daemonState.recapInactivityMs,
1331
+ });
739
1332
 
740
1333
  if (autoStart) {
741
1334
  await runTick(nowIso);
@@ -790,6 +1383,8 @@ export function getSentiDaemon(
790
1383
  export {
791
1384
  ACTIVE_SENTI_DAEMONS,
792
1385
  DAEMON_TICK_INTERVAL_MS,
1386
+ DEFAULT_RECAP_INACTIVITY_MS_OVERRIDE,
1387
+ DEFAULT_RECAP_INTERVAL_MS_OVERRIDE,
793
1388
  DEFAULT_STALE_AGENT_SECONDS,
794
1389
  FILE_CONFLICT_WINDOW_MS,
795
1390
  HELP_REQUEST_TIMEOUT_MS,