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,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENGRAM — build lifecycle: absent | building | ready.
|
|
3
|
+
*
|
|
4
|
+
* An engram that is auto-built on session load is queryable by definition: something
|
|
5
|
+
* will ask it a question while it is still being written. The dangerous answer is not
|
|
6
|
+
* an error, it is a PLAUSIBLE one — a needle search over a half-ingested document
|
|
7
|
+
* finds the best match among the chunks that happen to exist and returns it with the
|
|
8
|
+
* same confidence it would give a complete index. The caller cannot tell the
|
|
9
|
+
* difference, because a partial index does not look partial from the outside.
|
|
10
|
+
*
|
|
11
|
+
* So readiness is a fact that must be RECORDED, never inferred:
|
|
12
|
+
*
|
|
13
|
+
* absent no build has started
|
|
14
|
+
* building a build started and has not recorded completion -- REFUSES queries
|
|
15
|
+
* ready a build recorded completion, and the counts and digest agree
|
|
16
|
+
*
|
|
17
|
+
* THE CASE THIS IS BUILT AROUND: a build that DIES halfway. Nothing gets to observe
|
|
18
|
+
* the crash, so the state must be wrong-by-default in the safe direction. `begin`
|
|
19
|
+
* writes `building` BEFORE any chunk is written and only `complete` moves it to
|
|
20
|
+
* `ready`, so a killed process leaves `building` on disk forever. That is the
|
|
21
|
+
* intended outcome -- an engram nobody finished must never answer as though someone
|
|
22
|
+
* had. Recovery is to re-run the build, which is safe because ingestion is idempotent.
|
|
23
|
+
*
|
|
24
|
+
* A torn or unparseable state file is treated as `building` for the same reason:
|
|
25
|
+
* every unreadable state resolves to the one that refuses.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import fsp from "node:fs/promises";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
|
|
31
|
+
import { namespaceDir } from "./store.js";
|
|
32
|
+
|
|
33
|
+
export const BUILD_STATES = Object.freeze({
|
|
34
|
+
ABSENT: "absent",
|
|
35
|
+
BUILDING: "building",
|
|
36
|
+
READY: "ready",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const STATE_FILE = "build-state.json";
|
|
40
|
+
|
|
41
|
+
/** Raised when a namespace is asked a question it cannot honestly answer yet. */
|
|
42
|
+
export class EngramNotReadyError extends Error {
|
|
43
|
+
constructor(namespace, state) {
|
|
44
|
+
super(`engram namespace "${namespace}" is ${state}, not ready: refusing to answer from a partial index`);
|
|
45
|
+
this.name = "EngramNotReadyError";
|
|
46
|
+
this.state = state;
|
|
47
|
+
this.namespace = namespace;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function stateFile(root, namespace) {
|
|
52
|
+
return path.join(namespaceDir(root, namespace), STATE_FILE);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write JSON so a crash mid-write cannot leave a half-file that parses.
|
|
57
|
+
* Temp file + rename is atomic within a directory on every platform we run on.
|
|
58
|
+
*/
|
|
59
|
+
async function writeAtomic(file, value) {
|
|
60
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
61
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
62
|
+
await fsp.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
|
|
63
|
+
await fsp.rename(tmp, file);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createBuildState({ storeRoot } = {}) {
|
|
67
|
+
const root = path.resolve(String(storeRoot || "."));
|
|
68
|
+
|
|
69
|
+
async function read(namespace) {
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = await fsp.readFile(stateFile(root, namespace), "utf-8");
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error && error.code === "ENOENT") return { state: BUILD_STATES.ABSENT };
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
} catch {
|
|
81
|
+
// Unreadable state resolves to the one that REFUSES, never to ready.
|
|
82
|
+
return { state: BUILD_STATES.BUILDING, reason: "state file is unreadable" };
|
|
83
|
+
}
|
|
84
|
+
if (!parsed || typeof parsed !== "object") {
|
|
85
|
+
return { state: BUILD_STATES.BUILDING, reason: "state file is not an object" };
|
|
86
|
+
}
|
|
87
|
+
if (parsed.state === BUILD_STATES.READY) {
|
|
88
|
+
// READY is only honoured when its own record is self-consistent. A count or
|
|
89
|
+
// digest that disagrees means something wrote this file without finishing the
|
|
90
|
+
// work it describes.
|
|
91
|
+
const consistent =
|
|
92
|
+
Number.isInteger(parsed.expectedChunks) &&
|
|
93
|
+
Number.isInteger(parsed.writtenChunks) &&
|
|
94
|
+
parsed.expectedChunks === parsed.writtenChunks &&
|
|
95
|
+
typeof parsed.completedAt === "string" &&
|
|
96
|
+
parsed.completedAt.length > 0;
|
|
97
|
+
if (!consistent) {
|
|
98
|
+
return { ...parsed, state: BUILD_STATES.BUILDING, reason: "ready record is inconsistent" };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return parsed;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
read,
|
|
106
|
+
|
|
107
|
+
/** Mark a build STARTED. Must be called before the first chunk is written. */
|
|
108
|
+
async begin(namespace, { docDigest = null, expectedChunks = null, startedAt } = {}) {
|
|
109
|
+
const record = {
|
|
110
|
+
state: BUILD_STATES.BUILDING,
|
|
111
|
+
namespace: namespace.raw,
|
|
112
|
+
docDigest,
|
|
113
|
+
expectedChunks,
|
|
114
|
+
writtenChunks: 0,
|
|
115
|
+
startedAt: startedAt || new Date().toISOString(),
|
|
116
|
+
completedAt: null,
|
|
117
|
+
};
|
|
118
|
+
await writeAtomic(stateFile(root, namespace), record);
|
|
119
|
+
return record;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Mark a build COMPLETE. Refuses when the work does not match what `begin`
|
|
124
|
+
* promised -- completing a build that wrote fewer chunks than expected would
|
|
125
|
+
* stamp `ready` on a partial index, which is the whole failure this module exists
|
|
126
|
+
* to prevent.
|
|
127
|
+
*/
|
|
128
|
+
async complete(namespace, { writtenChunks, completedAt } = {}) {
|
|
129
|
+
const current = await read(namespace);
|
|
130
|
+
if (current.state === BUILD_STATES.ABSENT) {
|
|
131
|
+
throw new Error("cannot complete a build that never began");
|
|
132
|
+
}
|
|
133
|
+
if (!Number.isInteger(writtenChunks) || writtenChunks < 0) {
|
|
134
|
+
throw new TypeError("writtenChunks must be a non-negative integer");
|
|
135
|
+
}
|
|
136
|
+
if (Number.isInteger(current.expectedChunks) && current.expectedChunks !== writtenChunks) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`refusing to mark ready: expected ${current.expectedChunks} chunks, wrote ${writtenChunks}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const record = {
|
|
142
|
+
...current,
|
|
143
|
+
state: BUILD_STATES.READY,
|
|
144
|
+
writtenChunks,
|
|
145
|
+
expectedChunks: Number.isInteger(current.expectedChunks) ? current.expectedChunks : writtenChunks,
|
|
146
|
+
completedAt: completedAt || new Date().toISOString(),
|
|
147
|
+
reason: undefined,
|
|
148
|
+
};
|
|
149
|
+
delete record.reason;
|
|
150
|
+
await writeAtomic(stateFile(root, namespace), record);
|
|
151
|
+
return record;
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Throw ONLY when a build is in flight. The gate the RETRIEVAL path calls.
|
|
156
|
+
*
|
|
157
|
+
* Deliberately weaker than `assertQueryable`, and the difference is the whole
|
|
158
|
+
* reason this exists: `absent` must be ALLOWED. A session namespace is adapter-
|
|
159
|
+
* backed and live — nobody ever "builds" it, so it has no state file and never
|
|
160
|
+
* will. Requiring `ready` there would refuse every session recall in the product.
|
|
161
|
+
*
|
|
162
|
+
* What must be refused is the state where answering is actively misleading: a
|
|
163
|
+
* document half-written to disk, where recall would return the best match among
|
|
164
|
+
* the chunks that happen to exist and look exactly like a complete answer.
|
|
165
|
+
*/
|
|
166
|
+
async assertNotBuilding(namespace) {
|
|
167
|
+
const current = await read(namespace);
|
|
168
|
+
if (current.state === BUILD_STATES.BUILDING) {
|
|
169
|
+
throw new EngramNotReadyError(namespace.raw, current.state);
|
|
170
|
+
}
|
|
171
|
+
return current;
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
/** Throw unless the namespace is ready. For callers that require a finished build. */
|
|
175
|
+
async assertQueryable(namespace) {
|
|
176
|
+
const current = await read(namespace);
|
|
177
|
+
if (current.state !== BUILD_STATES.READY) {
|
|
178
|
+
throw new EngramNotReadyError(namespace.raw, current.state);
|
|
179
|
+
}
|
|
180
|
+
return current;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// canonical.js — the spine's canonical JSON encoder (ENGRAM identity substrate).
|
|
2
|
+
//
|
|
3
|
+
// Implements the JCS-subset of respawn contract §19.1 (docs/contracts/checkpoint-proof-identity.md,
|
|
4
|
+
// respawn repo): RFC 8785 canonical JSON restricted to INTEGER-ONLY numbers. This is the encoder
|
|
5
|
+
// whose output bytes are the preimage for content-addressed observation ids (sha256 over canonical
|
|
6
|
+
// JSON). Conformance: tests/fixtures/engram/canonicalization-vectors-v1.json (published hash
|
|
7
|
+
// 16b93737…, five vectors incl. the NFC/NFD never-normalize pair) — exercised by
|
|
8
|
+
// tests/unit.engram-canonical.test.mjs (required glob) and conformance gate (i) (§21.3).
|
|
9
|
+
//
|
|
10
|
+
// Rules (each REFUSES rather than guessing — no third behavior, §19.1):
|
|
11
|
+
// • Object keys sorted by UTF-16 code units (RFC 8785 §3.2.3). No whitespace.
|
|
12
|
+
// • Strings serialized per JSON.stringify (JCS-compatible escaping), NEVER unicode-normalized:
|
|
13
|
+
// NFC and NFD spellings are DIFFERENT bytes and must yield different digests (vector 4).
|
|
14
|
+
// • Numbers: safe integers only. -0 encodes as 0. Non-integers, NaN, ±Infinity, unsafe
|
|
15
|
+
// magnitudes, and BigInt REFUSE at encode time.
|
|
16
|
+
// • Values: null, boolean, string, integer, array, PLAIN object only. Dates, Maps, class
|
|
17
|
+
// instances, functions, symbols, and undefined REFUSE — an identity encoder must never
|
|
18
|
+
// silently coerce. Circular structures REFUSE.
|
|
19
|
+
// • Object entries whose value is undefined REFUSE (not skipped): an absent optional is
|
|
20
|
+
// expressed by OMITTING the key (vector 3), and silently dropping undefined would let two
|
|
21
|
+
// different in-memory shapes share one identity.
|
|
22
|
+
|
|
23
|
+
const REFUSE = (msg) => {
|
|
24
|
+
throw new TypeError(`canonicalize: ${msg}`);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function isPlainObject(v) {
|
|
28
|
+
if (v === null || typeof v !== "object") return false;
|
|
29
|
+
const proto = Object.getPrototypeOf(v);
|
|
30
|
+
return proto === Object.prototype || proto === null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function encode(value, seen, path) {
|
|
34
|
+
switch (typeof value) {
|
|
35
|
+
case "boolean":
|
|
36
|
+
return value ? "true" : "false";
|
|
37
|
+
case "number": {
|
|
38
|
+
if (!Number.isFinite(value)) REFUSE(`non-finite number at ${path} — integer-only (§19.1)`);
|
|
39
|
+
if (!Number.isInteger(value)) REFUSE(`non-integer number ${value} at ${path} — integer-only (§19.1)`);
|
|
40
|
+
if (!Number.isSafeInteger(value)) REFUSE(`integer magnitude beyond 2^53-1 at ${path} — unrepresentable exactly`);
|
|
41
|
+
return String(value === 0 ? 0 : value); // -0 → "0" (RFC 8785)
|
|
42
|
+
}
|
|
43
|
+
case "string":
|
|
44
|
+
return JSON.stringify(value); // JCS-compatible escaping; NO normalization
|
|
45
|
+
case "bigint":
|
|
46
|
+
REFUSE(`BigInt at ${path} — integer-only means JSON numbers (§19.1)`);
|
|
47
|
+
break;
|
|
48
|
+
case "undefined":
|
|
49
|
+
REFUSE(`undefined at ${path} — absent optionals OMIT the key`);
|
|
50
|
+
break;
|
|
51
|
+
case "function":
|
|
52
|
+
case "symbol":
|
|
53
|
+
REFUSE(`${typeof value} at ${path} — not a JSON value`);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
if (value === null) return "null";
|
|
57
|
+
if (seen.has(value)) REFUSE(`circular reference at ${path}`);
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
seen.add(value);
|
|
60
|
+
const parts = value.map((item, i) => encode(item, seen, `${path}[${i}]`));
|
|
61
|
+
seen.delete(value);
|
|
62
|
+
return `[${parts.join(",")}]`;
|
|
63
|
+
}
|
|
64
|
+
if (!isPlainObject(value)) REFUSE(`non-plain object (${value.constructor?.name ?? "unknown"}) at ${path} — refuse, never coerce`);
|
|
65
|
+
seen.add(value);
|
|
66
|
+
const keys = Object.keys(value).sort(); // default sort = UTF-16 code unit order (RFC 8785 §3.2.3)
|
|
67
|
+
const parts = keys.map((k) => `${JSON.stringify(k)}:${encode(value[k], seen, `${path}.${k}`)}`);
|
|
68
|
+
seen.delete(value);
|
|
69
|
+
return `{${parts.join(",")}}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Canonicalize a value to its §19.1 canonical JSON string (UTF-8 of this string is the digest preimage). */
|
|
73
|
+
export function canonicalize(value) {
|
|
74
|
+
return encode(value, new Set(), "$");
|
|
75
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENGRAM — document ingestion with SPAN-LEVEL PROVENANCE.
|
|
3
|
+
*
|
|
4
|
+
* The session adapter's atom is a message, addressed by `sequenceId`. A document
|
|
5
|
+
* has no such atom: it must be chunked, and the moment you chunk without recording
|
|
6
|
+
* each chunk's position you can name a chunk but cannot prove it is the line the
|
|
7
|
+
* caller asked for. "What is on page 3337 line 45" then gets answered by something
|
|
8
|
+
* that SOUNDS right, which is worse than not answering.
|
|
9
|
+
*
|
|
10
|
+
* So every chunk carries where it came from, and `resolveSpan` re-reads the source
|
|
11
|
+
* to prove the quote rather than trusting the stored copy.
|
|
12
|
+
*
|
|
13
|
+
* TWO THINGS THAT ARE NOT OBVIOUS AND ARE LOAD-BEARING:
|
|
14
|
+
*
|
|
15
|
+
* 1. IDENTITY MUST INCLUDE POSITION. `itemId` (store.js) hashes
|
|
16
|
+
* [kind, author, text, ts] and the store DEDUPES on it. Documents repeat
|
|
17
|
+
* themselves constantly -- blank lines, `---`, table rows, boilerplate, a
|
|
18
|
+
* recurring header -- and every repeat after the first would be silently
|
|
19
|
+
* dropped, its position unrecoverable, precisely when the queried line is a
|
|
20
|
+
* common one. `itemId` honours an explicit `id`, so chunks set one that
|
|
21
|
+
* includes the span. Same document re-ingested -> identical ids (idempotent,
|
|
22
|
+
* which is what makes "build once, serve everyone" real); same text at a
|
|
23
|
+
* different offset -> a different id, which is correct because it is a
|
|
24
|
+
* different occurrence.
|
|
25
|
+
*
|
|
26
|
+
* 2. OFFSETS ARE UTF-16 CODE UNITS, NOT BYTES. They index the JS string, so
|
|
27
|
+
* `text.slice(start, end)` is exact by construction. For any source containing
|
|
28
|
+
* astral characters these differ from byte offsets, and calling them "byte
|
|
29
|
+
* offsets" would be a claim this module does not support. `docDigest` is
|
|
30
|
+
* SHA-256 over the UTF-8 encoding of the whole document -- that one IS
|
|
31
|
+
* tamper-evidence, unlike the store's FNV-1a `contentHash`, which is a dedup
|
|
32
|
+
* key and nothing more.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { createHash } from "node:crypto";
|
|
36
|
+
|
|
37
|
+
/** Chunk kind. Distinct from `session` so a namespace's atoms are never confused. */
|
|
38
|
+
export const DOCUMENT_CHUNK_KIND = "document";
|
|
39
|
+
|
|
40
|
+
/** SHA-256 over the document's UTF-8 bytes. Tamper-evidence, not a dedup key. */
|
|
41
|
+
export function documentDigest(text) {
|
|
42
|
+
return createHash("sha256").update(String(text ?? ""), "utf8").digest("hex");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Deterministic, position-inclusive chunk id.
|
|
47
|
+
* Same (document, span) -> same id, on every run and every machine.
|
|
48
|
+
*/
|
|
49
|
+
export function chunkId(digest, start, end) {
|
|
50
|
+
return `doc:${String(digest).slice(0, 16)}:${start}-${end}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Split on line boundaries, accumulating whole lines up to `maxChars`.
|
|
55
|
+
*
|
|
56
|
+
* Line-aligned deliberately: the caller's question is "page N line M", so a chunk
|
|
57
|
+
* that begins mid-line makes the answer harder to prove than it needs to be. A
|
|
58
|
+
* single line longer than `maxChars` is emitted whole rather than split, because
|
|
59
|
+
* truncating it would produce a chunk whose text is not what the source says.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} text the whole document, verbatim
|
|
62
|
+
* @param {object} [options]
|
|
63
|
+
* @param {number} [options.maxChars] soft ceiling per chunk (UTF-16 code units)
|
|
64
|
+
* @param {number} [options.linesPerPage] lines per page for the `page` coordinate
|
|
65
|
+
* @returns {{start:number,end:number,line:number,endLine:number,page:number,text:string}[]}
|
|
66
|
+
*/
|
|
67
|
+
export function splitIntoSpans(text, { maxChars = 1200, linesPerPage = 50 } = {}) {
|
|
68
|
+
const source = String(text ?? "");
|
|
69
|
+
if (!source) return [];
|
|
70
|
+
if (!Number.isInteger(maxChars) || maxChars < 1) {
|
|
71
|
+
throw new TypeError("maxChars must be a positive integer");
|
|
72
|
+
}
|
|
73
|
+
if (!Number.isInteger(linesPerPage) || linesPerPage < 1) {
|
|
74
|
+
throw new TypeError("linesPerPage must be a positive integer");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const spans = [];
|
|
78
|
+
let chunkStart = 0;
|
|
79
|
+
let cursor = 0;
|
|
80
|
+
let line = 1;
|
|
81
|
+
let chunkStartLine = 1;
|
|
82
|
+
|
|
83
|
+
const flush = (end, endLine) => {
|
|
84
|
+
if (end <= chunkStart) return;
|
|
85
|
+
spans.push({
|
|
86
|
+
start: chunkStart,
|
|
87
|
+
end,
|
|
88
|
+
line: chunkStartLine,
|
|
89
|
+
endLine,
|
|
90
|
+
page: Math.floor((chunkStartLine - 1) / linesPerPage) + 1,
|
|
91
|
+
text: source.slice(chunkStart, end),
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
while (cursor < source.length) {
|
|
96
|
+
const nl = source.indexOf("\n", cursor);
|
|
97
|
+
const lineEnd = nl === -1 ? source.length : nl + 1;
|
|
98
|
+
// Would adding this line overflow a non-empty chunk? Close the chunk first.
|
|
99
|
+
if (lineEnd - chunkStart > maxChars && cursor > chunkStart) {
|
|
100
|
+
flush(cursor, line - 1);
|
|
101
|
+
chunkStart = cursor;
|
|
102
|
+
chunkStartLine = line;
|
|
103
|
+
}
|
|
104
|
+
cursor = lineEnd;
|
|
105
|
+
if (nl !== -1) line += 1;
|
|
106
|
+
}
|
|
107
|
+
flush(cursor, lastCoveredLine(line, source));
|
|
108
|
+
return spans;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The last line number covered when the document does not end in a newline. */
|
|
112
|
+
function lastCoveredLine(line, source) {
|
|
113
|
+
return source.endsWith("\n") ? Math.max(1, line - 1) : line;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Ingest a document as span-carrying store items.
|
|
118
|
+
*
|
|
119
|
+
* The span rides INSIDE the item (not alongside it) so it is covered by whatever
|
|
120
|
+
* digests the item, and the id encodes the span so the store's content-hash dedup
|
|
121
|
+
* cannot collapse two occurrences of the same text.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} text
|
|
124
|
+
* @param {object} options
|
|
125
|
+
* @param {string} options.source human-readable origin (filename, url, title)
|
|
126
|
+
* @param {string} [options.author]
|
|
127
|
+
* @param {string} [options.ts] ISO timestamp; defaults to now
|
|
128
|
+
* @returns {{docDigest:string, source:string, chars:number, items:object[]}}
|
|
129
|
+
*/
|
|
130
|
+
export function ingestDocument(text, { source, author = "document", ts, maxChars, linesPerPage } = {}) {
|
|
131
|
+
const body = String(text ?? "");
|
|
132
|
+
const origin = String(source ?? "").trim();
|
|
133
|
+
if (!origin) throw new TypeError("source is required: an unattributed span cannot be verified");
|
|
134
|
+
|
|
135
|
+
const docDigest = documentDigest(body);
|
|
136
|
+
const stamp = ts || new Date().toISOString();
|
|
137
|
+
const spans = splitIntoSpans(body, {
|
|
138
|
+
...(maxChars === undefined ? {} : { maxChars }),
|
|
139
|
+
...(linesPerPage === undefined ? {} : { linesPerPage }),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const items = spans.map((span) => ({
|
|
143
|
+
id: chunkId(docDigest, span.start, span.end),
|
|
144
|
+
kind: DOCUMENT_CHUNK_KIND,
|
|
145
|
+
author,
|
|
146
|
+
ts: stamp,
|
|
147
|
+
text: span.text,
|
|
148
|
+
span: {
|
|
149
|
+
start: span.start,
|
|
150
|
+
end: span.end,
|
|
151
|
+
line: span.line,
|
|
152
|
+
endLine: span.endLine,
|
|
153
|
+
page: span.page,
|
|
154
|
+
},
|
|
155
|
+
docDigest,
|
|
156
|
+
source: origin,
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
return { docDigest, source: origin, chars: body.length, items };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Re-derive a chunk's text FROM THE SOURCE and prove it matches what was stored.
|
|
164
|
+
*
|
|
165
|
+
* This is the whole point of the module: a needle answer is only trustworthy if
|
|
166
|
+
* the quote can be reproduced from the original at the coordinates it claims. A
|
|
167
|
+
* mismatch means the stored copy and the source have diverged, and the honest
|
|
168
|
+
* response is to refuse rather than serve the stored text as though verified.
|
|
169
|
+
*
|
|
170
|
+
* @returns {{ok:true, quote:string, start:number, end:number, line:number, page:number}
|
|
171
|
+
* | {ok:false, reason:string}}
|
|
172
|
+
*/
|
|
173
|
+
export function resolveSpan(documentText, item) {
|
|
174
|
+
const source = String(documentText ?? "");
|
|
175
|
+
const span = item?.span;
|
|
176
|
+
if (!span || !Number.isInteger(span.start) || !Number.isInteger(span.end)) {
|
|
177
|
+
return { ok: false, reason: "item carries no integer span" };
|
|
178
|
+
}
|
|
179
|
+
if (span.start < 0 || span.end > source.length || span.end < span.start) {
|
|
180
|
+
return { ok: false, reason: "span does not lie within the document" };
|
|
181
|
+
}
|
|
182
|
+
if (item.docDigest && documentDigest(source) !== item.docDigest) {
|
|
183
|
+
return { ok: false, reason: "document digest does not match the one the span was cut from" };
|
|
184
|
+
}
|
|
185
|
+
const quote = source.slice(span.start, span.end);
|
|
186
|
+
if (typeof item.text === "string" && quote !== item.text) {
|
|
187
|
+
return { ok: false, reason: "stored text does not match the source at that span" };
|
|
188
|
+
}
|
|
189
|
+
return { ok: true, quote, start: span.start, end: span.end, line: span.line, page: span.page };
|
|
190
|
+
}
|
package/src/engram/index.js
CHANGED
|
@@ -28,10 +28,13 @@ export {
|
|
|
28
28
|
export { createGovernance } from "./governance.js";
|
|
29
29
|
export { createMemoryTools } from "./tools.js";
|
|
30
30
|
export { runEngramSla } from "./sla.js";
|
|
31
|
+
export { ingestDocumentIntoNamespace } from "./ingest-document.js";
|
|
31
32
|
|
|
32
33
|
import { createStore } from "./store.js";
|
|
33
34
|
import { createLocalConsent } from "./namespace.js";
|
|
34
35
|
import { createGovernance } from "./governance.js";
|
|
36
|
+
import { createBuildState } from "./build-state.js";
|
|
37
|
+
import { ingestDocumentIntoNamespace } from "./ingest-document.js";
|
|
35
38
|
import { createMemoryTools } from "./tools.js";
|
|
36
39
|
import { createEmbedder } from "../session/recall/embedder.js";
|
|
37
40
|
|
|
@@ -59,6 +62,12 @@ export function createMemoryService({
|
|
|
59
62
|
isAuthorizedForSealed,
|
|
60
63
|
} = {}) {
|
|
61
64
|
const store = createStore({ storeRoot, adapters });
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
// Wired by DEFAULT, not opt-in: a readiness gate nobody injects is a comment.
|
|
66
|
+
const buildState = createBuildState({ storeRoot });
|
|
67
|
+
const tools = createMemoryTools({ store, consent, governance, embedder, renderer, isAuthorizedForSealed, buildState });
|
|
68
|
+
// Exposed on the service so the lifecycle cannot be bypassed by a caller who has a
|
|
69
|
+
// store but forgets the buildState -- the deps are bound here, once.
|
|
70
|
+
const ingestDocument = (options) =>
|
|
71
|
+
ingestDocumentIntoNamespace({ ...options, deps: { store, buildState } });
|
|
72
|
+
return { tools, store, buildState, ingestDocument };
|
|
64
73
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENGRAM — the document ingest path: the thing that makes the other three real.
|
|
3
|
+
*
|
|
4
|
+
* Before this, the pieces existed and none of them ran together:
|
|
5
|
+
* document.js could cut a document into span-carrying chunks -- nothing called it
|
|
6
|
+
* build-state.js could refuse a half-built namespace -- but nothing ever called
|
|
7
|
+
* `begin()`, so BUILDING was UNREACHABLE and the gate #808 wired
|
|
8
|
+
* could never actually fire
|
|
9
|
+
*
|
|
10
|
+
* A gate that is invoked but guards a state nothing can enter is a subtler version of
|
|
11
|
+
* a gate nobody invokes: it runs, it passes, and it has never once said no.
|
|
12
|
+
*
|
|
13
|
+
* ORDER IS THE WHOLE DESIGN, and it is chosen for the failure case rather than the
|
|
14
|
+
* happy one:
|
|
15
|
+
*
|
|
16
|
+
* 1. begin() BEFORE the first chunk is written
|
|
17
|
+
* 2. write chunks
|
|
18
|
+
* 3. complete() only after every chunk landed, with the count checked
|
|
19
|
+
*
|
|
20
|
+
* If the process dies at step 2 -- or the store throws, or the disk fills -- nothing
|
|
21
|
+
* runs step 3, so the namespace stays BUILDING and refuses queries FOREVER rather than
|
|
22
|
+
* silently serving a partial index. Recovery is to re-ingest, which is safe because
|
|
23
|
+
* chunk ids are content+position addressed: the chunks already written dedupe, the
|
|
24
|
+
* missing ones land, and the build completes.
|
|
25
|
+
*
|
|
26
|
+
* The alternative order -- write first, then mark building -- would leave a window
|
|
27
|
+
* where a partial index answers as though whole, which is the exact failure the
|
|
28
|
+
* lifecycle exists to prevent.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { ingestDocument } from "./document.js";
|
|
32
|
+
import { parseNamespace } from "./namespace.js";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Ingest a document into a namespace, under the build lifecycle.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} options
|
|
38
|
+
* @param {string} options.scope namespace, e.g. "document:handbook"
|
|
39
|
+
* @param {string} options.text the document, verbatim
|
|
40
|
+
* @param {string} options.source origin (filename, url, title) -- required
|
|
41
|
+
* @param {object} options.deps { store, buildState }
|
|
42
|
+
* @param {object} [options.caller]
|
|
43
|
+
* @param {string} [options.ts]
|
|
44
|
+
* @param {number} [options.maxChars]
|
|
45
|
+
* @param {number} [options.linesPerPage]
|
|
46
|
+
* @returns {Promise<{ok:true, namespace:string, docDigest:string, chunks:number,
|
|
47
|
+
* written:number, deduped:number, state:string}>}
|
|
48
|
+
*/
|
|
49
|
+
export async function ingestDocumentIntoNamespace({
|
|
50
|
+
scope,
|
|
51
|
+
text,
|
|
52
|
+
source,
|
|
53
|
+
deps: { store, buildState } = {},
|
|
54
|
+
caller,
|
|
55
|
+
ts,
|
|
56
|
+
maxChars,
|
|
57
|
+
linesPerPage,
|
|
58
|
+
} = {}) {
|
|
59
|
+
if (!store) throw new TypeError("a store is required");
|
|
60
|
+
if (!buildState) {
|
|
61
|
+
// Refuse rather than ingest without the lifecycle. Writing chunks with no
|
|
62
|
+
// build state would produce a namespace that is queryable while incomplete --
|
|
63
|
+
// the precise condition build-state.js exists to make impossible.
|
|
64
|
+
throw new TypeError("a buildState is required: ingesting without the lifecycle would leave a partial index queryable");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const namespace = parseNamespace(scope);
|
|
68
|
+
const cut = ingestDocument(text, {
|
|
69
|
+
source,
|
|
70
|
+
...(caller?.id ? { author: caller.id } : {}),
|
|
71
|
+
...(ts === undefined ? {} : { ts }),
|
|
72
|
+
...(maxChars === undefined ? {} : { maxChars }),
|
|
73
|
+
...(linesPerPage === undefined ? {} : { linesPerPage }),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// STEP 1 -- claim the namespace as BUILDING before a single chunk exists on disk.
|
|
77
|
+
await buildState.begin(namespace, {
|
|
78
|
+
docDigest: cut.docDigest,
|
|
79
|
+
expectedChunks: cut.items.length,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// STEP 2 -- write. If this throws, step 3 never runs and the namespace stays
|
|
83
|
+
// BUILDING, which is the correct outcome: an interrupted ingest must not answer.
|
|
84
|
+
const result = await store.appendItems(namespace, cut.items);
|
|
85
|
+
|
|
86
|
+
// STEP 3 -- complete, with the count. `complete` itself refuses if fewer chunks
|
|
87
|
+
// landed than were promised, so a short write cannot be stamped ready.
|
|
88
|
+
//
|
|
89
|
+
// The count is written + deduped, i.e. chunks PRESENT, not chunks newly added.
|
|
90
|
+
// Counting only new writes would make re-ingesting an already-complete document
|
|
91
|
+
// fail to complete (every chunk dedupes, so written === 0), which would strand a
|
|
92
|
+
// perfectly good namespace in BUILDING -- turning idempotent recovery into a way
|
|
93
|
+
// to break a working index.
|
|
94
|
+
const state = await buildState.complete(namespace, {
|
|
95
|
+
writtenChunks: result.written + result.deduped,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
namespace: namespace.raw,
|
|
101
|
+
docDigest: cut.docDigest,
|
|
102
|
+
chunks: cut.items.length,
|
|
103
|
+
written: result.written,
|
|
104
|
+
deduped: result.deduped,
|
|
105
|
+
state: state.state,
|
|
106
|
+
};
|
|
107
|
+
}
|
package/src/engram/store.js
CHANGED
|
@@ -21,7 +21,12 @@ import path from "node:path";
|
|
|
21
21
|
import { buildObservationsFromItems } from "./observations.js";
|
|
22
22
|
import { contentHash, normalizeString } from "../session/recall/text.js";
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Where a namespace's files live. EXPORTED so siblings (e.g. build-state.js) resolve
|
|
26
|
+
* the same directory rather than re-deriving the convention -- a second copy of this
|
|
27
|
+
* sanitization would drift and silently point at a different namespace.
|
|
28
|
+
*/
|
|
29
|
+
export function namespaceDir(root, namespace) {
|
|
25
30
|
const safe = namespace.raw.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
26
31
|
return path.join(root, ".sentinelayer", "engram", safe);
|
|
27
32
|
}
|
package/src/engram/tools.js
CHANGED
|
@@ -56,6 +56,7 @@ export function createMemoryTools({
|
|
|
56
56
|
store,
|
|
57
57
|
consent,
|
|
58
58
|
governance,
|
|
59
|
+
buildState = null,
|
|
59
60
|
embedder = createEmbedder(),
|
|
60
61
|
renderer = null,
|
|
61
62
|
isAuthorizedForSealed = () => false,
|
|
@@ -81,6 +82,11 @@ export function createMemoryTools({
|
|
|
81
82
|
async function retrieveInternal({ scope, query, k, role, caller }) {
|
|
82
83
|
const namespace = parseNamespace(scope);
|
|
83
84
|
authorize(caller, namespace, "read", { consent }); // fail-closed 403
|
|
85
|
+
// A half-built index must not answer. Gated HERE, in the shared core, so recall
|
|
86
|
+
// and summarize cannot diverge -- a second door is how a rule ends up enforced on
|
|
87
|
+
// one path and not the other. `absent` is allowed: session namespaces are
|
|
88
|
+
// adapter-backed and never "built", so only an in-flight build is refused.
|
|
89
|
+
if (buildState) await buildState.assertNotBuilding(namespace);
|
|
84
90
|
const authorizedForSealed = Boolean(isAuthorizedForSealed(caller, namespace));
|
|
85
91
|
|
|
86
92
|
const all = await store.readObservations(namespace);
|
package/src/events/schema.js
CHANGED
|
@@ -125,6 +125,12 @@ export function createAgentEvent({
|
|
|
125
125
|
cursor,
|
|
126
126
|
sequenceId,
|
|
127
127
|
sequence_id,
|
|
128
|
+
id,
|
|
129
|
+
messageRevision,
|
|
130
|
+
editedAt,
|
|
131
|
+
editedBy,
|
|
132
|
+
canEdit,
|
|
133
|
+
revisionEvidence,
|
|
128
134
|
} = {}) {
|
|
129
135
|
const normalizedEvent = normalizeNonEmptyString(event);
|
|
130
136
|
const normalizedAgent = normalizeAgentShape({
|
|
@@ -149,6 +155,12 @@ export function createAgentEvent({
|
|
|
149
155
|
workItemId: normalizeOptionalString(workItemId),
|
|
150
156
|
requestId: normalizeOptionalString(requestId),
|
|
151
157
|
eventId: normalizeOptionalString(eventId),
|
|
158
|
+
id: normalizeOptionalString(id),
|
|
159
|
+
messageRevision: normalizePositiveInteger(messageRevision),
|
|
160
|
+
editedAt: editedAt === null ? null : normalizeOptionalString(editedAt),
|
|
161
|
+
editedBy: editedBy === null ? null : isPlainObject(editedBy) ? { ...editedBy } : undefined,
|
|
162
|
+
canEdit: typeof canEdit === "boolean" ? canEdit : undefined,
|
|
163
|
+
revisionEvidence: isPlainObject(revisionEvidence) ? { ...revisionEvidence } : undefined,
|
|
152
164
|
idempotencyToken: normalizeOptionalString(idempotencyToken),
|
|
153
165
|
cursor: normalizeOptionalString(cursor),
|
|
154
166
|
sequenceId: normalizePositiveInteger(sequenceId ?? sequence_id),
|
|
@@ -220,6 +232,12 @@ export function normalizeAgentEvent(evt, { allowLegacy = true } = {}) {
|
|
|
220
232
|
workItemId: evt.workItemId,
|
|
221
233
|
requestId: evt.requestId,
|
|
222
234
|
eventId: evt.eventId,
|
|
235
|
+
id: evt.id,
|
|
236
|
+
messageRevision: evt.messageRevision,
|
|
237
|
+
editedAt: evt.editedAt,
|
|
238
|
+
editedBy: evt.editedBy,
|
|
239
|
+
canEdit: evt.canEdit,
|
|
240
|
+
revisionEvidence: evt.revisionEvidence,
|
|
223
241
|
idempotencyToken: evt.idempotencyToken,
|
|
224
242
|
cursor: evt.cursor,
|
|
225
243
|
sequenceId: evt.sequenceId ?? evt.sequence_id,
|
package/src/legacy-cli.js
CHANGED
|
@@ -228,8 +228,8 @@ function printUsage() {
|
|
|
228
228
|
console.log(" sl session action <id> working_on --target-sequence <n> Claim work on a message");
|
|
229
229
|
console.log(" sl session reply <id> <seq> \"msg\" Thread a response under a message");
|
|
230
230
|
console.log(" sl session comment <id> <seq> \"msg\" Alias for threaded reply");
|
|
231
|
-
console.log(" sl session read <id> --remote --agent <id> Read
|
|
232
|
-
console.log(" sl session
|
|
231
|
+
console.log(" sl session read <id> --remote --agent <id> Read events and advance delivery cursor");
|
|
232
|
+
console.log(" sl session edit <id> <seq|reply-uuid> \"msg\" Edit your message without losing history");
|
|
233
233
|
console.log(" sl session pins <id> --json List pinned messages with content (readable by agents)");
|
|
234
234
|
console.log(" sl session lock <id> <files...> --intent <why> Claim file locks (fail-closed, TTL auto-release)");
|
|
235
235
|
console.log(" sl session unlock <id> <files...> Release file locks you hold");
|