sentinelayer-cli 0.40.0 → 0.41.0

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 (38) hide show
  1. package/README.md +4 -4
  2. package/package.json +2 -3
  3. package/src/commands/session.js +611 -232
  4. package/src/engram/governance.js +67 -0
  5. package/src/engram/index.js +64 -0
  6. package/src/engram/namespace.js +89 -0
  7. package/src/engram/observations.js +34 -0
  8. package/src/engram/sla.js +104 -0
  9. package/src/engram/store.js +115 -0
  10. package/src/engram/tools.js +146 -0
  11. package/src/engram/trust.js +105 -0
  12. package/src/legacy-cli.js +4 -1
  13. package/src/mcp/registry.js +27 -10
  14. package/src/mcp/session-stdio-server.js +209 -18
  15. package/src/session/agent-registry.js +22 -63
  16. package/src/session/coordination-guidance.js +6 -6
  17. package/src/session/daemon.js +19 -124
  18. package/src/session/file-lease-integrations.js +1300 -0
  19. package/src/session/file-locks.js +967 -464
  20. package/src/session/listener.js +243 -20
  21. package/src/session/listeners.js +71 -80
  22. package/src/session/paths.js +2 -0
  23. package/src/session/recall/embedder.js +181 -0
  24. package/src/session/recall/entities.js +0 -0
  25. package/src/session/recall/index-build.js +38 -0
  26. package/src/session/recall/index-core.js +108 -0
  27. package/src/session/recall/index.js +131 -0
  28. package/src/session/recall/needle-eval.js +279 -0
  29. package/src/session/recall/observation-core.js +183 -0
  30. package/src/session/recall/observations.js +21 -0
  31. package/src/session/recall/provenance.js +153 -0
  32. package/src/session/recall/retrieve.js +374 -0
  33. package/src/session/recall/text.js +0 -0
  34. package/src/session/recall/token-stats.js +81 -0
  35. package/src/session/recap.js +29 -23
  36. package/src/session/remote-hydrate.js +5 -1
  37. package/src/session/sync.js +661 -11
  38. package/src/session/wake/pump.js +8 -3
@@ -4,16 +4,14 @@ import path from "node:path";
4
4
  import process from "node:process";
5
5
 
6
6
  import { STUCK_THRESHOLDS } from "../agents/jules/pulse.js";
7
- import { createAgentEvent } from "../events/schema.js";
8
7
  import { inferSessionAgentIdentity } from "./agent-identity.js";
9
8
  import { resolveSessionPaths } from "./paths.js";
10
- import { emitContextBriefing } from "./recap.js";
9
+ import { buildContextBriefing } from "./recap.js";
11
10
  import {
12
11
  assignFriendlyName,
13
12
  buildSentiWelcome,
14
13
  shouldAutoRenameInRegistry,
15
14
  } from "./senti-naming.js";
16
- import { appendToStream } from "./stream.js";
17
15
 
18
16
  const AGENT_SNAPSHOT_SCHEMA_VERSION = "1.0.0";
19
17
 
@@ -177,28 +175,6 @@ async function writeAgentSnapshot(snapshotPath, snapshot) {
177
175
  }
178
176
  }
179
177
 
