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,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
|
+
}
|