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.
Files changed (69) hide show
  1. package/README.md +22 -4
  2. package/package.json +4 -4
  3. package/src/auth/admission-scope.js +34 -0
  4. package/src/auth/gate.js +6 -0
  5. package/src/auth/http.js +4 -0
  6. package/src/auth/service.js +6 -0
  7. package/src/board/done-gate.js +155 -0
  8. package/src/board/evidence-resolvers.js +138 -0
  9. package/src/cli.js +24 -1
  10. package/src/commands/board.js +116 -0
  11. package/src/commands/session.js +1094 -279
  12. package/src/engram/build-state.js +183 -0
  13. package/src/engram/canonical.js +75 -0
  14. package/src/engram/document.js +190 -0
  15. package/src/engram/governance.js +67 -0
  16. package/src/engram/index.js +73 -0
  17. package/src/engram/ingest-document.js +107 -0
  18. package/src/engram/namespace.js +89 -0
  19. package/src/engram/observations.js +34 -0
  20. package/src/engram/sla.js +104 -0
  21. package/src/engram/store.js +120 -0
  22. package/src/engram/tools.js +152 -0
  23. package/src/engram/trust.js +105 -0
  24. package/src/events/schema.js +18 -0
  25. package/src/legacy-cli.js +6 -3
  26. package/src/mcp/registry.js +27 -10
  27. package/src/mcp/session-stdio-server.js +209 -18
  28. package/src/net/test-egress-guard.js +158 -0
  29. package/src/review/investor-dd-orchestrator.js +14 -2
  30. package/src/session/admission-auth.js +312 -0
  31. package/src/session/admission-preimage.js +90 -0
  32. package/src/session/admission.js +620 -0
  33. package/src/session/agent-registry.js +22 -63
  34. package/src/session/checkpoints.js +25 -1
  35. package/src/session/codebase-context.js +54 -0
  36. package/src/session/coordination-guidance.js +6 -6
  37. package/src/session/daemon-spawn.js +20 -2
  38. package/src/session/daemon.js +26 -125
  39. package/src/session/event-identity.js +9 -2
  40. package/src/session/file-lease-integrations.js +1300 -0
  41. package/src/session/file-locks.js +967 -464
  42. package/src/session/first-message.js +11 -3
  43. package/src/session/listener.js +246 -20
  44. package/src/session/listeners.js +71 -80
  45. package/src/session/live-source.js +47 -16
  46. package/src/session/message-edits.js +89 -0
  47. package/src/session/paths.js +2 -0
  48. package/src/session/recall/embedder.js +181 -0
  49. package/src/session/recall/entities.js +0 -0
  50. package/src/session/recall/index-build.js +38 -0
  51. package/src/session/recall/index-core.js +108 -0
  52. package/src/session/recall/index.js +131 -0
  53. package/src/session/recall/needle-eval.js +279 -0
  54. package/src/session/recall/observation-core.js +183 -0
  55. package/src/session/recall/observations.js +21 -0
  56. package/src/session/recall/provenance.js +153 -0
  57. package/src/session/recall/retrieve.js +374 -0
  58. package/src/session/recall/text.js +0 -0
  59. package/src/session/recall/token-stats.js +81 -0
  60. package/src/session/recap.js +54 -26
  61. package/src/session/remote-hydrate.js +12 -20
  62. package/src/session/store.js +13 -14
  63. package/src/session/stream.js +23 -3
  64. package/src/session/sync.js +789 -22
  65. package/src/session/tickets.js +316 -0
  66. package/src/session/title-sync.js +2 -1
  67. package/src/session/usage-report.js +41 -2
  68. package/src/session/wake/pump.js +8 -3
  69. package/src/session/wake/resolve-target.js +10 -5
