sentinelayer-cli 0.41.0 → 0.42.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.
- package/README.md +18 -0
- package/package.json +3 -2
- package/src/auth/admission-scope.js +34 -0
- package/src/auth/gate.js +6 -0
- package/src/auth/http.js +4 -0
- package/src/auth/service.js +6 -0
- package/src/board/done-gate.js +155 -0
- package/src/board/evidence-resolvers.js +138 -0
- package/src/cli.js +24 -1
- package/src/commands/board.js +116 -0
- package/src/commands/session.js +483 -47
- package/src/engram/build-state.js +183 -0
- package/src/engram/canonical.js +75 -0
- package/src/engram/document.js +190 -0
- package/src/engram/index.js +11 -2
- package/src/engram/ingest-document.js +107 -0
- package/src/engram/store.js +6 -1
- package/src/engram/tools.js +6 -0
- package/src/events/schema.js +18 -0
- package/src/legacy-cli.js +2 -2
- package/src/net/test-egress-guard.js +158 -0
- package/src/review/investor-dd-orchestrator.js +14 -2
- package/src/session/admission-auth.js +312 -0
- package/src/session/admission-preimage.js +90 -0
- package/src/session/admission.js +620 -0
- package/src/session/checkpoints.js +25 -1
- package/src/session/codebase-context.js +54 -0
- package/src/session/coordination-guidance.js +1 -1
- package/src/session/daemon-spawn.js +20 -2
- package/src/session/daemon.js +7 -1
- package/src/session/event-identity.js +9 -2
- package/src/session/first-message.js +11 -3
- package/src/session/listener.js +3 -0
- package/src/session/live-source.js +47 -16
- package/src/session/message-edits.js +89 -0
- package/src/session/recap.js +26 -4
- package/src/session/remote-hydrate.js +7 -19
- package/src/session/store.js +13 -14
- package/src/session/stream.js +23 -3
- package/src/session/sync.js +128 -11
- package/src/session/tickets.js +316 -0
- package/src/session/title-sync.js +2 -1
- package/src/session/usage-report.js +41 -2
- package/src/session/wake/resolve-target.js +10 -5
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isMainThread, parentPort, Worker, workerData } from "node:worker_threads";
|
|
4
|
+
|
|
5
|
+
const CACHE_LIMIT_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
const CACHE_TIMEOUT_MS = 750;
|
|
7
|
+
|
|
8
|
+
// Session creation is not an ingest command. Never recursively walk the cwd:
|
|
9
|
+
// it can be a home directory, a monorepo, or a slow network filesystem.
|
|
10
|
+
// The worker owns only a read; it cannot write metadata after its deadline.
|
|
11
|
+
if (!isMainThread && workerData?.kind === "session-context-cache") {
|
|
12
|
+
let context = {};
|
|
13
|
+
try {
|
|
14
|
+
const cachePath = path.join(workerData.targetPath, ".sentinelayer", "CODEBASE_INGEST.json");
|
|
15
|
+
const stat = await fs.stat(cachePath);
|
|
16
|
+
if (stat.isFile() && stat.size <= CACHE_LIMIT_BYTES) {
|
|
17
|
+
const raw = await fs.readFile(cachePath);
|
|
18
|
+
if (raw.byteLength <= CACHE_LIMIT_BYTES) {
|
|
19
|
+
const parsed = JSON.parse(raw.toString("utf8"));
|
|
20
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) context = parsed;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// Missing, malformed, unreadable or oversized optional context is absent.
|
|
25
|
+
}
|
|
26
|
+
parentPort.postMessage(context);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readSessionCodebaseContext(targetPath, { timeoutMs = CACHE_TIMEOUT_MS } = {}) {
|
|
30
|
+
let worker;
|
|
31
|
+
let timer;
|
|
32
|
+
try {
|
|
33
|
+
worker = new Worker(new URL("./codebase-context.js", import.meta.url), {
|
|
34
|
+
workerData: { kind: "session-context-cache", targetPath },
|
|
35
|
+
resourceLimits: { maxOldGenerationSizeMb: 32 },
|
|
36
|
+
stdout: true,
|
|
37
|
+
stderr: true,
|
|
38
|
+
});
|
|
39
|
+
worker.stdout.resume();
|
|
40
|
+
worker.stderr.resume();
|
|
41
|
+
return await new Promise((resolve) => {
|
|
42
|
+
timer = setTimeout(() => resolve({}), timeoutMs);
|
|
43
|
+
worker.once("message", resolve);
|
|
44
|
+
worker.once("error", () => resolve({}));
|
|
45
|
+
worker.once("exit", () => resolve({}));
|
|
46
|
+
});
|
|
47
|
+
} catch {
|
|
48
|
+
return {};
|
|
49
|
+
} finally {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
// Await actual worker termination, not merely a race against its promise.
|
|
52
|
+
if (worker) await worker.terminate();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -7,7 +7,7 @@ export const COORDINATION_ETIQUETTE_ITEMS = Object.freeze([
|
|
|
7
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
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
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
|
|
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 delivery cursor for the displayed window; it never appends per-message view events or proves human viewing. Correct your own message with `sl session edit <id> <sequence-or-reply-uuid> \"<replacement>\"`; edits retain audit history and notify recipients. 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.",
|
|
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import fsp from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
5
6
|
|
|
6
7
|
import { resolveSessionPaths } from "./paths.js";
|
|
7
8
|
import {
|
|
@@ -116,9 +117,22 @@ export async function getDaemonStatus(sessionId, { targetPath = process.cwd() }
|
|
|
116
117
|
};
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
// The daemon is ALWAYS this package's own CLI entry, never "whatever script is running".
|
|
121
|
+
// It used to be process.argv[1]: under a test runner that is the TEST FILE, so the "daemon"
|
|
122
|
+
// re-ran the tests, and every bootstrapped session spawned more detached copies of them.
|
|
123
|
+
// On 2026-09-29 one such run became a detached fork bomb that took the machine down and
|
|
124
|
+
// created thousands of sessions on the API.
|
|
125
|
+
const CLI_ENTRY_PATH = fileURLToPath(new URL("../../bin/sl.js", import.meta.url));
|
|
126
|
+
|
|
119
127
|
export function resolveCliEntryPath() {
|
|
120
|
-
|
|
121
|
-
|
|
128
|
+
return CLI_ENTRY_PATH;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Under the node test runner (it sets NODE_TEST_CONTEXT in every test process), a detached
|
|
132
|
+
// daemon outlives the test that started it. One is spawned only when the test names the
|
|
133
|
+
// entry itself (cliPath), deliberately; never as a side effect of code under test.
|
|
134
|
+
function underTestRunner() {
|
|
135
|
+
return normalizeString(process.env.NODE_TEST_CONTEXT) !== "";
|
|
122
136
|
}
|
|
123
137
|
|
|
124
138
|
/**
|
|
@@ -156,6 +170,10 @@ export async function spawnDetachedSentiDaemon({
|
|
|
156
170
|
};
|
|
157
171
|
}
|
|
158
172
|
|
|
173
|
+
if (!normalizeString(cliPath) && underTestRunner()) {
|
|
174
|
+
return { spawned: false, pid: null, reason: "test_runner", logPath: "" };
|
|
175
|
+
}
|
|
176
|
+
|
|
159
177
|
const entryPath = normalizeString(cliPath) || resolveCliEntryPath();
|
|
160
178
|
if (!entryPath) {
|
|
161
179
|
return { spawned: false, pid: null, reason: "cli_entry_unresolved", logPath: "" };
|
package/src/session/daemon.js
CHANGED
|
@@ -783,7 +783,7 @@ async function runSessionDirectiveWatcher(daemonState) {
|
|
|
783
783
|
if (!daemonState.running) {
|
|
784
784
|
return;
|
|
785
785
|
}
|
|
786
|
-
if (normalizeString(event.event) !== "session_message") {
|
|
786
|
+
if (normalizeString(event.event) !== "session_message" || Number(event.messageRevision || 1) > 1) {
|
|
787
787
|
continue;
|
|
788
788
|
}
|
|
789
789
|
await maybeHandleTaskDirective(daemonState, event);
|
|
@@ -1280,6 +1280,12 @@ async function maybeGenerateSessionCheckpoint(
|
|
|
1280
1280
|
targetPath: daemonState.targetPath,
|
|
1281
1281
|
minEvents: daemonState.checkpointMinEvents,
|
|
1282
1282
|
maxEvents: daemonState.checkpointMaxEvents,
|
|
1283
|
+
// origin is the AUTHORITATIVE field (lane 2): this daemon is a process an agent
|
|
1284
|
+
// started, so its cuts are origin:'agent' — never the managed service, even though
|
|
1285
|
+
// #800's humble default would already resolve to agent. Declaring it explicitly means
|
|
1286
|
+
// the daemon's honesty does not silently ride on that default. createdBy stays the
|
|
1287
|
+
// descriptive "senti" persona; origin:'agent' is what a DoD/audit keys on.
|
|
1288
|
+
origin: "agent",
|
|
1283
1289
|
createdByAgentId: SENTI_IDENTITY.id,
|
|
1284
1290
|
nowIso: normalizedNow,
|
|
1285
1291
|
});
|
|
@@ -25,6 +25,9 @@ export function sessionEventHasDurableCursor(event = {}) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export function sessionEventUpgradesExisting(existingEvent = {}, candidateEvent = {}) {
|
|
28
|
+
const existingRevision = Number(existingEvent.messageRevision ?? 1);
|
|
29
|
+
const candidateRevision = Number(candidateEvent.messageRevision ?? 1);
|
|
30
|
+
if (candidateRevision !== existingRevision) return candidateRevision > existingRevision;
|
|
28
31
|
const existingSequence = readSessionEventSequence(existingEvent);
|
|
29
32
|
const candidateSequence = readSessionEventSequence(candidateEvent);
|
|
30
33
|
if (candidateSequence > 0 && existingSequence <= 0) {
|
|
@@ -85,7 +88,10 @@ function mergeObjectPreferringPrimary(primaryValue, secondaryValue) {
|
|
|
85
88
|
function mergeDuplicateEvent(existingEvent = {}, candidateEvent = {}) {
|
|
86
89
|
const existingScore = eventDurabilityScore(existingEvent);
|
|
87
90
|
const candidateScore = eventDurabilityScore(candidateEvent);
|
|
88
|
-
const
|
|
91
|
+
const existingRevision = Number(existingEvent.messageRevision ?? 1);
|
|
92
|
+
const candidateRevision = Number(candidateEvent.messageRevision ?? 1);
|
|
93
|
+
const differentRevision = existingRevision !== candidateRevision;
|
|
94
|
+
const candidateIsPrimary = differentRevision ? candidateRevision > existingRevision : candidateScore > existingScore || (
|
|
89
95
|
candidateScore === existingScore &&
|
|
90
96
|
sessionEventUpgradesExisting(existingEvent, candidateEvent)
|
|
91
97
|
) || (
|
|
@@ -99,7 +105,8 @@ function mergeDuplicateEvent(existingEvent = {}, candidateEvent = {}) {
|
|
|
99
105
|
...secondaryEvent,
|
|
100
106
|
...primaryEvent,
|
|
101
107
|
agent: mergeObjectPreferringPrimary(primaryEvent.agent, secondaryEvent.agent),
|
|
102
|
-
|
|
108
|
+
// Revision snapshots replace, rather than merge, the body/routing fields.
|
|
109
|
+
payload: differentRevision ? primaryEvent.payload : mergeObjectPreferringPrimary(primaryEvent.payload, secondaryEvent.payload),
|
|
103
110
|
};
|
|
104
111
|
}
|
|
105
112
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createAgentEvent } from "../events/schema.js";
|
|
2
2
|
import { appendToStream } from "./stream.js";
|
|
3
|
+
import { syncSessionEventToApi } from "./sync.js";
|
|
3
4
|
|
|
4
5
|
export const FIRST_MESSAGE_AGENT = Object.freeze({
|
|
5
6
|
id: "senti",
|
|
@@ -72,7 +73,7 @@ export function buildFirstSentiMessage({ sessionId } = {}) {
|
|
|
72
73
|
|
|
73
74
|
/**
|
|
74
75
|
* Post the first-Senti-message as the opening event of a freshly created
|
|
75
|
-
* session. Best-effort
|
|
76
|
+
* session. Best-effort with bounded remote sync — a failure never fails session
|
|
76
77
|
* creation. Returns { posted, reason }.
|
|
77
78
|
*/
|
|
78
79
|
export async function postFirstSentiMessage({ sessionId, targetPath = process.cwd() } = {}) {
|
|
@@ -91,8 +92,15 @@ export async function postFirstSentiMessage({ sessionId, targetPath = process.cw
|
|
|
91
92
|
},
|
|
92
93
|
});
|
|
93
94
|
try {
|
|
94
|
-
await appendToStream(sid, event, {
|
|
95
|
-
|
|
95
|
+
const persisted = await appendToStream(sid, event, {
|
|
96
|
+
targetPath, syncRemote: false, lockTimeoutMs: 500,
|
|
97
|
+
});
|
|
98
|
+
const remoteSync = await syncSessionEventToApi(sid, persisted, {
|
|
99
|
+
targetPath, timeoutMs: 2_000,
|
|
100
|
+
}).catch(() => ({ synced: false, reason: "sync_failed" }));
|
|
101
|
+
// Keep posted's existing local-durability meaning; remote acknowledgement
|
|
102
|
+
// is explicit and must never be inferred from this compatibility field.
|
|
103
|
+
return { posted: true, reason: "posted", remoteSynced: Boolean(remoteSync.synced), remoteSync };
|
|
96
104
|
} catch (error) {
|
|
97
105
|
return { posted: false, reason: normalizeString(error?.message) || "append_failed" };
|
|
98
106
|
}
|
package/src/session/listener.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "./sync.js";
|
|
9
9
|
import { cursorAdvances, readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
|
|
10
10
|
import { isSessionListenerLifecycleEvent } from "./control-events.js";
|
|
11
|
+
import { sessionMessageRoutingEvent } from "./message-edits.js";
|
|
11
12
|
|
|
12
13
|
const BROADCAST_RECIPIENTS = new Set([
|
|
13
14
|
"*",
|
|
@@ -68,6 +69,7 @@ function addRecipientValue(values, value) {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
export function collectSessionEventRecipients(event = {}) {
|
|
72
|
+
event = sessionMessageRoutingEvent(event);
|
|
71
73
|
const values = [];
|
|
72
74
|
if (!isPlainObject(event)) return values;
|
|
73
75
|
const payload = isPlainObject(event.payload) ? event.payload : {};
|
|
@@ -90,6 +92,7 @@ export function collectSessionEventRecipients(event = {}) {
|
|
|
90
92
|
|
|
91
93
|
export function eventMatchesAgent(event = {}, agentId = "") {
|
|
92
94
|
if (!isPlainObject(event)) return false;
|
|
95
|
+
event = sessionMessageRoutingEvent(event);
|
|
93
96
|
const normalizedAgentId = normalizeComparableId(agentId);
|
|
94
97
|
if (!normalizedAgentId) return false;
|
|
95
98
|
|
|
@@ -21,10 +21,10 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
21
21
|
import { resolveSessionPaths } from "./paths.js";
|
|
22
22
|
import { readStream } from "./stream.js";
|
|
23
23
|
import {
|
|
24
|
-
addSessionEventIdentityKeys,
|
|
25
24
|
dedupeSessionEvents,
|
|
26
|
-
|
|
25
|
+
readSessionEventSequence,
|
|
27
26
|
sessionEventIdentityKeys,
|
|
27
|
+
sessionEventUpgradesExisting,
|
|
28
28
|
} from "./event-identity.js";
|
|
29
29
|
|
|
30
30
|
const DEFAULT_RECONNECT_BACKOFF_MS = 2_000;
|
|
@@ -61,14 +61,21 @@ export async function* watchLocalStream({
|
|
|
61
61
|
} = {}) {
|
|
62
62
|
if (!sessionId) return;
|
|
63
63
|
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
64
|
-
let
|
|
64
|
+
let observed = new Map();
|
|
65
|
+
const observe = (event, previous = observed) => {
|
|
66
|
+
const keys = sessionEventIdentityKeys(event);
|
|
67
|
+
const existing = keys.map((key) => previous.get(key)).find(Boolean);
|
|
68
|
+
const changed = !existing || sessionEventUpgradesExisting(existing, event);
|
|
69
|
+
for (const key of keys) observed.set(key, changed ? event : existing);
|
|
70
|
+
return changed;
|
|
71
|
+
};
|
|
65
72
|
|
|
66
73
|
// Replay the tail first so any caller getting the iterator catches
|
|
67
74
|
// up with the in-flight context before live events start arriving.
|
|
68
|
-
const
|
|
75
|
+
const baseline = dedupeSessionEvents(await _readEvents(sessionId, { targetPath, tail: 0 }));
|
|
76
|
+
for (const event of baseline) observe(event);
|
|
77
|
+
const initial = Number(initialTail) > 0 ? baseline.slice(-Math.floor(Number(initialTail))) : baseline;
|
|
69
78
|
for (const event of initial) {
|
|
70
|
-
const candidate = event.ts || event.timestamp;
|
|
71
|
-
if (candidate) lastTs = candidate;
|
|
72
79
|
yield { source: "fs", event };
|
|
73
80
|
}
|
|
74
81
|
|
|
@@ -114,11 +121,15 @@ export async function* watchLocalStream({
|
|
|
114
121
|
await pendingPromise;
|
|
115
122
|
if (aborted()) break;
|
|
116
123
|
|
|
117
|
-
|
|
124
|
+
// Revisions retain the original timestamp. Identity + monotonic revision,
|
|
125
|
+
// not wall time, determines whether a local projection is new.
|
|
126
|
+
const events = dedupeSessionEvents(await _readEvents(sessionId, { targetPath, tail: 0 }));
|
|
127
|
+
// The NDJSON stream rotates at its configured event cap. Retire identities
|
|
128
|
+
// no longer in that file instead of accumulating a daemon-lifetime cache.
|
|
129
|
+
const previous = observed;
|
|
130
|
+
observed = new Map();
|
|
118
131
|
for (const event of events) {
|
|
119
|
-
|
|
120
|
-
if (lastTs && candidate && candidate <= lastTs) continue;
|
|
121
|
-
if (candidate) lastTs = candidate;
|
|
132
|
+
if (!observe(event, previous)) continue;
|
|
122
133
|
queue.push({ source: "fs", event });
|
|
123
134
|
}
|
|
124
135
|
while (queue.length > 0) {
|
|
@@ -154,11 +165,24 @@ export async function* watchRemoteStream({
|
|
|
154
165
|
sessionId,
|
|
155
166
|
)}/stream`;
|
|
156
167
|
let backoff = reconnectBackoffMs;
|
|
168
|
+
// Highest server sequence actually DELIVERED to the consumer. SSE is a live push with no
|
|
169
|
+
// backlog: reconnecting without it silently drops everything published while we were away,
|
|
170
|
+
// and a listener that missed messages looks exactly like a quiet room. This is what makes an
|
|
171
|
+
// agent "go dark".
|
|
172
|
+
let resumeSequence = 0;
|
|
157
173
|
|
|
158
174
|
while (!signal?.aborted) {
|
|
159
175
|
let response;
|
|
176
|
+
// Resume strictly after the last delivered event. The server filter is exclusive
|
|
177
|
+
// (`sequence_id > from_sequence`), so this replays nothing and skips nothing. The first
|
|
178
|
+
// connection sends no cursor and starts live, which is the behaviour attaching to a room
|
|
179
|
+
// should have -- resume only ever applies to a RECONNECT.
|
|
180
|
+
const url =
|
|
181
|
+
resumeSequence > 0
|
|
182
|
+
? `${endpoint}?fromSequence=${encodeURIComponent(String(resumeSequence))}`
|
|
183
|
+
: endpoint;
|
|
160
184
|
try {
|
|
161
|
-
response = await _sseFetch(
|
|
185
|
+
response = await _sseFetch(url, {
|
|
162
186
|
method: "GET",
|
|
163
187
|
headers: {
|
|
164
188
|
Authorization: `Bearer ${token}`,
|
|
@@ -201,6 +225,11 @@ export async function* watchRemoteStream({
|
|
|
201
225
|
try {
|
|
202
226
|
const event = JSON.parse(payload);
|
|
203
227
|
yield { source: "sse", event };
|
|
228
|
+
// Advance only AFTER the consumer has taken the event. If the generator is closed
|
|
229
|
+
// mid-yield we resume one early and the merger dedups the repeat -- a duplicate is
|
|
230
|
+
// recoverable, a gap is not.
|
|
231
|
+
const sequence = readSessionEventSequence(event);
|
|
232
|
+
if (sequence > resumeSequence) resumeSequence = sequence;
|
|
204
233
|
} catch {
|
|
205
234
|
yield { source: "sse", raw: payload };
|
|
206
235
|
}
|
|
@@ -255,7 +284,7 @@ export async function* mergeLiveSources({
|
|
|
255
284
|
: watchRemoteStream({ apiBaseUrl, sessionId, token, signal })
|
|
256
285
|
: null;
|
|
257
286
|
|
|
258
|
-
const seen = new
|
|
287
|
+
const seen = new Map();
|
|
259
288
|
const queue = [];
|
|
260
289
|
let pending = null;
|
|
261
290
|
|
|
@@ -301,14 +330,16 @@ export async function* mergeLiveSources({
|
|
|
301
330
|
if (item.event) {
|
|
302
331
|
const key = eventKey(item.event);
|
|
303
332
|
if (key) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
333
|
+
const keys = sessionEventIdentityKeys(item.event);
|
|
334
|
+
const existing = keys.map((identity) => seen.get(identity)).find(Boolean) || seen.get(key);
|
|
335
|
+
if (existing && !sessionEventUpgradesExisting(existing, item.event)) continue;
|
|
336
|
+
for (const identity of keys) seen.set(identity, item.event);
|
|
337
|
+
seen.set(key, item.event);
|
|
307
338
|
if (seen.size > 5000) {
|
|
308
339
|
// bound memory — older keys roll out
|
|
309
340
|
const trimmed = Array.from(seen).slice(-2500);
|
|
310
341
|
seen.clear();
|
|
311
|
-
for (const
|
|
342
|
+
for (const [identity, event] of trimmed) seen.set(identity, event);
|
|
312
343
|
}
|
|
313
344
|
}
|
|
314
345
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createAgentEvent } from "../events/schema.js";
|
|
2
|
+
import { enrichEventWithMentions } from "./mentions.js";
|
|
3
|
+
import { sessionEventIdentityKeys } from "./event-identity.js";
|
|
4
|
+
|
|
5
|
+
export function messageRevision(value = {}) {
|
|
6
|
+
const revision = Number(value.messageRevision ?? value.message_revision ?? 1);
|
|
7
|
+
return Number.isSafeInteger(revision) && revision > 0 ? revision : 1;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function replyRevisionEvent(sessionId, reply = {}) {
|
|
11
|
+
if (!reply.id || (reply.actionType ?? reply.action_type) !== "reply") return null;
|
|
12
|
+
const sequence = reply.targetSequenceId ?? reply.target_sequence_id ?? null;
|
|
13
|
+
const target = sequence ? `#${sequence}` : reply.targetCursor || "target";
|
|
14
|
+
const note = String(reply.note ?? "");
|
|
15
|
+
return createAgentEvent({
|
|
16
|
+
event: "session_reply",
|
|
17
|
+
agentId: reply.actorId ?? reply.actor_id ?? "unknown",
|
|
18
|
+
agentModel: reply.actorKind ?? reply.actor_kind ?? "session-action",
|
|
19
|
+
sessionId,
|
|
20
|
+
eventId: `session-action-${reply.id}`,
|
|
21
|
+
cursor: `action:${reply.id}`,
|
|
22
|
+
ts: reply.createdAt ?? reply.created_at,
|
|
23
|
+
messageRevision: messageRevision(reply),
|
|
24
|
+
editedAt: reply.editedAt,
|
|
25
|
+
editedBy: reply.editedBy,
|
|
26
|
+
canEdit: reply.canEdit,
|
|
27
|
+
revisionEvidence: reply.revisionEvidence,
|
|
28
|
+
payload: {
|
|
29
|
+
actionId: reply.id,
|
|
30
|
+
actionType: "reply",
|
|
31
|
+
targetSequenceId: sequence,
|
|
32
|
+
targetCursor: reply.targetCursor || null,
|
|
33
|
+
targetActionId: reply.targetActionId || null,
|
|
34
|
+
note,
|
|
35
|
+
metadata: reply.metadata && typeof reply.metadata === "object" ? reply.metadata : undefined,
|
|
36
|
+
message: `reply ${target}: ${note}`,
|
|
37
|
+
source: "session_action",
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Edits are immutable notifications with their own sequence/identity. Only the
|
|
43
|
+
// nested snapshot contributes to the CURRENT body; it is never replayed as a
|
|
44
|
+
// new authored message or task directive.
|
|
45
|
+
export function sessionMessageEditSnapshot(event = {}) {
|
|
46
|
+
if (event.event !== "session_message_edited" || event.payload?.schema !== "session-message-edit:v1") return null;
|
|
47
|
+
const payload = event.payload;
|
|
48
|
+
if (payload.targetActionId) {
|
|
49
|
+
const reply = payload.reply;
|
|
50
|
+
if (reply?.id !== payload.targetActionId || messageRevision(reply) !== messageRevision(payload)) return null;
|
|
51
|
+
return replyRevisionEvent(event.sessionId, reply);
|
|
52
|
+
}
|
|
53
|
+
const snapshot = payload.event;
|
|
54
|
+
if (!snapshot?.id || snapshot.id !== payload.targetMessageId || messageRevision(snapshot) !== messageRevision(payload)) return null;
|
|
55
|
+
return snapshot;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function projectSessionMessageEdits(events = []) {
|
|
59
|
+
const snapshots = new Map();
|
|
60
|
+
for (const event of events) {
|
|
61
|
+
const snapshot = sessionMessageEditSnapshot(event) || (["session_message", "session_reply"].includes(event.event) ? event : null);
|
|
62
|
+
if (!snapshot) continue;
|
|
63
|
+
for (const key of sessionEventIdentityKeys(snapshot)) {
|
|
64
|
+
if (!snapshots.has(key) || messageRevision(snapshot) > messageRevision(snapshots.get(key))) snapshots.set(key, snapshot);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return events.map((event) => {
|
|
68
|
+
if (event.event === "session_message_edited") return event;
|
|
69
|
+
const snapshot = sessionEventIdentityKeys(event).map((key) => snapshots.get(key)).filter(Boolean)
|
|
70
|
+
.sort((left, right) => messageRevision(right) - messageRevision(left))[0];
|
|
71
|
+
return snapshot && messageRevision(snapshot) > messageRevision(event) ? { ...event, ...snapshot } : event;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Routing consumes the immutable revision snapshot, but delivery/dedupe always
|
|
76
|
+
// consumes the notification's own identity. Reply edits retain parent routing.
|
|
77
|
+
export function sessionMessageRoutingEvent(event = {}) {
|
|
78
|
+
const snapshot = sessionMessageEditSnapshot(event);
|
|
79
|
+
if (!snapshot) return event;
|
|
80
|
+
const parentPayload = event.payload?.event?.payload || {};
|
|
81
|
+
const payload = { ...parentPayload, ...snapshot.payload };
|
|
82
|
+
if (event.payload?.reply) payload.message = String(event.payload.reply.note ?? "");
|
|
83
|
+
for (const key of ["to", "recipient", "recipients", "targetAgent", "targetAgentId", "broadcast", "mentions"]) {
|
|
84
|
+
if (event.payload[key] !== undefined) payload[key] = event.payload[key];
|
|
85
|
+
}
|
|
86
|
+
const routed = enrichEventWithMentions({ ...snapshot, agent: event.agent || snapshot.agent, payload });
|
|
87
|
+
if (routed.payload?.mentions?.broadcast?.length) routed.payload.broadcast = true;
|
|
88
|
+
return routed;
|
|
89
|
+
}
|
package/src/session/recap.js
CHANGED
|
@@ -923,7 +923,10 @@ function buildRecapText({
|
|
|
923
923
|
const lockText = `${activeLocks} file lock${activeLocks === 1 ? "" : "s"} active`;
|
|
924
924
|
const pendingText =
|
|
925
925
|
pendingTasks > 0 ? `You have ${pendingTasks} pending task${pendingTasks === 1 ? "" : "s"}.` : "";
|
|
926
|
-
const taskText = buildTaskLedgerText(taskLedger
|
|
926
|
+
const taskText = buildTaskLedgerText(taskLedger, {
|
|
927
|
+
liveListenerCount: liveListeners.length,
|
|
928
|
+
recentActorCount: recentActors.length,
|
|
929
|
+
});
|
|
927
930
|
const usageText = buildUsageLedgerText(usageSummary);
|
|
928
931
|
const workPlanText = buildWorkPlanText(workPlan);
|
|
929
932
|
const snippetText = snippets.length > 0 ? `Recent: ${snippets.join(" | ")}` : "";
|
|
@@ -933,9 +936,20 @@ function buildRecapText({
|
|
|
933
936
|
).trim();
|
|
934
937
|
}
|
|
935
938
|
|
|
936
|
-
function buildTaskLedgerText(
|
|
939
|
+
function buildTaskLedgerText(
|
|
940
|
+
taskLedger = emptyTaskLedgerSummary(),
|
|
941
|
+
{ liveListenerCount = 0, recentActorCount = 0 } = {},
|
|
942
|
+
) {
|
|
937
943
|
const total = Number(taskLedger.total || 0);
|
|
938
944
|
if (!total) {
|
|
945
|
+
const listeners = Math.max(0, Math.floor(Number(liveListenerCount || 0)));
|
|
946
|
+
const actors = Math.max(0, Math.floor(Number(recentActorCount || 0)));
|
|
947
|
+
if (listeners > 0) {
|
|
948
|
+
return `Session tasks: none queued (${listeners} live listener${listeners === 1 ? "" : "s"}; no Senti task assignments recorded)`;
|
|
949
|
+
}
|
|
950
|
+
if (actors > 0) {
|
|
951
|
+
return `Session tasks: none queued (${actors} recent actor${actors === 1 ? "" : "s"}; no Senti task assignments recorded)`;
|
|
952
|
+
}
|
|
939
953
|
return "Session tasks: none queued";
|
|
940
954
|
}
|
|
941
955
|
const active = Number(taskLedger.active || 0);
|
|
@@ -1112,7 +1126,7 @@ const AGENT_JOIN_RULES = [
|
|
|
1112
1126
|
"",
|
|
1113
1127
|
"**Writing back** — You can use **markdown**: bold, italic, lists, fenced code, and `inline code`. The web dashboard renders it. Plain text also works. Keep posts terse and technical — link to the work, don't recap it.",
|
|
1114
1128
|
"",
|
|
1115
|
-
"**Actions and threading** — Use message actions instead of top-level ACK chatter: `sl session react <id> ack --target-sequence <n>` only when an explicit ACK matters, and `sl session action <id> working_on --target-sequence <n>` for ownership. `sl session read <id> --remote --agent <your-name>` advances one monotonic per-agent
|
|
1129
|
+
"**Actions and threading** — Use message actions instead of top-level ACK chatter: `sl session react <id> ack --target-sequence <n>` only when an explicit ACK matters, and `sl session action <id> working_on --target-sequence <n>` for ownership. `sl session read <id> --remote --agent <your-name>` advances one monotonic per-agent delivery cursor, never a durable view event or proof of human viewing. Reply to a specific message with `sl session reply <id> <sequence> \"<message>\"`, `sl session comment <id> <sequence> \"<message>\"`, or `sl session say <id> \"<message>\" --reply-to <sequence>`; correct your own message with `sl session edit <id> <sequence-or-reply-uuid> \"<replacement>\"`. Edits retain audit history and notify recipients; only start a new top-level post for a new topic. Run `sl session actions` for the full list.",
|
|
1116
1130
|
"",
|
|
1117
1131
|
"**Search before asking** — Use `sl session search <id> \"<topic>\" --limit 10` to recover old context before asking another agent to re-paste or summarize what is already in the transcript.",
|
|
1118
1132
|
"",
|
|
@@ -1146,7 +1160,10 @@ function buildPeriodicText(recap = {}) {
|
|
|
1146
1160
|
? "no live listeners"
|
|
1147
1161
|
: "listener status unknown";
|
|
1148
1162
|
const recentActorText = `${recentActors} recent actor${recentActors === 1 ? "" : "s"}`;
|
|
1149
|
-
const taskText = buildTaskLedgerText(summary.taskLedger
|
|
1163
|
+
const taskText = buildTaskLedgerText(summary.taskLedger, {
|
|
1164
|
+
liveListenerCount: liveListeners,
|
|
1165
|
+
recentActorCount: recentActors,
|
|
1166
|
+
});
|
|
1150
1167
|
const workPlanText = buildWorkPlanText(summary.workPlan);
|
|
1151
1168
|
const usageText = buildUsageLedgerText({
|
|
1152
1169
|
totals: summary.usageTotals,
|
|
@@ -1216,6 +1233,7 @@ export async function buildSessionRecap(
|
|
|
1216
1233
|
);
|
|
1217
1234
|
const liveListenerIds = liveListenerRows.map((row) => row.agentId).filter(Boolean);
|
|
1218
1235
|
|
|
1236
|
+
const selectedSourceEvents = events.filter(isMeaningfulRecapSourceEvent);
|
|
1219
1237
|
const actorEvents = visibleEvents.filter(isMeaningfulRecapSourceEvent);
|
|
1220
1238
|
const recentActorSet = new Set();
|
|
1221
1239
|
for (const event of actorEvents) {
|
|
@@ -1294,6 +1312,10 @@ export async function buildSessionRecap(
|
|
|
1294
1312
|
usageTotals: usageSummary.totals,
|
|
1295
1313
|
usageTopAgents: usageSummary.topAgents,
|
|
1296
1314
|
snippets,
|
|
1315
|
+
selectedEventCount: events.length,
|
|
1316
|
+
selectedSourceEventCount: selectedSourceEvents.length,
|
|
1317
|
+
visibleSourceEventCount: actorEvents.length,
|
|
1318
|
+
ignoredOperationalEventCount: Math.max(0, events.length - selectedSourceEvents.length),
|
|
1297
1319
|
elapsedMinutes,
|
|
1298
1320
|
windowElapsedMinutes,
|
|
1299
1321
|
sessionStartedAt: sessionMetadata?.createdAt
|
|
@@ -32,8 +32,10 @@ import {
|
|
|
32
32
|
sessionEventIdentityKeys,
|
|
33
33
|
sessionEventHasKnownIdentity,
|
|
34
34
|
sessionEventUpgradesExisting,
|
|
35
|
+
dedupeSessionEvents,
|
|
35
36
|
} from "./event-identity.js";
|
|
36
37
|
import { isSessionControlEvent } from "./control-events.js";
|
|
38
|
+
import { sessionMessageEditSnapshot } from "./message-edits.js";
|
|
37
39
|
|
|
38
40
|
const EVENTS_CURSOR_SUFFIX = "events";
|
|
39
41
|
const DEFAULT_EVENT_PAGE_LIMIT = 200;
|
|
@@ -370,25 +372,11 @@ export async function hydrateSessionFromRemote({
|
|
|
370
372
|
|
|
371
373
|
// Dedup across sources — both endpoints can return the same event
|
|
372
374
|
// (e.g. a human relay event). Cursor values are unique per event.
|
|
373
|
-
const
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if (c && seenCursors.has(c)) continue;
|
|
379
|
-
if (sessionEventHasKnownIdentity(e, seenKeys)) continue;
|
|
380
|
-
if (c) seenCursors.add(c);
|
|
381
|
-
addSessionEventIdentityKeys(seenKeys, e);
|
|
382
|
-
merged.push(e);
|
|
383
|
-
}
|
|
384
|
-
for (const e of eventsResult?.events || []) {
|
|
385
|
-
const c = (e && typeof e === "object" && typeof e.cursor === "string") ? e.cursor : "";
|
|
386
|
-
if (c && seenCursors.has(c)) continue;
|
|
387
|
-
if (sessionEventHasKnownIdentity(e, seenKeys)) continue;
|
|
388
|
-
if (c) seenCursors.add(c);
|
|
389
|
-
addSessionEventIdentityKeys(seenKeys, e);
|
|
390
|
-
merged.push(e);
|
|
391
|
-
}
|
|
375
|
+
const sourceEvents = [...(humanResult?.events || []), ...(eventsResult?.events || [])];
|
|
376
|
+
const merged = dedupeSessionEvents(sourceEvents.flatMap((event) => {
|
|
377
|
+
const snapshot = sessionMessageEditSnapshot(event);
|
|
378
|
+
return snapshot ? [snapshot, event] : [event];
|
|
379
|
+
}));
|
|
392
380
|
|
|
393
381
|
// If BOTH pollers failed, surface the human-message failure (the
|
|
394
382
|
// legacy contract) so existing callers see no behavior change. If
|
package/src/session/store.js
CHANGED
|
@@ -4,10 +4,10 @@ import path from "node:path";
|
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
|
|
6
6
|
import { buildArtifactLineageIndex, verifyArtifactChain } from "../daemon/artifact-lineage.js";
|
|
7
|
-
import {
|
|
7
|
+
import { readSessionCodebaseContext } from "./codebase-context.js";
|
|
8
8
|
import { computeSessionAnalytics } from "./analytics.js";
|
|
9
9
|
import { resolveSessionPaths, resolveSessionsRoot } from "./paths.js";
|
|
10
|
-
import { appendToStream } from "./stream.js";
|
|
10
|
+
import { appendToStream, withSessionStreamLock } from "./stream.js";
|
|
11
11
|
|
|
12
12
|
const SESSION_SCHEMA_VERSION = "1.0.0";
|
|
13
13
|
const DEFAULT_TTL_SECONDS = 24 * 60 * 60;
|
|
@@ -247,13 +247,7 @@ function normalizeSessionTemplate(raw = null) {
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
async function collectSessionCodebaseContext(targetPath) {
|
|
250
|
-
|
|
251
|
-
const cachedIngest = await readJsonFile(cachedIngestPath, { allowMissing: true });
|
|
252
|
-
if (cachedIngest && typeof cachedIngest === "object") {
|
|
253
|
-
return normalizeCodebaseContext(cachedIngest);
|
|
254
|
-
}
|
|
255
|
-
const ingest = await collectCodebaseIngest({ rootPath: targetPath });
|
|
256
|
-
return normalizeCodebaseContext(ingest);
|
|
250
|
+
return normalizeCodebaseContext(await readSessionCodebaseContext(targetPath));
|
|
257
251
|
}
|
|
258
252
|
|
|
259
253
|
async function buildArchiveSidecars(
|
|
@@ -507,11 +501,16 @@ export async function createSession({
|
|
|
507
501
|
}
|
|
508
502
|
);
|
|
509
503
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
504
|
+
return withSessionStreamLock(sessionId, async () => {
|
|
505
|
+
// A concurrent materializer may have won while optional context was read.
|
|
506
|
+
// Never reset its title/TTL/status or race its first billing append.
|
|
507
|
+
const existing = await loadMetadata(sessionId, { targetPath: resolvedTargetPath });
|
|
508
|
+
if (existing) return buildSessionPayload(existing.metadata, paths, nowIso);
|
|
509
|
+
await fsp.mkdir(paths.agentsDir, { recursive: true });
|
|
510
|
+
await saveMetadata(metadata, paths);
|
|
511
|
+
await fsp.writeFile(paths.streamPath, "", { encoding: "utf-8", flag: "a" });
|
|
512
|
+
return buildSessionPayload(metadata, paths, nowIso);
|
|
513
|
+
}, { targetPath: resolvedTargetPath });
|
|
515
514
|
}
|
|
516
515
|
|
|
517
516
|
export async function updateSessionTitle(
|