sentinelayer-cli 0.39.2 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +4 -4
  2. package/package.json +2 -3
  3. package/src/agents/ai-governance/tools/base.js +19 -166
  4. package/src/agents/backend/tools/base.js +33 -186
  5. package/src/agents/code-quality/tools/base.js +27 -154
  6. package/src/agents/data-layer/tools/base.js +35 -180
  7. package/src/agents/documentation/tools/base.js +18 -146
  8. package/src/agents/infrastructure/tools/base.js +17 -164
  9. package/src/agents/observability/tools/base.js +35 -180
  10. package/src/agents/performance/tools/base.js +35 -205
  11. package/src/agents/release/tools/base.js +35 -180
  12. package/src/agents/reliability/tools/base.js +35 -180
  13. package/src/agents/security/tools/base.js +33 -187
  14. package/src/agents/shared-tools/domain-base.js +246 -0
  15. package/src/agents/supply-chain/tools/base.js +15 -144
  16. package/src/agents/testing/tools/base.js +33 -184
  17. package/src/cli.js +5 -0
  18. package/src/commands/mcp.js +1 -1
  19. package/src/commands/ring-owner.js +58 -0
  20. package/src/commands/session.js +703 -263
  21. package/src/engram/governance.js +67 -0
  22. package/src/engram/index.js +64 -0
  23. package/src/engram/namespace.js +89 -0
  24. package/src/engram/observations.js +34 -0
  25. package/src/engram/sla.js +104 -0
  26. package/src/engram/store.js +115 -0
  27. package/src/engram/tools.js +146 -0
  28. package/src/engram/trust.js +105 -0
  29. package/src/legacy-cli.js +6 -3
  30. package/src/mcp/registry.js +27 -10
  31. package/src/mcp/session-stdio-server.js +209 -18
  32. package/src/mcp/smoke.js +9 -8
  33. package/src/pocket/ring-owner.js +97 -0
  34. package/src/review/local-review.js +9 -5
  35. package/src/review/omargate-orchestrator.js +111 -14
  36. package/src/review/spec-binding.js +16 -1
  37. package/src/scan/generator.js +1 -1
  38. package/src/session/agent-registry.js +22 -63
  39. package/src/session/checkpoints.js +194 -0
  40. package/src/session/coordination-guidance.js +6 -6
  41. package/src/session/daemon.js +19 -124
  42. package/src/session/file-lease-integrations.js +1300 -0
  43. package/src/session/file-locks.js +967 -464
  44. package/src/session/listener.js +243 -20
  45. package/src/session/listeners.js +71 -80
  46. package/src/session/paths.js +2 -0
  47. package/src/session/recall/embedder.js +181 -0
  48. package/src/session/recall/entities.js +0 -0
  49. package/src/session/recall/index-build.js +38 -0
  50. package/src/session/recall/index-core.js +108 -0
  51. package/src/session/recall/index.js +131 -0
  52. package/src/session/recall/needle-eval.js +279 -0
  53. package/src/session/recall/observation-core.js +183 -0
  54. package/src/session/recall/observations.js +21 -0
  55. package/src/session/recall/provenance.js +153 -0
  56. package/src/session/recall/retrieve.js +374 -0
  57. package/src/session/recall/text.js +0 -0
  58. package/src/session/recall/token-stats.js +81 -0
  59. package/src/session/recap.js +29 -23
  60. package/src/session/remote-hydrate.js +5 -1
  61. package/src/session/sync.js +661 -11
  62. package/src/session/wake/pump.js +8 -3