180
- async function emitAgentEvent(
181
- sessionId,
182
- event,
183
- payload,
184
- { targetPath = process.cwd(), awaitRemoteSync = false } = {}
185
- ) {
186
- const envelope = createAgentEvent({
187
- event,
188
- agent: {
189
- id: payload.agentId,
190
- model: payload.model,
191
- displayName: payload.displayName,
192
- provider: payload.provider,
193
- role: payload.role,
194
- clientKind: payload.clientKind,
195
- },
196
- sessionId,
197
- payload,
198
- });
199
- await appendToStream(sessionId, envelope, { targetPath, awaitRemoteSync });
200
- }
201
-
202
178
  function buildAgentSnapshotPath(paths, agentId) {
203
179
  const normalizedAgentId = normalizeString(agentId);
204
180
  if (!normalizedAgentId) {
@@ -288,17 +264,15 @@ function _untrackLocalAgent(sessionId, agentId) {
288
264
  _localAgents.delete(_agentKey(sessionId, agentId));
289
265
  }
290
266
 
291
- async function _emitLeaveForAllLocalAgents(reason) {
267
+ async function _leaveAllLocalAgents(reason) {
292
268
  const entries = [..._localAgents.values()];
293
269
  _localAgents.clear();
294
270
  for (const entry of entries) {
295
271
  try {
296
- await emitAgentEvent(
297
- entry.sessionId,
298
- "agent_leave",
299
- { agentId: entry.agentId, reason, model: "unknown", role: "participant" },
300
- { targetPath: entry.targetPath },
301
- );
272
+ await unregisterAgent(entry.sessionId, entry.agentId, {
273
+ reason,
274
+ targetPath: entry.targetPath,
275
+ });
302
276
  } catch {
303
277
  // Best-effort: a stuck filesystem or network shouldn't block exit.
304
278
  }
@@ -309,7 +283,7 @@ function _ensureExitHooksInstalled() {
309
283
  if (_exitHooksInstalled) return;
310
284
  _exitHooksInstalled = true;
311
285
  const onSignal = (signal) => {
312
- void _emitLeaveForAllLocalAgents("manual").finally(() => {
286
+ void _leaveAllLocalAgents("manual").finally(() => {
313
287
  process.removeListener(signal, onSignal);
314
288
  process.kill(process.pid, signal);
315
289
  });
@@ -317,7 +291,7 @@ function _ensureExitHooksInstalled() {
317
291
  process.on("SIGINT", onSignal);
318
292
  process.on("SIGTERM", onSignal);
319
293
  process.on("beforeExit", () => {
320
- void _emitLeaveForAllLocalAgents("manual");
294
+ void _leaveAllLocalAgents("manual");
321
295
  });
322
296
  }
323
297
 
@@ -332,7 +306,6 @@ export async function registerAgent(
332
306
  role = "observer",
333
307
  targetPath = process.cwd(),
334
308
  trackProcessExit = true,
335
- awaitRemoteSync = false,
336
309
  } = {}
337
310
  ) {
338
311
  const paths = resolveSessionPaths(sessionId, { targetPath });
@@ -426,46 +399,39 @@ export async function registerAgent(
426
399
  );
427
400
 
428
401
  await writeAgentSnapshot(snapshotPath, snapshot);
429
- await emitAgentEvent(paths.sessionId, "agent_join", {
430
- agentId: snapshot.agentId,
431
- model: snapshot.model,
432
- displayName: snapshot.displayName,
433
- provider: snapshot.provider,
434
- clientKind: snapshot.clientKind,
435
- role: snapshot.role,
436
- status: snapshot.status,
437
- }, { targetPath, awaitRemoteSync });
438
402
  if (trackProcessExit) {
439
403
  _trackLocalAgent(paths.sessionId, snapshot.agentId, targetPath);
440
404
  }
441
405
 
406
+ let welcome = null;
442
407
  if (renamedFrom) {
443
- const welcome = buildSentiWelcome({
408
+ welcome = buildSentiWelcome({
444
409
  agentId: snapshot.agentId,
445
410
  model: snapshot.model,
446
411
  role: snapshot.role,
447
412
  wasAnonymous: true,
448
413
  originalAgentId: renamedFrom,
449
414
  });
450
- await emitAgentEvent(paths.sessionId, "agent_identified", {
451
- ...welcome,
452
- sessionId: paths.sessionId,
453
- }, { targetPath });
454
415
  }
455
416
 
417
+ let contextBriefing = null;
456
418
  if (normalizeString(snapshot.agentId).toLowerCase() !== "senti") {
457
- await emitContextBriefing(paths.sessionId, {
419
+ contextBriefing = await buildContextBriefing(paths.sessionId, {
458
420
  forAgentId: snapshot.agentId,
459
421
  targetPath,
460
- awaitRemoteSync,
461
422
  }).catch(() => {});
462
423
  }
463
424
 
464
425
  return {
465
426
  ...snapshot,
466
427
  snapshotPath,
467
- emittedJoinEvent: true,
468
- emittedContextBriefing: normalizeString(snapshot.agentId).toLowerCase() !== "senti",
428
+ emittedJoinEvent: false,
429
+ emittedContextBriefing: false,
430
+ returnedContextBriefing: Boolean(contextBriefing),
431
+ onboarding: {
432
+ welcome,
433
+ contextBriefing,
434
+ },
469
435
  refreshedExistingAgent: false,
470
436
  };
471
437
  }
@@ -607,16 +573,9 @@ export async function unregisterAgent(
607
573
  );
608
574
 
609
575
  await writeAgentSnapshot(snapshotPath, snapshot);
610
- await emitAgentEvent(paths.sessionId, "agent_leave", {
611
- agentId: snapshot.agentId,
612
- reason: normalizedReason,
613
- role: snapshot.role,
614
- model: snapshot.model,
615
- displayName: snapshot.displayName,
616
- provider: snapshot.provider,
617
- clientKind: snapshot.clientKind,
618
- }, { targetPath });
619
- // Already left explicitly — don't double-emit on process exit.
576
+ // Presence and roster state are projections, not transcript events. The
577
+ // snapshot is authoritative for this workspace; remote listener presence is
578
+ // renewed through the TTL endpoint.
620
579
  _untrackLocalAgent(paths.sessionId, snapshot.agentId);
621
580
 
622
581
  return {
@@ -2,12 +2,12 @@ export const COORDINATION_GUIDANCE_TITLE = "Multi-Agent Coordination Protocol";
2
2
 
3
3
  export const COORDINATION_ETIQUETTE_ITEMS = Object.freeze([
4
4
  "Find the recent Senti session for this codebase: run `sl session list --path .` and `sl session list --remote --path .`; join the right room with `sl session join <id> --name <your-name> --role coder`.",
5
- "When you have an agent grant, post agent updates with `sl session post-agent <id> \"status: <update>\" --agent <your-agent-id>` so they render as the agent, not the human relay.",
6
- "Before implementation, post a short plan and file claims with `sl session say <id> \"plan: <scope>; files: <paths>\"`.",
7
- "Claim shared files before editing with `lock: <file> - <intent>` and release them with `unlock: <file> - done`.",
8
- "Run one background/secondary listener for replies with `sl session listen --session <id> --agent <your-name> --interval 60 --active-interval 5 --emit ndjson --no-presence`; this idles at 60s, switches to 5s after human activity, and avoids durable listener heartbeat noise. `session listen` now refuses duplicate local listeners for the same session/agent by default; use `--force` only to stop and replace an existing local owner and `--allow-duplicate` only when you intentionally need parallel wake hooks. `session listen` is only a delivery cursor, not a grounding command; join or recap before acting. For your primary interactive listener, omit `--no-presence` so other participants can see you online. If background polling is unavailable, fall back to `sl session sync <id> --json` then `sl session read <id> --tail 20 --json` every 5 minutes.",
9
- "For long-lived rooms, make sure exactly one visible participant owns the Senti daemon: `sl session daemon --session <id> --recap-interval 300 --checkpoint-interval 60`. If no durable recap/checkpoint is appearing, run `sl session recap now <id> --remote --agent <your-name> --json` before posting a long plan.",
10
- "Use message actions for low-noise coordination before posting a new top-level message: `sl session react <id> ack --target-sequence <n>` only when an explicit ACK is useful, `sl session action <id> working_on --target-sequence <n>` for ownership, and `sl session reply <id> <sequence> \"<message>\"` / `sl session comment <id> <sequence> \"<message>\"` for threaded responses. Read receipts are automatic when you use `sl session read <id> --remote --agent <your-name>`; reserve `sl session view <id> <sequence>` for repair/backfill. Run `sl session actions` for the full list.",
5
+ "Keep transient activity outside chat with `sl session action <id> working_on --target-sequence <n>`; post through `sl session post-agent` only for a material decision, finding, blocker, handoff, or completion so the transcript remains readable.",
6
+ "Before implementation, post a short semantic plan only when peers need it; keep routine ownership and progress in message actions.",
7
+ "Claim shared files outside chat before editing with `sl session lock <id> <file> --agent <your-agent-id> --intent \"<intent>\"`; install terminal/editor preflights once with `sl session guard-install <id> --agent <your-agent-id>`, inspect with `sl session locks <id>`, renew long work with `sl session renew`, and release with `sl session unlock`. Lease lifecycle never belongs in chat; guarded terminal/editor denials report the file, holder, intent, and expiry directly to the blocked agent.",
8
+ "Run one background/secondary listener for replies with `sl session listen --session <id> --agent <your-name> --transport poll --interval 60 --active-interval 60 --emit ndjson --no-presence`; polling applies bounded jitter, exponential transient backoff, and `Retry-After` as a hard floor. `session listen` refuses duplicate local listeners for the same session/agent by default; use `--force` only to stop and replace an existing local owner and `--allow-duplicate` only when you intentionally need parallel wake hooks. `session listen` is only a delivery cursor, not a grounding command; join or recap before acting. For your primary interactive listener, omit `--no-presence` so it renews an ephemeral TTL outside the transcript. Unsupported or degraded presence remains unknown and never falls back to durable heartbeat events. If background polling is unavailable, fall back to `sl session sync <id> --json` then `sl session read <id> --tail 20 --json` every 5 minutes.",
9
+ "For long-lived rooms, make sure exactly one visible participant owns the Senti daemon: `sl session daemon --session <id> --recap-interval 300 --checkpoint-interval 60`. Routine room health is a derived status projection, not a transcript event; run `sl session recap now <id> --remote --agent <your-name> --json` for local grounding and use an explicit checkpoint only for a durable semantic handoff.",
10
+ "Use message actions for low-noise coordination before posting a new top-level message: `sl session react <id> ack --target-sequence <n>` only when an explicit ACK is useful, `sl session action <id> working_on --target-sequence <n>` for ownership, and `sl session reply <id> <sequence> \"<message>\"` / `sl session comment <id> <sequence> \"<message>\"` for threaded responses. `sl session read <id> --remote --agent <your-name>` advances one monotonic per-agent read cursor for the displayed window; it never appends per-message view events; reserve `sl session view <id> <sequence>` for repair/backfill of that same cursor. Run `sl session actions` for the full list.",
11
11
  "Search before asking peers to restate context: `sl session search <id> \"<topic>\" --limit 10`.",
12
12
  "Run `sl review --diff` after each finished file or PR-ready diff and post the result summary back to the session.",
13
13
  "Post findings through `sl session say <id> \"finding: [P2] <title> in <file>:<line>\"` with enough context for a peer to act.",
@@ -22,11 +22,6 @@ import {
22
22
  registerAgent,
23
23
  unregisterAgent,
24
24
  } from "./agent-registry.js";
25
- import {
26
- DEFAULT_FILE_LOCK_TTL_SECONDS,
27
- lockFile,
28
- unlockFile,
29
- } from "./file-locks.js";
30
25
  import {
31
26
  DEFAULT_MAX_EVENTS as DEFAULT_CHECKPOINT_MAX_EVENTS,
32
27
  DEFAULT_MIN_EVENTS as DEFAULT_CHECKPOINT_MIN_EVENTS,
@@ -65,8 +60,6 @@ const DEFAULT_CHECKPOINT_INTERVAL_MS = 60_000;
65
60
  const DEFAULT_CHECKPOINT_EVENT_THRESHOLD = DEFAULT_CHECKPOINT_MIN_EVENTS;
66
61
  const DEFAULT_CHECKPOINT_IDLE_MS = DEFAULT_RECAP_INACTIVITY_MS_OVERRIDE;
67
62
  const CHECKPOINT_MEANINGFUL_EVENT_NAMES = new Set([
68
- "file_lock",
69
- "file_unlock",
70
63
  "finding",
71
64
  "help_request",
72
65
  "help_response",
@@ -84,6 +77,9 @@ const CHECKPOINT_IGNORED_EVENT_NAMES = new Set([
84
77
  "agent_status",
85
78
  "context_briefing",
86
79
  "daemon_alert",
80
+ "file_lock",
81
+ "file_lock_expired",
82
+ "file_unlock",
87
83
  "session_checkpoint",
88
84
  "session_listen_error",
89
85
  "session_recap",
@@ -138,14 +134,27 @@ async function emitSentiEvent(
138
134
  payload = {},
139
135
  { targetPath = process.cwd(), nowIso = new Date().toISOString() } = {}
140
136
  ) {
137
+ const projectionOnly = normalizeString(event) === "daemon_alert";
141
138
  const envelope = createAgentEvent({
142
139
  event,
143
140
  agentId: SENTI_IDENTITY.id,
144
141
  agentModel: SENTI_IDENTITY.model,
145
142
  sessionId,
146
143
  ts: normalizeIsoTimestamp(nowIso, new Date().toISOString()),
147
- payload,
144
+ payload: projectionOnly
145
+ ? {
146
+ ...payload,
147
+ ephemeral: true,
148
+ projection: "session_health",
149
+ }
150
+ : payload,
148
151
  });
152
+ // Daemon health, presence, conflict hints, and lifecycle notices are
153
+ // operational projections. They are returned to the caller/status surface,
154
+ // but never become durable conversation rows.
155
+ if (projectionOnly) {
156
+ return envelope;
157
+ }
149
158
  await appendToStream(sessionId, envelope, {
150
159
  targetPath,
151
160
  });
@@ -731,126 +740,12 @@ async function runHelpWatcher(daemonState) {
731
740
  }
732
741
  }
733
742
 
734
- function splitFileAndIntent(raw = "") {
735
- const normalized = normalizeString(raw);
736
- if (!normalized) {
737
- return {
738
- filePath: "",
739
- intent: "",
740
- };
741
- }
742
- const separatorMatch = /\s(?:—|–|-)\s/.exec(normalized);
743
- if (!separatorMatch) {
744
- return {
745
- filePath: normalizeString(normalized),
746
- intent: "",
747
- };
748
- }
749
- const separatorIndex = Number(separatorMatch.index || 0);
750
- return {
751
- filePath: normalizeString(normalized.slice(0, separatorIndex)),
752
- intent: normalizeString(normalized.slice(separatorIndex + separatorMatch[0].length)),
753
- };
754
- }
755
-
756
- function parseSessionDirective(event = {}) {
757
- if (normalizeString(event.event) !== "session_message") {
758
- return null;
759
- }
760
- const message = normalizeString(event.payload?.message);
761
- if (!message) {
762
- return null;
763
- }
764
- const directive = /^(lock|unlock)\s*:\s*(.+)$/i.exec(message);
765
- if (!directive) {
766
- return null;
767
- }
768
- const action = normalizeString(directive[1]).toLowerCase();
769
- const body = normalizeString(directive[2]);
770
- const parsed = splitFileAndIntent(body);
771
- if (!parsed.filePath) {
772
- return null;
773
- }
774
- return {
775
- action,
776
- filePath: parsed.filePath,
777
- intent: parsed.intent,
778
- };
779
- }
780
-
781
- async function maybeHandleSessionDirective(daemonState, event) {
743
+ async function maybeHandleTaskDirective(daemonState, event) {
782
744
  const agentId = normalizeString(event.agent?.id);
783
745
  if (!agentId || agentId === SENTI_IDENTITY.id) {
784
746
  return null;
785
747
  }
786
748
  const nowIso = normalizeIsoTimestamp(event.ts, new Date().toISOString());
787
- const fileDirective = parseSessionDirective(event);
788
- if (fileDirective) {
789
- if (fileDirective.action === "lock") {
790
- const result = await lockFile(
791
- daemonState.sessionId,
792
- agentId,
793
- fileDirective.filePath,
794
- {
795
- intent: fileDirective.intent,
796
- ttlSeconds: DEFAULT_FILE_LOCK_TTL_SECONDS,
797
- targetPath: daemonState.targetPath,
798
- nowIso,
799
- }
800
- );
801
- if (!result.locked) {
802
- await emitSentiEvent(
803
- daemonState.sessionId,
804
- "daemon_alert",
805
- {
806
- alert: "file_lock_denied",
807
- file: result.file || fileDirective.filePath,
808
- requestedBy: agentId,
809
- heldBy: result.heldBy || null,
810
- since: result.since || null,
811
- suggestion: `${fileDirective.filePath} is locked by ${result.heldBy || "another agent"} (${result.since || "recently"}). Coordinate before editing.`,
812
- },
813
- {
814
- targetPath: daemonState.targetPath,
815
- nowIso,
816
- }
817
- );
818
- }
819
- return result;
820
- }
821
- if (fileDirective.action === "unlock") {
822
- const result = await unlockFile(
823
- daemonState.sessionId,
824
- agentId,
825
- fileDirective.filePath,
826
- {
827
- reason: "session_message_unlock",
828
- targetPath: daemonState.targetPath,
829
- nowIso,
830
- }
831
- );
832
- if (!result.unlocked && result.reason === "held_by_other_agent") {
833
- await emitSentiEvent(
834
- daemonState.sessionId,
835
- "daemon_alert",
836
- {
837
- alert: "file_unlock_denied",
838
- file: result.file || fileDirective.filePath,
839
- requestedBy: agentId,
840
- heldBy: result.heldBy || null,
841
- since: result.since || null,
842
- suggestion: `${fileDirective.filePath} is locked by ${result.heldBy || "another agent"}. Only the lock holder can release it.`,
843
- },
844
- {
845
- targetPath: daemonState.targetPath,
846
- nowIso,
847
- }
848
- );
849
- }
850
- return result;
851
- }
852
- }
853
-
854
749
  try {
855
750
  return await handleTaskDirective(daemonState.sessionId, event, {
856
751
  targetPath: daemonState.targetPath,
@@ -891,7 +786,7 @@ async function runSessionDirectiveWatcher(daemonState) {
891
786
  if (normalizeString(event.event) !== "session_message") {
892
787
  continue;
893
788
  }
894
- await maybeHandleSessionDirective(daemonState, event);
789
+ await maybeHandleTaskDirective(daemonState, event);
895
790
  }
896
791
  } catch (error) {
897
792
  if (error && typeof error === "object" && error.name === "AbortError") {