sentinelayer-cli 0.46.4 → 0.48.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 +63 -3
- package/package.json +12 -7
- package/skills/omar-gate-setup/SKILL.md +103 -0
- package/skills/senti-agent-identity/SKILL.md +80 -0
- package/skills/senti-judge/SKILL.md +139 -0
- package/skills/senti-room-notifications/SKILL.md +73 -0
- package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
- package/skills/senti-room-notifications/package.json +1 -0
- package/skills/senti-room-notifications/test-classifier.js +109 -0
- package/skills/senti-room-notifications/test-receipt.js +54 -0
- package/skills/sentinelayer-cli/SKILL.md +222 -0
- package/src/agents/devtestbot/runner.js +22 -11
- package/src/agents/devtestbot/tool.js +24 -11
- package/src/agents/jules/fix-cycle.js +7 -1
- package/src/agents/jules/tools/runtime-audit.js +17 -12
- package/src/agents/shared-tools/dispatch-core.js +10 -5
- package/src/ai/aidenid.js +4 -2
- package/src/ai/domain-target-store.js +11 -5
- package/src/ai/identity-store.js +11 -5
- package/src/ai/site-store.js +8 -4
- package/src/audit/orchestrator.js +21 -14
- package/src/audit/package.js +6 -4
- package/src/audit/persona-loop.js +7 -0
- package/src/audit/replay.js +4 -2
- package/src/auth/gate.js +46 -8
- package/src/auth/service.js +34 -15
- package/src/auth/session-store.js +105 -47
- package/src/board/evidence-resolvers.js +3 -4
- package/src/cli.js +23 -3
- package/src/commands/ai/identity-lifecycle.js +30 -26
- package/src/commands/ai/provision-governance.js +32 -24
- package/src/commands/ai/shared.js +6 -4
- package/src/commands/audit.js +14 -12
- package/src/commands/auth.js +24 -4
- package/src/commands/board.js +2 -2
- package/src/commands/chat.js +8 -6
- package/src/commands/judge.js +125 -0
- package/src/commands/legacy-args.js +1 -0
- package/src/commands/mcp.js +45 -52
- package/src/commands/omargate.js +2 -1
- package/src/commands/plugin.js +11 -3
- package/src/commands/review.js +9 -4
- package/src/commands/scan.js +7 -7
- package/src/commands/session.js +607 -57
- package/src/commands/skills.js +45 -0
- package/src/commands/watch.js +9 -7
- package/src/config/output-filesystem.js +156 -0
- package/src/config/schema.js +4 -2
- package/src/config/service.js +40 -4
- package/src/cost/history.js +15 -9
- package/src/daemon/artifact-lineage.js +15 -12
- package/src/daemon/assignment-ledger.js +31 -25
- package/src/daemon/ast-drift.js +7 -3
- package/src/daemon/budget-governor.js +26 -20
- package/src/daemon/error-worker.js +28 -22
- package/src/daemon/fix-cycle.js +5 -4
- package/src/daemon/hybrid-mapper.js +19 -16
- package/src/daemon/ingest-refresh.js +4 -0
- package/src/daemon/jira-lifecycle.js +21 -17
- package/src/daemon/operator-control.js +18 -15
- package/src/daemon/reliability-lane.js +18 -15
- package/src/daemon/scope-engine.js +12 -8
- package/src/daemon/watchdog.js +15 -12
- package/src/engram/build-state.js +24 -3
- package/src/engram/capability-index.js +367 -0
- package/src/engram/compile-graph.js +1063 -0
- package/src/engram/contracts/action-receipt.js +82 -0
- package/src/engram/contracts/capability-index.js +259 -0
- package/src/engram/contracts/capability.js +377 -0
- package/src/engram/contracts/content-hash.js +8 -0
- package/src/engram/contracts/evidence.js +258 -0
- package/src/engram/contracts/graph.js +594 -0
- package/src/engram/contracts/identity.js +8 -0
- package/src/engram/contracts/index.js +149 -0
- package/src/engram/contracts/media-asset.js +85 -0
- package/src/engram/contracts/money.js +249 -0
- package/src/engram/contracts/source-snapshot.js +57 -0
- package/src/engram/contracts/task-context-pack.js +114 -0
- package/src/engram/contracts/temporal-segment.js +72 -0
- package/src/engram/contracts/versions.js +96 -0
- package/src/engram/extract-html.js +692 -0
- package/src/engram/graph-store.js +877 -0
- package/src/engram/index.js +98 -14
- package/src/engram/ingest-site.js +132 -0
- package/src/engram/live-state.js +1180 -0
- package/src/engram/namespace.js +34 -1
- package/src/engram/retrieve-site.js +753 -0
- package/src/engram/safe-reader.js +597 -0
- package/src/engram/source-intake.js +251 -0
- package/src/engram/store.js +227 -9
- package/src/ingest/engine.js +21 -28
- package/src/judge/verdict.js +305 -0
- package/src/legacy-cli.js +96 -19
- package/src/mcp/bridge-inputs.js +28 -4
- package/src/mcp/cli-command-tools.js +70 -0
- package/src/mcp/registry.js +17 -13
- package/src/mcp/session-stdio-server.js +297 -19
- package/src/memory/blackboard.js +5 -4
- package/src/net/gh-api-path.js +35 -9
- package/src/net/test-egress-guard.js +16 -4
- package/src/plugin/manifest.js +12 -8
- package/src/review/ai-review.js +110 -10
- package/src/review/investor-dd-devtestbot.js +17 -6
- package/src/review/investor-dd-orchestrator.js +21 -13
- package/src/review/local-review.js +26 -16
- package/src/review/omargate-cache.js +5 -5
- package/src/review/omargate-orchestrator.js +89 -17
- package/src/review/replay.js +9 -4
- package/src/review/report.js +22 -13
- package/src/review/typesafe-severity.js +201 -0
- package/src/session/admission-auth.js +34 -7
- package/src/session/admission.js +322 -71
- package/src/session/agent-access.js +137 -0
- package/src/session/agent-registry.js +2 -1
- package/src/session/attachment-summary.js +77 -0
- package/src/session/edit-summary.js +19 -0
- package/src/session/invitations.js +65 -1
- package/src/session/mentions.js +17 -9
- package/src/session/message-edits.js +5 -4
- package/src/session/preview.js +10 -2
- package/src/session/recap.js +3 -2
- package/src/session/room-files.js +519 -0
- package/src/session/route-classes.js +22 -8
- package/src/session/setup-guides.js +4 -0
- package/src/session/standup.js +192 -0
- package/src/session/sync.js +229 -10
- package/src/session/tickets.js +41 -0
- package/src/session/transcript.js +2 -1
- package/src/session/wake/resolve-target.js +7 -3
- package/src/skills/listener-runtime.cjs +92 -0
- package/src/skills/service.js +207 -0
- package/src/swarm/factory.js +5 -5
- package/src/swarm/pentest.js +9 -9
- package/src/swarm/report.js +4 -4
- package/src/swarm/runtime.js +15 -12
- package/src/swarm/scenario-dsl.js +7 -5
- package/src/telemetry/ledger.js +16 -7
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EW04 source-byte intake. The caller owns the source registry, origin allowlist,
|
|
3
|
+
* authorization decision and byte reader. In particular, `read` must be a
|
|
4
|
+
* trusted connector (for example, a repository blob reader); this module does
|
|
5
|
+
* not turn a URL from page content into a network request.
|
|
6
|
+
*
|
|
7
|
+
* Capture is deliberately before extraction: the returned bytes and digest are
|
|
8
|
+
* the evidence an extractor must cite. A schema-valid snapshot alone does not
|
|
9
|
+
* make extracted assertions authoritative.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { isIP } from "node:net";
|
|
13
|
+
import { sourceSnapshotSchema } from "./contracts/source-snapshot.js";
|
|
14
|
+
|
|
15
|
+
export const MAX_SOURCE_BYTES = 1024 * 1024;
|
|
16
|
+
const DEFAULT_SOURCE_BYTES = 256 * 1024;
|
|
17
|
+
const TEXT_TYPES = new Set(["text/plain", "text/html", "application/json"]);
|
|
18
|
+
// Public guides may name credential variables. Reject credential-shaped values
|
|
19
|
+
// without treating a variable name such as AIDENID_API_KEY as a leaked key.
|
|
20
|
+
const CREDENTIAL_VALUES = [
|
|
21
|
+
/(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-(?:proj-|ant-)?[A-Za-z0-9_-]{20,})/,
|
|
22
|
+
/(?:AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]+PRIVATE KEY-----|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35})/,
|
|
23
|
+
/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}/,
|
|
24
|
+
/authorization\s*:\s*bearer\s+[A-Za-z0-9._~+/-]{24,}/i,
|
|
25
|
+
// The key may be quoted, as in JSON ("api_key": "...") or YAML ('token': ...).
|
|
26
|
+
/(?:api[_-]?key|secret|token|password)["']?\s*[:=]\s*["']?[A-Za-z0-9._~+/-]{24,}/i,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export class SourceIntakeError extends Error {
|
|
30
|
+
constructor(code) {
|
|
31
|
+
super(code);
|
|
32
|
+
this.name = "SourceIntakeError";
|
|
33
|
+
this.code = code;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const OWN_ERRORS = new WeakSet();
|
|
38
|
+
const CODES = new Set([
|
|
39
|
+
"invalid_source_limit", "source_access_denied", "source_not_approved",
|
|
40
|
+
"source_rejected", "source_too_large", "source_unavailable",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
function reject(code) {
|
|
44
|
+
// Frozen: a caller that kept an error from an earlier call cannot rewrite its message or code and replay it.
|
|
45
|
+
const error = Object.freeze(new SourceIntakeError(code));
|
|
46
|
+
OWN_ERRORS.add(error);
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function approvedUrl(raw, allowedOrigins) {
|
|
51
|
+
if (typeof raw !== "string" || !(allowedOrigins instanceof Set)) reject("source_not_approved");
|
|
52
|
+
let url;
|
|
53
|
+
try {
|
|
54
|
+
url = new URL(raw);
|
|
55
|
+
} catch {
|
|
56
|
+
reject("source_not_approved");
|
|
57
|
+
}
|
|
58
|
+
if (
|
|
59
|
+
url.href !== raw || url.protocol !== "https:" || url.username || url.password ||
|
|
60
|
+
url.search || url.hash || isIP(url.hostname.replace(/^\[|\]$/g, "")) || !allowedOrigins.has(url.origin)
|
|
61
|
+
) reject("source_not_approved");
|
|
62
|
+
return url.href;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function unchangedApprovedSource(current, approved, allowedOrigins) {
|
|
66
|
+
try {
|
|
67
|
+
return current?.tenantScope === approved.tenantScope &&
|
|
68
|
+
current.visibility === approved.visibility &&
|
|
69
|
+
current.version === approved.version &&
|
|
70
|
+
current.sourceType === approved.sourceType &&
|
|
71
|
+
current.mediaType === approved.mediaType &&
|
|
72
|
+
approvedUrl(current.url, allowedOrigins) === approved.url;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The TypedArray intrinsic getters: a reader-supplied view can shadow .byteLength/.byteOffset/.buffer with
|
|
79
|
+
// own properties, so the cap and the copy read the real values through these, never through the object.
|
|
80
|
+
const TYPED_ARRAY = Object.getPrototypeOf(Uint8Array.prototype);
|
|
81
|
+
const intrinsic = (name) => Object.getOwnPropertyDescriptor(TYPED_ARRAY, name).get;
|
|
82
|
+
const VIEW_LENGTH = intrinsic("byteLength");
|
|
83
|
+
const VIEW_OFFSET = intrinsic("byteOffset");
|
|
84
|
+
const VIEW_BUFFER = intrinsic("buffer");
|
|
85
|
+
// A stream may only yield this many chunks in total, so endless empty chunks cannot hold a capture open.
|
|
86
|
+
export const MAX_SOURCE_CHUNKS = 4096;
|
|
87
|
+
|
|
88
|
+
function copyView(view) {
|
|
89
|
+
const length = Reflect.apply(VIEW_LENGTH, view, []);
|
|
90
|
+
return { length, copy: () => Buffer.from(new Uint8Array(Reflect.apply(VIEW_BUFFER, view, []), Reflect.apply(VIEW_OFFSET, view, []), length)) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function boundedBytes(body, limit) {
|
|
94
|
+
if (body instanceof Uint8Array) {
|
|
95
|
+
const view = copyView(body);
|
|
96
|
+
if (view.length > limit) reject("source_too_large");
|
|
97
|
+
return view.copy();
|
|
98
|
+
}
|
|
99
|
+
if (!body || typeof body[Symbol.asyncIterator] !== "function") reject("source_rejected");
|
|
100
|
+
const chunks = [];
|
|
101
|
+
let length = 0;
|
|
102
|
+
let count = 0;
|
|
103
|
+
for await (const chunk of body) {
|
|
104
|
+
if (!(chunk instanceof Uint8Array)) reject("source_rejected");
|
|
105
|
+
count += 1;
|
|
106
|
+
if (count > MAX_SOURCE_CHUNKS) reject("source_too_large");
|
|
107
|
+
const view = copyView(chunk);
|
|
108
|
+
if (view.length === 0) continue; // never retain empty chunks
|
|
109
|
+
length += view.length;
|
|
110
|
+
if (length > limit) reject("source_too_large");
|
|
111
|
+
chunks.push(view.copy());
|
|
112
|
+
}
|
|
113
|
+
return Buffer.concat(chunks, length);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Capture a registered source into an EW02 SourceSnapshot and bounded bytes.
|
|
118
|
+
* `authorize` must consult the real tenant policy and is called again before
|
|
119
|
+
* returning captured bytes; `sources` and
|
|
120
|
+
* `allowedOrigins` must be maintained by the site owner, not by page content.
|
|
121
|
+
* `read` receives the approved URL and immutable source version, and must not
|
|
122
|
+
* follow redirects. It must report the actual version of the returned bytes,
|
|
123
|
+
* not echo the requested version; only the trusted connector can make that
|
|
124
|
+
* assertion. Its result must also report the final URL, status and media type.
|
|
125
|
+
*/
|
|
126
|
+
export async function captureSourceBytes({
|
|
127
|
+
sourceId,
|
|
128
|
+
tenantScope,
|
|
129
|
+
sources,
|
|
130
|
+
allowedOrigins,
|
|
131
|
+
authorize,
|
|
132
|
+
read,
|
|
133
|
+
capturedAt = () => new Date().toISOString(),
|
|
134
|
+
maxBytes = DEFAULT_SOURCE_BYTES,
|
|
135
|
+
} = {}) {
|
|
136
|
+
if (
|
|
137
|
+
typeof sourceId !== "string" || !sourceId ||
|
|
138
|
+
typeof tenantScope !== "string" || !tenantScope ||
|
|
139
|
+
typeof authorize !== "function"
|
|
140
|
+
) reject("source_access_denied");
|
|
141
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_SOURCE_BYTES) {
|
|
142
|
+
reject("invalid_source_limit");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// No source lookup or reader invocation until the tenant policy grants access.
|
|
146
|
+
let permitted = false;
|
|
147
|
+
try {
|
|
148
|
+
permitted = await authorize({ tenantScope, sourceId });
|
|
149
|
+
} catch {
|
|
150
|
+
reject("source_access_denied");
|
|
151
|
+
}
|
|
152
|
+
if (permitted !== true) reject("source_access_denied");
|
|
153
|
+
|
|
154
|
+
let approved;
|
|
155
|
+
try {
|
|
156
|
+
const source = sources instanceof Map ? sources.get(sourceId) : undefined;
|
|
157
|
+
approved = source && Object.freeze({
|
|
158
|
+
tenantScope: source.tenantScope,
|
|
159
|
+
visibility: source.visibility,
|
|
160
|
+
url: source.url,
|
|
161
|
+
version: source.version,
|
|
162
|
+
sourceType: source.sourceType,
|
|
163
|
+
mediaType: source.mediaType,
|
|
164
|
+
});
|
|
165
|
+
} catch {
|
|
166
|
+
reject("source_not_approved");
|
|
167
|
+
}
|
|
168
|
+
if (!approved || approved.tenantScope !== tenantScope || approved.visibility !== "public") {
|
|
169
|
+
reject("source_not_approved");
|
|
170
|
+
}
|
|
171
|
+
const url = approvedUrl(approved.url, allowedOrigins);
|
|
172
|
+
if (typeof approved.version !== "string" || !approved.version ||
|
|
173
|
+
(approved.sourceType !== "api" && approved.sourceType !== "ingest" && approved.sourceType !== "import") ||
|
|
174
|
+
!TEXT_TYPES.has(approved.mediaType) ||
|
|
175
|
+
typeof read !== "function") reject("source_not_approved");
|
|
176
|
+
|
|
177
|
+
let response;
|
|
178
|
+
try {
|
|
179
|
+
response = await read({ sourceId, tenantScope, url, version: approved.version, maxBytes });
|
|
180
|
+
} catch {
|
|
181
|
+
reject("source_unavailable");
|
|
182
|
+
}
|
|
183
|
+
let declaredLength;
|
|
184
|
+
try {
|
|
185
|
+
declaredLength = response?.contentLength;
|
|
186
|
+
if (
|
|
187
|
+
response?.status !== 200 || response.url !== url || response.version !== approved.version ||
|
|
188
|
+
typeof response.mediaType !== "string" ||
|
|
189
|
+
response.mediaType.split(";", 1)[0].trim().toLowerCase() !== approved.mediaType.toLowerCase() ||
|
|
190
|
+
(declaredLength !== undefined &&
|
|
191
|
+
(!Number.isSafeInteger(declaredLength) || declaredLength < 0 || declaredLength > maxBytes))
|
|
192
|
+
) reject("source_rejected");
|
|
193
|
+
} catch {
|
|
194
|
+
reject("source_rejected");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let bytes;
|
|
198
|
+
try {
|
|
199
|
+
bytes = await boundedBytes(response.body, maxBytes);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
// Never rethrow the caught object: a body stream can throw anything, including one of our own earlier errors.
|
|
202
|
+
// Re-raise only a fresh error carrying an allow-listed code.
|
|
203
|
+
if (OWN_ERRORS.has(error) && CODES.has(error.code)) reject(error.code);
|
|
204
|
+
reject("source_unavailable");
|
|
205
|
+
}
|
|
206
|
+
if (declaredLength !== undefined && declaredLength !== bytes.length) {
|
|
207
|
+
reject("source_rejected");
|
|
208
|
+
}
|
|
209
|
+
let text;
|
|
210
|
+
try {
|
|
211
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
212
|
+
} catch {
|
|
213
|
+
reject("source_rejected");
|
|
214
|
+
}
|
|
215
|
+
// This first slice accepts public text only. A private/authenticated connector
|
|
216
|
+
// needs its own grant and content policy; a token in public source is denied.
|
|
217
|
+
if (CREDENTIAL_VALUES.some((pattern) => pattern.test(text))) reject("source_rejected");
|
|
218
|
+
|
|
219
|
+
let snapshot;
|
|
220
|
+
try {
|
|
221
|
+
snapshot = sourceSnapshotSchema.parse({
|
|
222
|
+
sourceId,
|
|
223
|
+
sourceType: approved.sourceType,
|
|
224
|
+
version: approved.version,
|
|
225
|
+
capturedAt: capturedAt(),
|
|
226
|
+
tenantScope,
|
|
227
|
+
contentHash: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
|
|
228
|
+
metadata: { origin: new URL(url).origin, mediaType: approved.mediaType, byteLength: bytes.length },
|
|
229
|
+
});
|
|
230
|
+
} catch {
|
|
231
|
+
reject("source_rejected");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// A read may outlive owner policy or source-version changes. Never return
|
|
235
|
+
// bytes with a snapshot from a different approved record or revoked grant.
|
|
236
|
+
let stillPermitted = false;
|
|
237
|
+
try {
|
|
238
|
+
stillPermitted = await authorize({ tenantScope, sourceId });
|
|
239
|
+
} catch {
|
|
240
|
+
reject("source_access_denied");
|
|
241
|
+
}
|
|
242
|
+
if (stillPermitted !== true) reject("source_access_denied");
|
|
243
|
+
let current;
|
|
244
|
+
try {
|
|
245
|
+
current = sources.get(sourceId);
|
|
246
|
+
} catch {
|
|
247
|
+
reject("source_not_approved");
|
|
248
|
+
}
|
|
249
|
+
if (!unchangedApprovedSource(current, approved, allowedOrigins)) reject("source_not_approved");
|
|
250
|
+
return { snapshot, bytes };
|
|
251
|
+
}
|
package/src/engram/store.js
CHANGED
|
@@ -13,22 +13,218 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Detachability: imports ONLY the §1 engine core (observations + text) and
|
|
15
15
|
* node builtins. No SentinelLayer session/mcp/auth imports.
|
|
16
|
+
*
|
|
17
|
+
* --- On-disk layout (since 2a885ea8) ---
|
|
18
|
+
*
|
|
19
|
+
* Each namespace lives under `.sentinelayer/engram/ns-<sha256hex(raw)>/`.
|
|
20
|
+
* The sha256-hex directory name makes every namespace's storage independent:
|
|
21
|
+
* distinct namespaces never share a directory, even on case-insensitive
|
|
22
|
+
* filesystems. A `namespace.json` sidecar is written on first write and
|
|
23
|
+
* verified on every subsequent access as defence-in-depth (the stored item
|
|
24
|
+
* records carry no namespace field, so ownership of a legacy lossy directory
|
|
25
|
+
* can never be proven from contents alone).
|
|
26
|
+
*
|
|
27
|
+
* Before 2a885ea8, namespaceDir mapped a namespace to a lossy sanitized name
|
|
28
|
+
* (`raw.replace(/[^A-Za-z0-9._-]/g, "_")`), which let distinct namespaces
|
|
29
|
+
* collide. The `legacyLossyDirs` option controls what happens when the hashed
|
|
30
|
+
* directory is absent but the old lossy directory exists:
|
|
31
|
+
* - "refuse" (default): throw a typed error, never read or write.
|
|
32
|
+
* - "first-claim": atomically rename the legacy dir to the hashed dir, write
|
|
33
|
+
* namespace.json with a legacyFrom marker, and proceed. Two concurrent
|
|
34
|
+
* claims yield exactly one owner (rename is the arbiter).
|
|
35
|
+
*
|
|
36
|
+
* A `namespace.json` sidecar is written on first write and verified on every
|
|
37
|
+
* access. A sidecar whose `raw` names a different namespace causes a
|
|
38
|
+
* `NamespaceOwnershipError`.
|
|
16
39
|
*/
|
|
17
40
|
|
|
18
41
|
import fsp from "node:fs/promises";
|
|
42
|
+
import crypto from "node:crypto";
|
|
19
43
|
import path from "node:path";
|
|
20
44
|
|
|
21
45
|
import { buildObservationsFromItems } from "./observations.js";
|
|
22
46
|
import { contentHash, normalizeString } from "../session/recall/text.js";
|
|
23
47
|
|
|
48
|
+
function sha256hex(str) {
|
|
49
|
+
return crypto.createHash("sha256").update(str, "utf-8").digest("hex");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ENGRAM_ROOT = ".sentinelayer";
|
|
53
|
+
const ENGRAM_DIR = "engram";
|
|
54
|
+
|
|
55
|
+
export class LegacyStorageError extends Error {
|
|
56
|
+
constructor(message) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = "LegacyStorageError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class NamespaceOwnershipError extends Error {
|
|
63
|
+
constructor(message) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = "NamespaceOwnershipError";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The old (pre-2a885ea8) lossy directory name for a namespace.
|
|
71
|
+
* Exported for legacy migration logic.
|
|
72
|
+
*/
|
|
73
|
+
export function legacyNamespaceDirName(namespace) {
|
|
74
|
+
return namespace.raw.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
75
|
+
}
|
|
76
|
+
|
|
24
77
|
/**
|
|
25
|
-
* Where a namespace's files live.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
78
|
+
* Where a namespace's files live. Each namespace gets its own sha256-hex
|
|
79
|
+
* directory so distinct namespaces never collide, even on case-insensitive
|
|
80
|
+
* filesystems. A `namespace.json` sidecar is written on first write and
|
|
81
|
+
* verified on every subsequent access.
|
|
82
|
+
*
|
|
83
|
+
* EXPORTED so siblings (e.g. build-state.js) resolve the same directory rather
|
|
84
|
+
* than re-deriving the convention -- a second copy would drift and silently
|
|
85
|
+
* point at a different namespace.
|
|
28
86
|
*/
|
|
29
87
|
export function namespaceDir(root, namespace) {
|
|
30
|
-
|
|
31
|
-
|
|
88
|
+
return path.join(root, ENGRAM_ROOT, ENGRAM_DIR, `ns-${sha256hex(namespace.raw)}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The old lossy directory path (pre-2a885ea8). */
|
|
92
|
+
function legacyDir(root, namespace) {
|
|
93
|
+
return path.join(root, ENGRAM_ROOT, ENGRAM_DIR, legacyNamespaceDirName(namespace));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const SIDECAR = "namespace.json";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Verify the directory's namespace.json sidecar matches the expected namespace.
|
|
100
|
+
* NEVER creates the directory or sidecar. Throws NamespaceOwnershipError if a
|
|
101
|
+
* different namespace already owns the directory.
|
|
102
|
+
*/
|
|
103
|
+
export async function verifyNamespace(dir, namespace) {
|
|
104
|
+
const sidecar = path.join(dir, SIDECAR);
|
|
105
|
+
let raw;
|
|
106
|
+
try {
|
|
107
|
+
raw = await fsp.readFile(sidecar, "utf-8");
|
|
108
|
+
} catch (e) {
|
|
109
|
+
if (e && e.code === "ENOENT") {
|
|
110
|
+
throw new NamespaceOwnershipError(
|
|
111
|
+
`namespace dir has no sidecar: directory ${dir} is not a valid engram namespace`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`corrupt namespace sidecar in ${dir}: ${e.message}`);
|
|
115
|
+
}
|
|
116
|
+
let existing;
|
|
117
|
+
try {
|
|
118
|
+
existing = JSON.parse(raw);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
throw new Error(`corrupt namespace sidecar in ${dir}: ${e.message}`);
|
|
121
|
+
}
|
|
122
|
+
if (existing && typeof existing === "object" && existing !== null) {
|
|
123
|
+
if (existing.raw !== namespace.raw) {
|
|
124
|
+
throw new NamespaceOwnershipError(
|
|
125
|
+
`namespace dir collision: directory ${dir} already owned by "${existing.raw}" (tried to access as "${namespace.raw}")`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
throw new Error(`corrupt namespace sidecar in ${dir}: expected object, got ${typeof existing}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Ensure the directory's namespace.json sidecar matches the expected namespace,
|
|
135
|
+
* writing it on first write with flag "wx" so a concurrent create is detected.
|
|
136
|
+
* Throws NamespaceOwnershipError if a different namespace already owns the
|
|
137
|
+
* directory.
|
|
138
|
+
*/
|
|
139
|
+
export async function ensureNamespace(dir, namespace, legacyFrom) {
|
|
140
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
141
|
+
const sidecar = path.join(dir, SIDECAR);
|
|
142
|
+
try {
|
|
143
|
+
await fsp.writeFile(
|
|
144
|
+
sidecar,
|
|
145
|
+
JSON.stringify({ raw: namespace.raw, ...(legacyFrom ? { legacyFrom } : {}) }, null, 2) + "\n",
|
|
146
|
+
{ flag: "wx" },
|
|
147
|
+
);
|
|
148
|
+
return;
|
|
149
|
+
} catch (e) {
|
|
150
|
+
if (e && e.code === "EEXIST") {
|
|
151
|
+
// Sidecar already exists — verify it matches.
|
|
152
|
+
return verifyNamespace(dir, namespace);
|
|
153
|
+
}
|
|
154
|
+
throw e;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve the namespace directory, handling legacy migration if configured.
|
|
160
|
+
* Returns the directory path to use.
|
|
161
|
+
*/
|
|
162
|
+
export async function resolveNamespaceDir(root, namespace, legacyLossyDirs) {
|
|
163
|
+
const dir = namespaceDir(root, namespace);
|
|
164
|
+
try {
|
|
165
|
+
await fsp.access(dir);
|
|
166
|
+
return dir;
|
|
167
|
+
} catch {
|
|
168
|
+
// Hashed dir doesn't exist — check for legacy
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const oldDir = legacyDir(root, namespace);
|
|
172
|
+
let legacyExists = false;
|
|
173
|
+
try {
|
|
174
|
+
await fsp.access(oldDir);
|
|
175
|
+
legacyExists = true;
|
|
176
|
+
} catch {
|
|
177
|
+
// No legacy dir either
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!legacyExists) {
|
|
181
|
+
return dir;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (legacyLossyDirs === "first-claim") {
|
|
185
|
+
// Atomically claim the legacy directory via rename
|
|
186
|
+
try {
|
|
187
|
+
await fsp.rename(oldDir, dir);
|
|
188
|
+
// Write sidecar with legacy marker
|
|
189
|
+
await ensureNamespace(dir, namespace, legacyNamespaceDirName(namespace));
|
|
190
|
+
// Notify once per claim
|
|
191
|
+
console.error(
|
|
192
|
+
`engram: claimed legacy storage for "${namespace.raw}" from lossy dir "${legacyNamespaceDirName(namespace)}"`,
|
|
193
|
+
);
|
|
194
|
+
return dir;
|
|
195
|
+
} catch (e) {
|
|
196
|
+
if (e && e.code === "ENOENT") {
|
|
197
|
+
// Lost the race — another caller claimed it or it was deleted.
|
|
198
|
+
// Try the hashed dir now (the winner may have renamed to it).
|
|
199
|
+
try {
|
|
200
|
+
await fsp.access(dir);
|
|
201
|
+
return dir;
|
|
202
|
+
} catch {
|
|
203
|
+
// Neither exists — start fresh
|
|
204
|
+
return dir;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// NS6: on EEXIST/ENOTEMPTY/EPERM, the hashed dir appeared concurrently.
|
|
208
|
+
// Re-check: if its sidecar is ours, use it; otherwise rethrow.
|
|
209
|
+
if (e && (e.code === "EEXIST" || e.code === "ENOTEMPTY" || e.code === "EPERM")) {
|
|
210
|
+
try {
|
|
211
|
+
await verifyNamespace(dir, namespace);
|
|
212
|
+
// Sidecar is ours — use the hashed dir, leave legacy untouched
|
|
213
|
+
return dir;
|
|
214
|
+
} catch {
|
|
215
|
+
// Sidecar names another namespace — rethrow original
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// "refuse" — the default
|
|
223
|
+
// Note: the error message intentionally omits directory paths (public repo).
|
|
224
|
+
throw new LegacyStorageError(
|
|
225
|
+
`ambiguous legacy storage for namespace "${namespace.raw}". The old lossy directory still exists. ` +
|
|
226
|
+
`Run with legacyLossyDirs: "first-claim" to migrate, or delete the legacy dir manually.`,
|
|
227
|
+
);
|
|
32
228
|
}
|
|
33
229
|
|
|
34
230
|
/** Stable, content-addressed item id (idempotent write — §3 dedup for free). */
|
|
@@ -66,17 +262,20 @@ async function readItemsFile(file) {
|
|
|
66
262
|
* @param {string} options.storeRoot Root dir for the append-log store.
|
|
67
263
|
* @param {Record<string, (id:string)=>Promise<object[]>>} [options.adapters]
|
|
68
264
|
* Map of namespace-kind -> async source adapter returning observations.
|
|
265
|
+
* @param {"refuse"|"first-claim"} [options.legacyLossyDirs="refuse"]
|
|
266
|
+
* Policy for pre-2a885ea8 lossy directories.
|
|
69
267
|
* @returns {{
|
|
70
268
|
* appendItems: (namespace:object, items:object[]) => Promise<{written:number, deduped:number, namespace:string}>,
|
|
71
269
|
* readObservations: (namespace:object) => Promise<object[]>,
|
|
72
270
|
* }}
|
|
73
271
|
*/
|
|
74
|
-
export function createStore({ storeRoot, adapters = {} } = {}) {
|
|
272
|
+
export function createStore({ storeRoot, adapters = {}, legacyLossyDirs = "refuse" } = {}) {
|
|
75
273
|
const root = path.resolve(String(storeRoot || "."));
|
|
76
274
|
|
|
77
275
|
return {
|
|
78
276
|
async appendItems(namespace, items = []) {
|
|
79
|
-
const dir =
|
|
277
|
+
const dir = await resolveNamespaceDir(root, namespace, legacyLossyDirs);
|
|
278
|
+
await ensureNamespace(dir, namespace);
|
|
80
279
|
const file = path.join(dir, "items.ndjson");
|
|
81
280
|
const existing = await readItemsFile(file);
|
|
82
281
|
const seen = new Set(existing.map((it) => itemId(it)));
|
|
@@ -105,8 +304,27 @@ export function createStore({ storeRoot, adapters = {} } = {}) {
|
|
|
105
304
|
const adapterObservations = adapter
|
|
106
305
|
? await Promise.resolve(adapter(namespace.id)).catch(() => [])
|
|
107
306
|
: [];
|
|
108
|
-
const
|
|
109
|
-
|
|
307
|
+
const dir = await resolveNamespaceDir(root, namespace, legacyLossyDirs);
|
|
308
|
+
// NS1: reads never create. If the hashed dir is absent, return empty.
|
|
309
|
+
let dirExists = false;
|
|
310
|
+
try {
|
|
311
|
+
await fsp.access(dir);
|
|
312
|
+
dirExists = true;
|
|
313
|
+
} catch {
|
|
314
|
+
// dir absent — no data
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let items = [];
|
|
318
|
+
if (dirExists) {
|
|
319
|
+
// A first-claim rename on read is allowed (the explicit migration policy
|
|
320
|
+
// in resolveNamespaceDir handles it). If the dir exists after
|
|
321
|
+
// resolution, verify its sidecar without creating anything.
|
|
322
|
+
await verifyNamespace(dir, namespace);
|
|
323
|
+
const file = path.join(dir, "items.ndjson");
|
|
324
|
+
items = await readItemsFile(file);
|
|
325
|
+
}
|
|
326
|
+
// If dir is absent and no adapter observations, items stays []
|
|
327
|
+
// and the union below produces only adapter observations (or []).
|
|
110
328
|
const itemObservations = buildObservationsFromItems(items, { namespace: namespace.raw }).observations;
|
|
111
329
|
|
|
112
330
|
// Union, dedup by id (first wins — adapter source is authoritative for its ids).
|
package/src/ingest/engine.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requireOutputFileSystem } from "../config/output-filesystem.js";
|
|
1
2
|
import { execFile } from "node:child_process";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import fs from "node:fs";
|
|
@@ -8,7 +9,7 @@ import { promisify } from "node:util";
|
|
|
8
9
|
|
|
9
10
|
import ignore from "ignore";
|
|
10
11
|
|
|
11
|
-
import {
|
|
12
|
+
import { resolveOutputContext } from "../config/service.js";
|
|
12
13
|
|
|
13
14
|
const DEFAULT_IGNORED_DIRS = new Set([
|
|
14
15
|
".git",
|
|
@@ -259,19 +260,6 @@ async function readExistingIngest(outputPath) {
|
|
|
259
260
|
}
|
|
260
261
|
}
|
|
261
262
|
|
|
262
|
-
async function resolveIngestOutputPath({ rootPath, outputFile = "", outputDir = "" }) {
|
|
263
|
-
const resolvedRoot = path.resolve(rootPath || process.cwd());
|
|
264
|
-
const explicitOutputFile = String(outputFile || "").trim();
|
|
265
|
-
if (explicitOutputFile) {
|
|
266
|
-
return path.resolve(resolvedRoot, explicitOutputFile);
|
|
267
|
-
}
|
|
268
|
-
const outputRoot = await resolveOutputRoot({
|
|
269
|
-
cwd: resolvedRoot,
|
|
270
|
-
outputDirOverride: outputDir,
|
|
271
|
-
});
|
|
272
|
-
return path.join(outputRoot, "CODEBASE_INGEST.json");
|
|
273
|
-
}
|
|
274
|
-
|
|
275
263
|
async function readGitLastCommitAt(rootPath) {
|
|
276
264
|
const resolvedRoot = path.resolve(rootPath || process.cwd());
|
|
277
265
|
try {
|
|
@@ -793,36 +781,36 @@ export function formatIngestSummary(ingest) {
|
|
|
793
781
|
return lines.join("\n");
|
|
794
782
|
}
|
|
795
783
|
|
|
796
|
-
|
|
784
|
+
async function resolveIngestOutputContext({ rootPath, outputFile = "", outputDir = "" }) {
|
|
797
785
|
const resolvedRoot = path.resolve(rootPath || process.cwd());
|
|
798
786
|
const resolvedOutputFile = String(outputFile || "").trim();
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
await fsp.mkdir(path.dirname(outputPath), { recursive: true });
|
|
810
|
-
await fsp.writeFile(outputPath, `${JSON.stringify(ingest, null, 2)}\n`, "utf-8");
|
|
787
|
+
const output = await resolveOutputContext({ cwd: resolvedRoot, outputDirOverride: resolvedOutputFile || outputDir });
|
|
788
|
+
return { outputPath: resolvedOutputFile ? output.outputRoot : path.join(output.outputRoot, "CODEBASE_INGEST.json"), outputFs: output.outputFs };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export async function writeCodebaseIngest({ ingest, rootPath, outputFile = "", outputDir = "", outputFs } = {}) {
|
|
792
|
+
requireOutputFileSystem(outputFs);
|
|
793
|
+
const { outputPath } = await resolveIngestOutputContext({ rootPath, outputFile, outputDir });
|
|
794
|
+
|
|
795
|
+
await outputFs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
796
|
+
await outputFs.writeFile(outputPath, `${JSON.stringify(ingest, null, 2)}\n`, "utf-8");
|
|
811
797
|
return outputPath;
|
|
812
798
|
}
|
|
813
799
|
|
|
814
800
|
export async function resolveCodebaseIngest({
|
|
801
|
+
outputFs: inheritedOutputFs,
|
|
815
802
|
rootPath = process.cwd(),
|
|
816
803
|
outputFile = "",
|
|
817
804
|
outputDir = "",
|
|
818
805
|
refresh = false,
|
|
819
806
|
} = {}) {
|
|
820
807
|
const resolvedRoot = path.resolve(rootPath || process.cwd());
|
|
821
|
-
const outputPath = await
|
|
808
|
+
const { outputPath, outputFs: resolvedOutputFs } = await resolveIngestOutputContext({
|
|
822
809
|
rootPath: resolvedRoot,
|
|
823
810
|
outputFile,
|
|
824
811
|
outputDir,
|
|
825
812
|
});
|
|
813
|
+
const outputFs = inheritedOutputFs === undefined ? resolvedOutputFs : requireOutputFileSystem(inheritedOutputFs);
|
|
826
814
|
const existingIngest = await readExistingIngest(outputPath);
|
|
827
815
|
const fingerprint = await computeCodebaseContentFingerprint({
|
|
828
816
|
rootPath: resolvedRoot,
|
|
@@ -859,6 +847,7 @@ export async function resolveCodebaseIngest({
|
|
|
859
847
|
ingest.cache.filesCount = fingerprint.filesCount;
|
|
860
848
|
ingest.cache.latestFileMtimeMs = fingerprint.latestFileMtimeMs;
|
|
861
849
|
await writeCodebaseIngest({
|
|
850
|
+
outputFs,
|
|
862
851
|
ingest,
|
|
863
852
|
rootPath: resolvedRoot,
|
|
864
853
|
outputFile,
|
|
@@ -900,12 +889,16 @@ export async function resolveCodebaseIngest({
|
|
|
900
889
|
}
|
|
901
890
|
|
|
902
891
|
export async function generateCodebaseIngest({
|
|
892
|
+
outputFs: inheritedOutputFs,
|
|
903
893
|
rootPath = process.cwd(),
|
|
904
894
|
outputFile = "",
|
|
905
895
|
outputDir = "",
|
|
906
896
|
} = {}) {
|
|
897
|
+
const { outputFs: resolvedOutputFs } = await resolveIngestOutputContext({ rootPath, outputFile, outputDir });
|
|
898
|
+
const outputFs = inheritedOutputFs === undefined ? resolvedOutputFs : requireOutputFileSystem(inheritedOutputFs);
|
|
907
899
|
const ingest = await collectCodebaseIngest({ rootPath });
|
|
908
900
|
const outputPath = await writeCodebaseIngest({
|
|
901
|
+
outputFs,
|
|
909
902
|
ingest,
|
|
910
903
|
rootPath,
|
|
911
904
|
outputFile,
|