sentinelayer-cli 0.40.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 +22 -4
- package/package.json +4 -4
- 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 +1094 -279
- 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/governance.js +67 -0
- package/src/engram/index.js +73 -0
- package/src/engram/ingest-document.js +107 -0
- package/src/engram/namespace.js +89 -0
- package/src/engram/observations.js +34 -0
- package/src/engram/sla.js +104 -0
- package/src/engram/store.js +120 -0
- package/src/engram/tools.js +152 -0
- package/src/engram/trust.js +105 -0
- package/src/events/schema.js +18 -0
- package/src/legacy-cli.js +6 -3
- package/src/mcp/registry.js +27 -10
- package/src/mcp/session-stdio-server.js +209 -18
- 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/agent-registry.js +22 -63
- package/src/session/checkpoints.js +25 -1
- package/src/session/codebase-context.js +54 -0
- package/src/session/coordination-guidance.js +6 -6
- package/src/session/daemon-spawn.js +20 -2
- package/src/session/daemon.js +26 -125
- package/src/session/event-identity.js +9 -2
- package/src/session/file-lease-integrations.js +1300 -0
- package/src/session/file-locks.js +967 -464
- package/src/session/first-message.js +11 -3
- package/src/session/listener.js +246 -20
- package/src/session/listeners.js +71 -80
- package/src/session/live-source.js +47 -16
- package/src/session/message-edits.js +89 -0
- package/src/session/paths.js +2 -0
- package/src/session/recall/embedder.js +181 -0
- package/src/session/recall/entities.js +0 -0
- package/src/session/recall/index-build.js +38 -0
- package/src/session/recall/index-core.js +108 -0
- package/src/session/recall/index.js +131 -0
- package/src/session/recall/needle-eval.js +279 -0
- package/src/session/recall/observation-core.js +183 -0
- package/src/session/recall/observations.js +21 -0
- package/src/session/recall/provenance.js +153 -0
- package/src/session/recall/retrieve.js +374 -0
- package/src/session/recall/text.js +0 -0
- package/src/session/recall/token-stats.js +81 -0
- package/src/session/recap.js +54 -26
- package/src/session/remote-hydrate.js +12 -20
- package/src/session/store.js +13 -14
- package/src/session/stream.js +23 -3
- package/src/session/sync.js +789 -22
- 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/pump.js +8 -3
- 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
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENGRAM §2 — governance seams: signed recall receipts + per-call metering.
|
|
3
|
+
*
|
|
4
|
+
* Both are CLEAN INTERFACES with P0 stubs, because the real backing is not
|
|
5
|
+
* reachable in P0 (confirmed by the API scout):
|
|
6
|
+
* - Receipt signing = AIdenID, which is an EXTERNAL service
|
|
7
|
+
* (api.aidenid.com); this repo is only a client. §9 receipt shape is
|
|
8
|
+
* {caller, scope, ts, sig}. P0 emits an UNSIGNED receipt (sig=null) and
|
|
9
|
+
* marks stub:true — the seam is present so §2.1 drops in a real signer.
|
|
10
|
+
* - Metering = the server usage ledger is session-welded
|
|
11
|
+
* (SessionUsageLedgerEntry requires session_id + session_sequence_id).
|
|
12
|
+
* §2.1 generalizes it to a namespace/tenant ledger keyed on
|
|
13
|
+
* billing_account_id. P0 records a local stub entry.
|
|
14
|
+
*
|
|
15
|
+
* Never skip the seam — always emit (stub or real) so callers and tests can
|
|
16
|
+
* assert governance fired on every write/recall/summarize.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} [options]
|
|
21
|
+
* @param {{sign: (payload:object)=>Promise<string>|string}} [options.signer] Real receipt signer (§2.1 AIdenID).
|
|
22
|
+
* @param {{record: (entry:object)=>Promise<void>|void}} [options.meterSink] Real usage-ledger sink (§2.1).
|
|
23
|
+
* @param {()=>string} [options.now]
|
|
24
|
+
*/
|
|
25
|
+
export function createGovernance({ signer = null, meterSink = null, now = () => new Date().toISOString() } = {}) {
|
|
26
|
+
return {
|
|
27
|
+
/**
|
|
28
|
+
* Emit a signed recall receipt (§9 {caller, scope, ts, sig}).
|
|
29
|
+
* @returns {Promise<object>}
|
|
30
|
+
*/
|
|
31
|
+
async emitRecallReceipt({ caller, scope, namespace, query = "", resultCount = 0 } = {}) {
|
|
32
|
+
const base = {
|
|
33
|
+
caller: caller?.id || null,
|
|
34
|
+
scope,
|
|
35
|
+
namespace: namespace || scope,
|
|
36
|
+
query,
|
|
37
|
+
resultCount,
|
|
38
|
+
ts: now(),
|
|
39
|
+
};
|
|
40
|
+
if (signer && typeof signer.sign === "function") {
|
|
41
|
+
const sig = await signer.sign(base);
|
|
42
|
+
return { ...base, sig, stub: false };
|
|
43
|
+
}
|
|
44
|
+
return { ...base, sig: null, stub: true, seam: "aidenid-external-signer" };
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Meter one MaaS call.
|
|
49
|
+
* @returns {Promise<object>}
|
|
50
|
+
*/
|
|
51
|
+
async meter({ namespace, caller, action, count = 1, tokens = 0 } = {}) {
|
|
52
|
+
const entry = {
|
|
53
|
+
namespace,
|
|
54
|
+
caller: caller?.id || null,
|
|
55
|
+
action,
|
|
56
|
+
count,
|
|
57
|
+
tokens,
|
|
58
|
+
ts: now(),
|
|
59
|
+
};
|
|
60
|
+
if (meterSink && typeof meterSink.record === "function") {
|
|
61
|
+
await meterSink.record(entry);
|
|
62
|
+
return { ...entry, stub: false };
|
|
63
|
+
}
|
|
64
|
+
return { ...entry, stub: true, seam: "namespace-usage-ledger" };
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENGRAM §2 — Memory-as-a-Service: detachable public entry point.
|
|
3
|
+
*
|
|
4
|
+
* This barrel is the sellable-alone surface: the 3 tools + the store, trust,
|
|
5
|
+
* governance, and namespace seams, over the §1 recall engine. It imports NO
|
|
6
|
+
* SentinelLayer session/mcp/auth runtime — the SL session is just ONE injected
|
|
7
|
+
* store adapter (see the CLI wiring in src/mcp/session-stdio-server.js).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { createStore, itemId } from "./store.js";
|
|
11
|
+
export {
|
|
12
|
+
parseNamespace,
|
|
13
|
+
authorize,
|
|
14
|
+
createLocalConsent,
|
|
15
|
+
AccessDeniedError,
|
|
16
|
+
ACTION_SCOPES,
|
|
17
|
+
} from "./namespace.js";
|
|
18
|
+
export {
|
|
19
|
+
TRUST_LEVELS,
|
|
20
|
+
AUTHORITATIVE_FLOOR,
|
|
21
|
+
classifyWrite,
|
|
22
|
+
classifyObservation,
|
|
23
|
+
isAuthoritative,
|
|
24
|
+
trustRank,
|
|
25
|
+
sealItem,
|
|
26
|
+
visibleUnderScope,
|
|
27
|
+
} from "./trust.js";
|
|
28
|
+
export { createGovernance } from "./governance.js";
|
|
29
|
+
export { createMemoryTools } from "./tools.js";
|
|
30
|
+
export { runEngramSla } from "./sla.js";
|
|
31
|
+
export { ingestDocumentIntoNamespace } from "./ingest-document.js";
|
|
32
|
+
|
|
33
|
+
import { createStore } from "./store.js";
|
|
34
|
+
import { createLocalConsent } from "./namespace.js";
|
|
35
|
+
import { createGovernance } from "./governance.js";
|
|
36
|
+
import { createBuildState } from "./build-state.js";
|
|
37
|
+
import { ingestDocumentIntoNamespace } from "./ingest-document.js";
|
|
38
|
+
import { createMemoryTools } from "./tools.js";
|
|
39
|
+
import { createEmbedder } from "../session/recall/embedder.js";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Convenience factory: wire a default CLI-local MaaS instance (permissive-local
|
|
43
|
+
* consent, stub governance, hash-projection embedder) over a store. Callers
|
|
44
|
+
* inject `adapters` (e.g. { session }) to make a kind a first-class namespace.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} options
|
|
47
|
+
* @param {string} options.storeRoot
|
|
48
|
+
* @param {Record<string,Function>} [options.adapters]
|
|
49
|
+
* @param {object} [options.embedder]
|
|
50
|
+
* @param {object} [options.consent]
|
|
51
|
+
* @param {object} [options.governance]
|
|
52
|
+
* @param {object} [options.renderer]
|
|
53
|
+
* @returns {{tools: object, store: object}}
|
|
54
|
+
*/
|
|
55
|
+
export function createMemoryService({
|
|
56
|
+
storeRoot,
|
|
57
|
+
adapters = {},
|
|
58
|
+
embedder = createEmbedder(),
|
|
59
|
+
consent = createLocalConsent(),
|
|
60
|
+
governance = createGovernance(),
|
|
61
|
+
renderer = null,
|
|
62
|
+
isAuthorizedForSealed,
|
|
63
|
+
} = {}) {
|
|
64
|
+
const store = createStore({ storeRoot, adapters });
|
|
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 };
|
|
73
|
+
}
|