@@ -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
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * ENGRAM §2 — namespaces (the tenancy boundary) + fail-closed authorization.
3
+ *
4
+ * A `scope` selects a NAMESPACE, formatted `<kind>:<id>` (e.g. `session:abc`,
5
+ * `project:web`, `org:acme`, `agent:codex`, or generic `ns:notes`). The kind
6
+ * prefix routes the store to a backend (session kind -> the SL session
7
+ * adapter; everything else -> the generic append-log). The engine itself
8
+ * knows NOTHING about kinds — that lives in the store adapter map — which is
9
+ * what makes the whole thing detachable/sellable.
10
+ *
11
+ * Authorization is FAIL-CLOSED: an action is denied unless the injected
12
+ * consent explicitly allows the caller's required scope. P0 ships a
13
+ * permissive-LOCAL consent (the local principal is trusted; guests/unknown
14
+ * are denied) behind the same seam the hosted AIdenID/UserAgentGrant path
15
+ * (§2.1) will implement.
16
+ */
17
+
18
+ export class AccessDeniedError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = "AccessDeniedError";
22
+ this.status = 403;
23
+ this.code = "access_denied";
24
+ }
25
+ }
26
+
27
+ /** Action -> required OAuth-style scope (aligns with the hosted scope set). */
28
+ export const ACTION_SCOPES = Object.freeze({
29
+ write: "memory:write",
30
+ read: "memory:read",
31
+ summarize: "memory:read",
32
+ });
33
+
34
+ const NAMESPACE_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
35
+ const NAMESPACE_KIND_RE = /^[a-z][a-z0-9_-]{0,31}$/;
36
+
37
+ /**
38
+ * Parse + validate a namespace scope.
39
+ * @param {string} scope
40
+ * @returns {{kind: string, id: string, raw: string}}
41
+ */
42
+ export function parseNamespace(scope) {
43
+ const raw = String(scope ?? "").trim();
44
+ if (!raw) throw new Error("namespace scope is required.");
45
+ const colon = raw.indexOf(":");
46
+ const kind = colon > 0 ? raw.slice(0, colon) : "ns";
47
+ const id = colon > 0 ? raw.slice(colon + 1) : raw;
48
+ if (!NAMESPACE_KIND_RE.test(kind)) throw new Error(`invalid namespace kind '${kind}'.`);
49
+ if (!NAMESPACE_ID_RE.test(id)) throw new Error("invalid namespace id.");
50
+ if (id.includes("..") || id.includes("/") || id.includes("\\")) {
51
+ throw new Error("namespace id must not contain path-traversal segments.");
52
+ }
53
+ return { kind, id, raw: `${kind}:${id}` };
54
+ }
55
+
56
+ /**
57
+ * Create a LOCAL consent policy (P0). Fail-closed by construction: a caller
58
+ * with no identity, or a guest (unless explicitly allowed), is denied.
59
+ * @param {object} [options]
60
+ * @param {boolean} [options.allowGuests=false]
61
+ * @returns {{allows: (caller:object, namespace:object, scope:string)=>boolean}}
62
+ */
63
+ export function createLocalConsent({ allowGuests = false } = {}) {
64
+ return {
65
+ allows(caller, _namespace, _scope) {
66
+ if (!caller || !caller.id) return false; // no identity -> deny
67
+ if (caller.kind === "guest" && !allowGuests) return false; // guests denied by default
68
+ return true;
69
+ },
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Fail-closed authorization. Throws AccessDeniedError (403) unless the
75
+ * consent explicitly allows the caller's required scope on the namespace.
76
+ * @returns {{ok: true, scope: string}}
77
+ */
78
+ export function authorize(caller, namespace, action, { consent } = {}) {
79
+ const scope = ACTION_SCOPES[action];
80
+ if (!scope) throw new Error(`unknown action '${action}'.`);
81
+ const allowed =
82
+ consent && typeof consent.allows === "function" ? Boolean(consent.allows(caller, namespace, scope)) : false;
83
+ if (!allowed) {
84
+ throw new AccessDeniedError(
85
+ `caller '${caller?.id || "anonymous"}' lacks '${scope}' on namespace '${namespace?.raw || namespace}'.`,
86
+ );
87
+ }
88
+ return { ok: true, scope };
89
+ }
@@ -0,0 +1,34 @@
1
+ /** Generic Memory-as-a-Service item adapter for the pure observation core. */
2
+
3
+ import { buildEventObservations } from "../session/recall/observation-core.js";
4
+ import { normalizeString } from "../session/recall/text.js";
5
+
6
+ export function buildObservationsFromItems(items = [], { namespace = "" } = {}) {
7
+ const events = (Array.isArray(items) ? items : []).map((item) => ({
8
+ stream: "sl_event",
9
+ event: normalizeString(item?.kind) || "memory",
10
+ eventId: normalizeString(item?.id) || undefined,
11
+ idempotencyToken: normalizeString(item?.idempotencyToken) || undefined,
12
+ agent: { id: normalizeString(item?.author || item?.agentId) || "writer" },
13
+ payload: {
14
+ message: normalizeString(item?.text),
15
+ topics: item?.topics,
16
+ files: item?.files,
17
+ to: item?.mentions,
18
+ __trust: item?.trust,
19
+ __sealed: item?.sealed === true,
20
+ __revoked: item?.revoked === true,
21
+ },
22
+ ts: normalizeString(item?.ts) || new Date().toISOString(),
23
+ sequenceId: item?.sequenceId,
24
+ }));
25
+
26
+ const built = buildEventObservations(events, { sessionId: namespace });
27
+ for (const observation of built.observations) {
28
+ const payload = observation?.raw?.payload || {};
29
+ if (payload.__trust) observation.trust = payload.__trust;
30
+ observation.sealed = payload.__sealed === true;
31
+ observation.revoked = payload.__revoked === true;
32
+ }
33
+ return built;
34
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * ENGRAM §2 — the Memory-as-a-Service quality SLA (namespace-agnostic).
3
+ *
4
+ * The §1 8-Needle harness (ENGRAM §11) becomes the MaaS SLA, run OVER A NAMESPACE
5
+ * through the shared store: corpora are `memory.write`-shaped items written to
6
+ * a namespace, then recalled through the same store the tools use. This proves
7
+ * the SLA holds for ANY namespace (not just sessions), which is the
8
+ * detachability guarantee.
9
+ *
10
+ * Reuses the §1 corpus builders + evaluators verbatim (no reimplementation).
11
+ */
12
+
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { mkdtemp, rm } from "node:fs/promises";
16
+
17
+ import {
18
+ buildChainCorpus,
19
+ buildScatterCorpus,
20
+ evaluateChain,
21
+ evaluateScatter,
22
+ evaluateRecallAt10,
23
+ } from "../session/recall/needle-eval.js";
24
+ import { buildRecallIndex } from "../session/recall/index-core.js";
25
+ import { createEmbedder } from "../session/recall/embedder.js";
26
+ import { createStore } from "./store.js";
27
+ import { parseNamespace } from "./namespace.js";
28
+
29
+ function eventToItem(event) {
30
+ return {
31
+ id: event.eventId,
32
+ text: event?.payload?.message || "",
33
+ kind: event.event,
34
+ author: event?.agent?.id,
35
+ ts: event.ts,
36
+ topics: event?.payload?.topics,
37
+ sequenceId: event.sequenceId,
38
+ };
39
+ }
40
+
41
+ function latestTsMs(events) {
42
+ let max = 0;
43
+ for (const e of events) {
44
+ const ms = Date.parse(e.ts);
45
+ if (Number.isFinite(ms) && ms > max) max = ms;
46
+ }
47
+ return max;
48
+ }
49
+
50
+ /**
51
+ * Run the 8-Needle SLA over the namespace store.
52
+ * @param {object} [options]
53
+ * @returns {Promise<object>} metrics + pass/fail per gate
54
+ */
55
+ export async function runEngramSla({
56
+ chains = 120,
57
+ chainLen = 8,
58
+ scatterQueries = 50,
59
+ relevants = 8,
60
+ seed = 1,
61
+ embedder = createEmbedder(),
62
+ storeRoot = null,
63
+ gates = { chain: 0.95, scatter: 0.95, recallAt10: 0.95 },
64
+ } = {}) {
65
+ const root = storeRoot || (await mkdtemp(path.join(os.tmpdir(), "engram-sla-")));
66
+ const cleanup = !storeRoot;
67
+ try {
68
+ const store = createStore({ storeRoot: root, adapters: {} });
69
+ const chain = buildChainCorpus({ chains, chainLen, seed });
70
+ const scatter = buildScatterCorpus({ queries: scatterQueries, relevants, seed: seed + 100 });
71
+
72
+ const chainNs = parseNamespace("ns:sla-chain");
73
+ const scatterNs = parseNamespace("ns:sla-scatter");
74
+ await store.appendItems(chainNs, chain.events.map(eventToItem));
75
+ await store.appendItems(scatterNs, scatter.events.map(eventToItem));
76
+
77
+ const chainObs = await store.readObservations(chainNs);
78
+ const scatterObs = await store.readObservations(scatterNs);
79
+ const chainIndex = buildRecallIndex({ observations: chainObs, embedder, sessionId: chainNs.raw });
80
+ const scatterIndex = buildRecallIndex({ observations: scatterObs, embedder, sessionId: scatterNs.raw });
81
+
82
+ // Deterministic ACT-R clock, strictly after every corpus timestamp.
83
+ const nowMs = Math.max(latestTsMs(chain.events), latestTsMs(scatter.events)) + 10 * 60_000;
84
+
85
+ const chainResult = evaluateChain(chainIndex, chain.chainQueries, { k: 20, now: nowMs });
86
+ const scatterResult = evaluateScatter(scatterIndex, scatter.scatterQueries, { now: nowMs });
87
+ const recallAt10Result = evaluateRecallAt10(scatterIndex, scatter.scatterQueries, { now: nowMs });
88
+
89
+ const pass =
90
+ chainResult.rate >= gates.chain &&
91
+ scatterResult.rate >= gates.scatter &&
92
+ recallAt10Result.rate >= gates.recallAt10;
93
+
94
+ return {
95
+ config: { chains, chainLen, scatterQueries, relevants, seed, embedder: embedder.name, path: "namespace-store" },
96
+ needleChain: { rate: chainResult.rate, completed: chainResult.completed, total: chainResult.total, gate: gates.chain, pass: chainResult.rate >= gates.chain },
97
+ needleScatter: { rate: scatterResult.rate, total: scatterResult.total, gate: gates.scatter, pass: scatterResult.rate >= gates.scatter },
98
+ recallAt10: { rate: recallAt10Result.rate, total: recallAt10Result.total, gate: gates.recallAt10, pass: recallAt10Result.rate >= gates.recallAt10 },
99
+ pass,
100
+ };
101
+ } finally {
102
+ if (cleanup) await rm(root, { recursive: true, force: true }).catch(() => {});
103
+ }
104
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * ENGRAM §2 — the ONE shared namespace store.
3
+ *
4
+ * This is the single substrate the 3 tools, the 8-Needle SLA, and the
5
+ * token-cut measurement all read (relay ruling #5: no second store). A
6
+ * namespace's observations are the UNION of:
7
+ * - a read-only SOURCE ADAPTER for its kind, if one is registered
8
+ * (e.g. kind `session` -> the SL session adapter that maps session
9
+ * events to observations). Adapters are INJECTED, so this module imports
10
+ * NO session runtime and stays detachable.
11
+ * - a generic append-log the tools write to (`memory.write`), one NDJSON
12
+ * file per namespace, with content-hash dedup (ENGRAM §3 — idempotent).
13
+ *
14
+ * Detachability: imports ONLY the §1 engine core (observations + text) and
15
+ * node builtins. No SentinelLayer session/mcp/auth imports.
16
+ */
17
+
18
+ import fsp from "node:fs/promises";
19
+ import path from "node:path";
20
+
21
+ import { buildObservationsFromItems } from "./observations.js";
22
+ import { contentHash, normalizeString } from "../session/recall/text.js";
23
+
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) {
30
+ const safe = namespace.raw.replace(/[^A-Za-z0-9._-]/g, "_");
31
+ return path.join(root, ".sentinelayer", "engram", safe);
32
+ }
33
+
34
+ /** Stable, content-addressed item id (idempotent write — §3 dedup for free). */
35
+ export function itemId(item = {}) {
36
+ const explicit = normalizeString(item.id) || normalizeString(item.idempotencyToken);
37
+ if (explicit) return explicit;
38
+ const basis = [item.kind, item.author || item.agentId, item.text, item.ts]
39
+ .map((value) => normalizeString(value))
40
+ .join("|");
41
+ return `h:${contentHash(basis)}`;
42
+ }
43
+
44
+ async function readItemsFile(file) {
45
+ try {
46
+ const raw = await fsp.readFile(file, "utf-8");
47
+ const items = [];
48
+ for (const line of raw.split(/\r?\n/)) {
49
+ if (!line.trim()) continue;
50
+ try {
51
+ items.push(JSON.parse(line));
52
+ } catch {
53
+ // ignore malformed lines
54
+ }
55
+ }
56
+ return items;
57
+ } catch (error) {
58
+ if (error && error.code === "ENOENT") return [];
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Create the namespace store.
65
+ * @param {object} options
66
+ * @param {string} options.storeRoot Root dir for the append-log store.
67
+ * @param {Record<string, (id:string)=>Promise<object[]>>} [options.adapters]
68
+ * Map of namespace-kind -> async source adapter returning observations.
69
+ * @returns {{
70
+ * appendItems: (namespace:object, items:object[]) => Promise<{written:number, deduped:number, namespace:string}>,
71
+ * readObservations: (namespace:object) => Promise<object[]>,
72
+ * }}
73
+ */
74
+ export function createStore({ storeRoot, adapters = {} } = {}) {
75
+ const root = path.resolve(String(storeRoot || "."));
76
+
77
+ return {
78
+ async appendItems(namespace, items = []) {
79
+ const dir = namespaceDir(root, namespace);
80
+ const file = path.join(dir, "items.ndjson");
81
+ const existing = await readItemsFile(file);
82
+ const seen = new Set(existing.map((it) => itemId(it)));
83
+ const lines = [];
84
+ let written = 0;
85
+ let deduped = 0;
86
+ for (const item of Array.isArray(items) ? items : []) {
87
+ const id = itemId(item);
88
+ if (seen.has(id)) {
89
+ deduped += 1;
90
+ continue;
91
+ }
92
+ seen.add(id);
93
+ lines.push(JSON.stringify({ ...item, id }));
94
+ written += 1;
95
+ }
96
+ if (lines.length > 0) {
97
+ await fsp.mkdir(dir, { recursive: true });
98
+ await fsp.appendFile(file, `${lines.join("\n")}\n`, "utf-8");
99
+ }
100
+ return { written, deduped, namespace: namespace.raw };
101
+ },
102
+
103
+ async readObservations(namespace) {
104
+ const adapter = adapters[namespace.kind];
105
+ const adapterObservations = adapter
106
+ ? await Promise.resolve(adapter(namespace.id)).catch(() => [])
107
+ : [];
108
+ const file = path.join(namespaceDir(root, namespace), "items.ndjson");
109
+ const items = await readItemsFile(file);
110
+ const itemObservations = buildObservationsFromItems(items, { namespace: namespace.raw }).observations;
111
+
112
+ // Union, dedup by id (first wins — adapter source is authoritative for its ids).
113
+ const byId = new Map();
114
+ for (const obs of [...(Array.isArray(adapterObservations) ? adapterObservations : []), ...itemObservations]) {
115
+ if (obs && !byId.has(obs.id)) byId.set(obs.id, obs);
116
+ }
117
+ return Array.from(byId.values());
118
+ },
119
+ };
120
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * ENGRAM §2 — Memory-as-a-Service: the 3 MCP tools, EXACTLY.
3
+ *
4
+ * memory.write({ scope, items }) — idempotent (content-hash dedup),
5
+ * trust-sealed, late-binding.
6
+ * memory.recall({ scope, query, k }) — REUSES the §1 engine verbatim
7
+ * (dense int8 exact-scan + BM25 + RRF
8
+ * + bounded diffusion + ACT-R) ->
9
+ * ranked memories + provenance path.
10
+ * memory.summarize({ scope, focus }) — retrieve-then-generate: recall
11
+ * DETERMINISTICALLY selects the subset;
12
+ * the injected renderer (Gemma) only
13
+ * GENERATES prose. Never a model in
14
+ * selection.
15
+ *
16
+ * This layer is a namespace + governance wrapper. It imports ONLY the §1
17
+ * engine core + the sibling engram seams — no session/mcp/auth runtime — so
18
+ * the product is detachable/sellable on its own.
19
+ */
20
+
21
+ import { buildRecallIndex } from "../session/recall/index-core.js";
22
+ import { recall as recallEngine } from "../session/recall/retrieve.js";
23
+ import { createEmbedder } from "../session/recall/embedder.js";
24
+ import { normalizeString } from "../session/recall/text.js";
25
+
26
+ import { parseNamespace, authorize } from "./namespace.js";
27
+ import { sealItem, visibleUnderScope, isAuthoritative, classifyObservation } from "./trust.js";
28
+
29
+ function normalizeItem(raw) {
30
+ if (typeof raw === "string") return { text: raw };
31
+ if (!raw || typeof raw !== "object") return { text: "" };
32
+ return { ...raw, text: normalizeString(raw.text || raw.message || raw.content) };
33
+ }
34
+
35
+ /** Deterministic, non-model summary used when no renderer is injected. */
36
+ function deterministicDigest(focus, memories) {
37
+ const lines = memories.map((m, i) => {
38
+ const seq = m.sequenceId ? `#${m.sequenceId} ` : "";
39
+ return `${i + 1}. ${seq}${m.snippet || ""} [why: ${m.provenance}]`;
40
+ });
41
+ return [`Focus: ${focus}`, `Grounded in ${memories.length} recalled memories:`, ...lines].join("\n");
42
+ }
43
+
44
+ /**
45
+ * Build the 3 MaaS tools over an injected store + governance + consent.
46
+ * @param {object} deps
47
+ * @param {object} deps.store createStore(...)
48
+ * @param {object} deps.consent { allows(caller, namespace, scope) }
49
+ * @param {object} deps.governance createGovernance(...)
50
+ * @param {object} [deps.embedder]
51
+ * @param {{render: (ctx:object)=>Promise<string>|string}} [deps.renderer] Gemma prose generator.
52
+ * @param {(caller:object, namespace:object)=>boolean} [deps.isAuthorizedForSealed]
53
+ * @param {()=>number} [deps.now]
54
+ */
55
+ export function createMemoryTools({
56
+ store,
57
+ consent,
58
+ governance,
59
+ buildState = null,
60
+ embedder = createEmbedder(),
61
+ renderer = null,
62
+ isAuthorizedForSealed = () => false,
63
+ now = () => Date.now(),
64
+ } = {}) {
65
+ async function write({ scope, items = [], caller } = {}) {
66
+ const namespace = parseNamespace(scope);
67
+ authorize(caller, namespace, "write", { consent }); // fail-closed 403
68
+ const sealed = (Array.isArray(items) ? items : []).map((it) => {
69
+ const item = normalizeItem(it);
70
+ if (!item.author && !item.agentId) item.author = caller?.id;
71
+ return sealItem(item, { caller });
72
+ });
73
+ const result = await store.appendItems(namespace, sealed);
74
+ const meter = await governance.meter({ namespace: namespace.raw, caller, action: "write", count: result.written });
75
+ return { ok: true, namespace: namespace.raw, written: result.written, deduped: result.deduped, meter };
76
+ }
77
+
78
+ // Shared retrieval core: authorize (fail-closed) + read + trust-filter +
79
+ // §1 engine recall + receipt. Metered by the CALLER tool exactly once, so
80
+ // summarize (which retrieves internally) is billed as one summarize, not
81
+ // recall+summarize.
82
+ async function retrieveInternal({ scope, query, k, role, caller }) {
83
+ const namespace = parseNamespace(scope);
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);
90
+ const authorizedForSealed = Boolean(isAuthorizedForSealed(caller, namespace));
91
+
92
+ const all = await store.readObservations(namespace);
93
+ const visible = [];
94
+ for (const obs of all) {
95
+ if (!obs.trust) obs.trust = classifyObservation(obs);
96
+ if (visibleUnderScope(obs, { authorized: authorizedForSealed })) visible.push(obs);
97
+ }
98
+
99
+ const index = buildRecallIndex({ observations: visible, embedder, sessionId: namespace.raw });
100
+ const engineResult = recallEngine(index, { query: normalizeString(query), k, role, now: now() });
101
+ const results = engineResult.results.map((r) => {
102
+ const obs = index.byId.get(r.observationId);
103
+ const trust = obs?.trust || null;
104
+ return { ...r, trust, authoritative: isAuthoritative(trust) };
105
+ });
106
+ const receipt = await governance.emitRecallReceipt({
107
+ caller,
108
+ scope: namespace.raw,
109
+ namespace: namespace.raw,
110
+ query: normalizeString(query),
111
+ resultCount: results.length,
112
+ });
113
+ return { namespace, engineResult, results, receipt };
114
+ }
115
+
116
+ async function recall({ scope, query, k = 12, role = "", caller } = {}) {
117
+ const { namespace, engineResult, results, receipt } = await retrieveInternal({ scope, query, k, role, caller });
118
+ const meter = await governance.meter({ namespace: namespace.raw, caller, action: "recall", count: results.length });
119
+ return {
120
+ ok: true,
121
+ namespace: namespace.raw,
122
+ query: engineResult.query,
123
+ k: engineResult.k,
124
+ poolSize: engineResult.poolSize,
125
+ results,
126
+ receipt,
127
+ meter,
128
+ };
129
+ }
130
+
131
+ async function summarize({ scope, focus, k = 12, caller } = {}) {
132
+ // Deterministic SELECT via the shared retrieval; renderer only GENERATES.
133
+ const { namespace, results, receipt } = await retrieveInternal({ scope, query: focus, k, role: "", caller });
134
+ const summary =
135
+ renderer && typeof renderer.render === "function"
136
+ ? await renderer.render({ focus, memories: results })
137
+ : deterministicDigest(normalizeString(focus), results);
138
+ const meter = await governance.meter({ namespace: namespace.raw, caller, action: "summarize", count: results.length });
139
+ return {
140
+ ok: true,
141
+ namespace: namespace.raw,
142
+ focus: normalizeString(focus),
143
+ summary,
144
+ generated: Boolean(renderer && typeof renderer.render === "function"),
145
+ groundedIn: results.map((s) => ({ observationId: s.observationId, provenance: s.provenance })),
146
+ receipt,
147
+ meter,
148
+ };
149
+ }
150
+
151
+ return { write, recall, summarize };
152
+ }