@@ -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,64 @@
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
+
32
+ import { createStore } from "./store.js";
33
+ import { createLocalConsent } from "./namespace.js";
34
+ import { createGovernance } from "./governance.js";
35
+ import { createMemoryTools } from "./tools.js";
36
+ import { createEmbedder } from "../session/recall/embedder.js";
37
+
38
+ /**
39
+ * Convenience factory: wire a default CLI-local MaaS instance (permissive-local
40
+ * consent, stub governance, hash-projection embedder) over a store. Callers
41
+ * inject `adapters` (e.g. { session }) to make a kind a first-class namespace.
42
+ *
43
+ * @param {object} options
44
+ * @param {string} options.storeRoot
45
+ * @param {Record<string,Function>} [options.adapters]
46
+ * @param {object} [options.embedder]
47
+ * @param {object} [options.consent]
48
+ * @param {object} [options.governance]
49
+ * @param {object} [options.renderer]
50
+ * @returns {{tools: object, store: object}}
51
+ */
52
+ export function createMemoryService({
53
+ storeRoot,
54
+ adapters = {},
55
+ embedder = createEmbedder(),
56
+ consent = createLocalConsent(),
57
+ governance = createGovernance(),
58
+ renderer = null,
59
+ isAuthorizedForSealed,
60
+ } = {}) {
61
+ const store = createStore({ storeRoot, adapters });
62
+ const tools = createMemoryTools({ store, consent, governance, embedder, renderer, isAuthorizedForSealed });
63
+ return { tools, store };
64
+ }
@@ -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,115 @@
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
+ function namespaceDir(root, namespace) {
25
+ const safe = namespace.raw.replace(/[^A-Za-z0-9._-]/g, "_");
26
+ return path.join(root, ".sentinelayer", "engram", safe);
27
+ }
28
+
29
+ /** Stable, content-addressed item id (idempotent write — §3 dedup for free). */
30
+ export function itemId(item = {}) {
31
+ const explicit = normalizeString(item.id) || normalizeString(item.idempotencyToken);
32
+ if (explicit) return explicit;
33
+ const basis = [item.kind, item.author || item.agentId, item.text, item.ts]
34
+ .map((value) => normalizeString(value))
35
+ .join("|");
36
+ return `h:${contentHash(basis)}`;
37
+ }
38
+
39
+ async function readItemsFile(file) {
40
+ try {
41
+ const raw = await fsp.readFile(file, "utf-8");
42
+ const items = [];
43
+ for (const line of raw.split(/\r?\n/)) {
44
+ if (!line.trim()) continue;
45
+ try {
46
+ items.push(JSON.parse(line));
47
+ } catch {
48
+ // ignore malformed lines
49
+ }
50
+ }
51
+ return items;
52
+ } catch (error) {
53
+ if (error && error.code === "ENOENT") return [];
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Create the namespace store.
60
+ * @param {object} options
61
+ * @param {string} options.storeRoot Root dir for the append-log store.
62
+ * @param {Record<string, (id:string)=>Promise<object[]>>} [options.adapters]
63
+ * Map of namespace-kind -> async source adapter returning observations.
64
+ * @returns {{
65
+ * appendItems: (namespace:object, items:object[]) => Promise<{written:number, deduped:number, namespace:string}>,
66
+ * readObservations: (namespace:object) => Promise<object[]>,
67
+ * }}
68
+ */
69
+ export function createStore({ storeRoot, adapters = {} } = {}) {
70
+ const root = path.resolve(String(storeRoot || "."));
71
+
72
+ return {
73
+ async appendItems(namespace, items = []) {
74
+ const dir = namespaceDir(root, namespace);
75
+ const file = path.join(dir, "items.ndjson");
76
+ const existing = await readItemsFile(file);
77
+ const seen = new Set(existing.map((it) => itemId(it)));
78
+ const lines = [];
79
+ let written = 0;
80
+ let deduped = 0;
81
+ for (const item of Array.isArray(items) ? items : []) {
82
+ const id = itemId(item);
83
+ if (seen.has(id)) {
84
+ deduped += 1;
85
+ continue;
86
+ }
87
+ seen.add(id);
88
+ lines.push(JSON.stringify({ ...item, id }));
89
+ written += 1;
90
+ }
91
+ if (lines.length > 0) {
92
+ await fsp.mkdir(dir, { recursive: true });
93
+ await fsp.appendFile(file, `${lines.join("\n")}\n`, "utf-8");
94
+ }
95
+ return { written, deduped, namespace: namespace.raw };
96
+ },
97
+
98
+ async readObservations(namespace) {
99
+ const adapter = adapters[namespace.kind];
100
+ const adapterObservations = adapter
101
+ ? await Promise.resolve(adapter(namespace.id)).catch(() => [])
102
+ : [];
103
+ const file = path.join(namespaceDir(root, namespace), "items.ndjson");
104
+ const items = await readItemsFile(file);
105
+ const itemObservations = buildObservationsFromItems(items, { namespace: namespace.raw }).observations;
106
+
107
+ // Union, dedup by id (first wins — adapter source is authoritative for its ids).
108
+ const byId = new Map();
109
+ for (const obs of [...(Array.isArray(adapterObservations) ? adapterObservations : []), ...itemObservations]) {
110
+ if (obs && !byId.has(obs.id)) byId.set(obs.id, obs);
111
+ }
112
+ return Array.from(byId.values());
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,146 @@
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
+ embedder = createEmbedder(),
60
+ renderer = null,
61
+ isAuthorizedForSealed = () => false,
62
+ now = () => Date.now(),
63
+ } = {}) {
64
+ async function write({ scope, items = [], caller } = {}) {
65
+ const namespace = parseNamespace(scope);
66
+ authorize(caller, namespace, "write", { consent }); // fail-closed 403
67
+ const sealed = (Array.isArray(items) ? items : []).map((it) => {
68
+ const item = normalizeItem(it);
69
+ if (!item.author && !item.agentId) item.author = caller?.id;
70
+ return sealItem(item, { caller });
71
+ });
72
+ const result = await store.appendItems(namespace, sealed);
73
+ const meter = await governance.meter({ namespace: namespace.raw, caller, action: "write", count: result.written });
74
+ return { ok: true, namespace: namespace.raw, written: result.written, deduped: result.deduped, meter };
75
+ }
76
+
77
+ // Shared retrieval core: authorize (fail-closed) + read + trust-filter +
78
+ // §1 engine recall + receipt. Metered by the CALLER tool exactly once, so
79
+ // summarize (which retrieves internally) is billed as one summarize, not
80
+ // recall+summarize.
81
+ async function retrieveInternal({ scope, query, k, role, caller }) {
82
+ const namespace = parseNamespace(scope);
83
+ authorize(caller, namespace, "read", { consent }); // fail-closed 403
84
+ const authorizedForSealed = Boolean(isAuthorizedForSealed(caller, namespace));
85
+
86
+ const all = await store.readObservations(namespace);
87
+ const visible = [];
88
+ for (const obs of all) {
89
+ if (!obs.trust) obs.trust = classifyObservation(obs);
90
+ if (visibleUnderScope(obs, { authorized: authorizedForSealed })) visible.push(obs);
91
+ }
92
+
93
+ const index = buildRecallIndex({ observations: visible, embedder, sessionId: namespace.raw });
94
+ const engineResult = recallEngine(index, { query: normalizeString(query), k, role, now: now() });
95
+ const results = engineResult.results.map((r) => {
96
+ const obs = index.byId.get(r.observationId);
97
+ const trust = obs?.trust || null;
98
+ return { ...r, trust, authoritative: isAuthoritative(trust) };
99
+ });
100
+ const receipt = await governance.emitRecallReceipt({
101
+ caller,
102
+ scope: namespace.raw,
103
+ namespace: namespace.raw,
104
+ query: normalizeString(query),
105
+ resultCount: results.length,
106
+ });
107
+ return { namespace, engineResult, results, receipt };
108
+ }
109
+
110
+ async function recall({ scope, query, k = 12, role = "", caller } = {}) {
111
+ const { namespace, engineResult, results, receipt } = await retrieveInternal({ scope, query, k, role, caller });
112
+ const meter = await governance.meter({ namespace: namespace.raw, caller, action: "recall", count: results.length });
113
+ return {
114
+ ok: true,
115
+ namespace: namespace.raw,
116
+ query: engineResult.query,
117
+ k: engineResult.k,
118
+ poolSize: engineResult.poolSize,
119
+ results,
120
+ receipt,
121
+ meter,
122
+ };
123
+ }
124
+
125
+ async function summarize({ scope, focus, k = 12, caller } = {}) {
126
+ // Deterministic SELECT via the shared retrieval; renderer only GENERATES.
127
+ const { namespace, results, receipt } = await retrieveInternal({ scope, query: focus, k, role: "", caller });
128
+ const summary =
129
+ renderer && typeof renderer.render === "function"
130
+ ? await renderer.render({ focus, memories: results })
131
+ : deterministicDigest(normalizeString(focus), results);
132
+ const meter = await governance.meter({ namespace: namespace.raw, caller, action: "summarize", count: results.length });
133
+ return {
134
+ ok: true,
135
+ namespace: namespace.raw,
136
+ focus: normalizeString(focus),
137
+ summary,
138
+ generated: Boolean(renderer && typeof renderer.render === "function"),
139
+ groundedIn: results.map((s) => ({ observationId: s.observationId, provenance: s.provenance })),
140
+ receipt,
141
+ meter,
142
+ };
143
+ }
144
+
145
+ return { write, recall, summarize };
146
+ }