odai-dsh-agent 0.2.1 → 0.2.3

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 (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +138 -138
  3. package/bin/odai-dsh-agent.mjs +108 -108
  4. package/package.json +69 -69
  5. package/preset/odai/agent.cordis.yml +261 -260
  6. package/preset/odai/preset.yml +3 -3
  7. package/preset/odai/runtime/compaction-config.mjs +388 -388
  8. package/preset/odai/runtime/governance.mjs +128 -128
  9. package/preset/odai/runtime/index.mjs +2461 -2263
  10. package/preset/odai/runtime/output-config.mjs +360 -304
  11. package/preset/odai/runtime/research-packet.mjs +150 -150
  12. package/preset/odai/runtime/responsibility-scope.mjs +35 -2
  13. package/preset/odai/runtime/role-overlays.mjs +27 -27
  14. package/preset/odai/runtime/route-card.mjs +168 -152
  15. package/preset/odai/runtime/router.mjs +683 -651
  16. package/preset/odai/runtime/routing-config.mjs +480 -436
  17. package/preset/odai/runtime/routing-context.mjs +321 -321
  18. package/preset/odai/runtime/semantic-memory-store.mjs +452 -452
  19. package/preset/odai/runtime/semantic-memory.mjs +842 -842
  20. package/preset/odai/runtime/session-compat.mjs +389 -389
  21. package/preset/odai/runtime/skill-bundle.mjs +318 -318
  22. package/preset/odai/runtime/skill-evolution.mjs +1413 -1413
  23. package/preset/odai/skills/odai/agents/openai.yaml +4 -4
  24. package/preset/odai/skills/odai/assets/claude-agents/agent.md +11 -11
  25. package/preset/odai/skills/odai/assets/codex-agents/config.toml +11 -11
  26. package/preset/odai/skills/odai/assets/codex-agents/role.toml +5 -5
  27. package/preset/odai/skills/odai/assets/copilot-agents/agent.md +13 -13
  28. package/preset/odai/skills/odai/assets/hooks-policy.example.json +24 -24
  29. package/preset/odai/skills/odai/assets/routing-roles/controller.md +15 -15
  30. package/preset/odai/skills/odai/assets/routing-roles/executor.md +11 -11
  31. package/preset/odai/skills/odai/assets/routing-roles/frontend.md +7 -7
  32. package/preset/odai/skills/odai/assets/routing-roles/planner.md +29 -29
  33. package/preset/odai/skills/odai/assets/routing-roles/researcher.md +6 -6
  34. package/preset/odai/skills/odai/assets/routing-roles/reviewer.md +11 -11
  35. package/preset/odai/skills/odai/assets/task-state.md +14 -14
  36. package/preset/odai/skills/odai/manifest.json +37 -37
  37. package/preset/odai/skills/odai/references/craft.md +54 -54
  38. package/preset/odai/skills/odai/references/dao.md +30 -30
  39. package/preset/odai/skills/odai/references/leverage.md +53 -53
  40. package/preset/odai/skills/odai/references/planning.md +82 -82
  41. package/preset/odai/skills/odai/references/support.md +36 -36
  42. package/preset/odai/skills/odai/references/verification.md +23 -23
  43. package/preset/odai/skills/odai/scripts/build-hooks.mjs +273 -273
  44. package/preset/odai/skills/odai/scripts/build-routing.mjs +274 -274
  45. package/preset/odai/skills/odai/scripts/install-routing.mjs +397 -397
  46. package/preset/odai/skills/odai/scripts/odai-hook.mjs +457 -457
  47. package/preset/odai/skills/odai/scripts/run-role.mjs +336 -336
  48. package/preset/odai/skills/odai/scripts/run-routing.mjs +1154 -1154
  49. package/preset/odai/skills/odai/scripts/verify-routing.mjs +103 -103
  50. package/scripts/verify-agent-load.mjs +257 -257
  51. package/src/installer.mjs +398 -382
@@ -1,452 +1,452 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import {
3
- closeSync,
4
- constants,
5
- existsSync,
6
- fstatSync,
7
- fsyncSync,
8
- lstatSync,
9
- mkdirSync,
10
- openSync,
11
- readFileSync,
12
- realpathSync,
13
- renameSync,
14
- rmSync,
15
- writeFileSync,
16
- } from "node:fs";
17
- import { homedir } from "node:os";
18
- import { basename, dirname, resolve } from "node:path";
19
-
20
- import { acquireOwnedStoreLock } from "./store-lock.mjs";
21
-
22
- export const MEMORY_STORE_SCHEMA_VERSION = 1;
23
- export const MEMORY_EXTRACTOR_VERSION = 1;
24
- export const MEMORY_MODES = Object.freeze(["auto", "off"]);
25
- export const MEMORY_CATEGORIES = Object.freeze(["preference", "decision", "constraint", "fact"]);
26
- export const MEMORY_STATUSES = Object.freeze(["pending", "active", "superseded"]);
27
- export const DEFAULT_MEMORY_SETTINGS = Object.freeze({ mode: "auto" });
28
- export const MAX_MEMORY_ENTRIES = 1_000;
29
- export const MAX_MEMORY_PROVENANCE = 8;
30
- export const MAX_MEMORY_VALUE_CHARS = 600;
31
-
32
- const STORE_FIELDS = new Set(["schemaVersion", "settings", "entries"]);
33
- const SETTINGS_FIELDS = new Set(["mode"]);
34
- const ENTRY_FIELDS = new Set([
35
- "id",
36
- "scope",
37
- "category",
38
- "subject",
39
- "value",
40
- "status",
41
- "confidence",
42
- "supersedes",
43
- "conflictsWith",
44
- "provenance",
45
- "createdAt",
46
- "updatedAt",
47
- "occurrences",
48
- ]);
49
- const SCOPE_FIELDS = new Set(["kind", "key", "label"]);
50
- const PROVENANCE_FIELDS = new Set([
51
- "sourceKind",
52
- "sessionHash",
53
- "messageHash",
54
- "turn",
55
- "eventSeq",
56
- "excerptSha256",
57
- "observedAt",
58
- "extraction",
59
- "extractorVersion",
60
- ]);
61
-
62
- export class MemoryStoreValidationError extends Error {}
63
-
64
- function hash(value) {
65
- return createHash("sha256").update(value).digest("hex");
66
- }
67
-
68
- function assertPlainObject(value, field) {
69
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
70
- throw new TypeError(`${field} must be an object`);
71
- }
72
- return value;
73
- }
74
-
75
- function rejectUnknown(value, allowed, field) {
76
- const unknown = Object.keys(value).filter((name) => !allowed.has(name));
77
- if (unknown.length > 0) throw new TypeError(`${field} has unknown fields: ${unknown.join(", ")}`);
78
- }
79
-
80
- function requiredString(value, field, max = 1_000) {
81
- if (typeof value !== "string" || value.length === 0 || value.length > max) {
82
- throw new TypeError(`${field} must be a non-empty string of at most ${max} characters`);
83
- }
84
- return value;
85
- }
86
-
87
- function safeInteger(value, field) {
88
- if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${field} must be a non-negative integer`);
89
- return value;
90
- }
91
-
92
- function validateMode(mode, field = "memory mode") {
93
- if (!MEMORY_MODES.includes(mode)) throw new TypeError(`${field} must be auto or off`);
94
- return mode;
95
- }
96
-
97
- function validateScope(value, field) {
98
- const scope = assertPlainObject(value, field);
99
- rejectUnknown(scope, SCOPE_FIELDS, field);
100
- if (!["global", "project"].includes(scope.kind)) throw new TypeError(`${field}.kind must be global or project`);
101
- requiredString(scope.key, `${field}.key`, 64);
102
- if (scope.kind === "global" && scope.key !== "global") throw new TypeError(`${field}.key must be global for global scope`);
103
- if (scope.kind === "project" && !/^[a-f0-9]{64}$/u.test(scope.key)) {
104
- throw new TypeError(`${field}.key must be a sha256 project key`);
105
- }
106
- if (scope.label !== undefined) requiredString(scope.label, `${field}.label`, 120);
107
- return Object.freeze({
108
- kind: scope.kind,
109
- key: scope.key,
110
- ...(scope.label === undefined ? {} : { label: scope.label }),
111
- });
112
- }
113
-
114
- function validateProvenance(value, field) {
115
- const provenance = assertPlainObject(value, field);
116
- rejectUnknown(provenance, PROVENANCE_FIELDS, field);
117
- if (provenance.sourceKind !== "direct-user") throw new TypeError(`${field}.sourceKind must be direct-user`);
118
- if (!/^[a-f0-9]{64}$/u.test(provenance.sessionHash)) throw new TypeError(`${field}.sessionHash must be sha256`);
119
- if (!/^[a-f0-9]{64}$/u.test(provenance.messageHash)) throw new TypeError(`${field}.messageHash must be sha256`);
120
- if (!/^[a-f0-9]{64}$/u.test(provenance.excerptSha256)) throw new TypeError(`${field}.excerptSha256 must be sha256`);
121
- safeInteger(provenance.turn, `${field}.turn`);
122
- if (provenance.eventSeq !== undefined) safeInteger(provenance.eventSeq, `${field}.eventSeq`);
123
- safeInteger(provenance.observedAt, `${field}.observedAt`);
124
- if (!["local-explicit", "tool-exact-excerpt"].includes(provenance.extraction)) {
125
- throw new TypeError(`${field}.extraction is unsupported`);
126
- }
127
- if (provenance.extractorVersion !== MEMORY_EXTRACTOR_VERSION) {
128
- throw new TypeError(`${field}.extractorVersion is unsupported`);
129
- }
130
- return Object.freeze({
131
- sourceKind: "direct-user",
132
- sessionHash: provenance.sessionHash,
133
- messageHash: provenance.messageHash,
134
- turn: provenance.turn,
135
- ...(provenance.eventSeq === undefined ? {} : { eventSeq: provenance.eventSeq }),
136
- excerptSha256: provenance.excerptSha256,
137
- observedAt: provenance.observedAt,
138
- extraction: provenance.extraction,
139
- extractorVersion: provenance.extractorVersion,
140
- });
141
- }
142
-
143
- function validateEntry(value, field) {
144
- const entry = assertPlainObject(value, field);
145
- rejectUnknown(entry, ENTRY_FIELDS, field);
146
- requiredString(entry.id, `${field}.id`, 80);
147
- const scope = validateScope(entry.scope, `${field}.scope`);
148
- if (!MEMORY_CATEGORIES.includes(entry.category)) throw new TypeError(`${field}.category is unsupported`);
149
- requiredString(entry.subject, `${field}.subject`, 64);
150
- requiredString(entry.value, `${field}.value`, MAX_MEMORY_VALUE_CHARS);
151
- if (!MEMORY_STATUSES.includes(entry.status)) throw new TypeError(`${field}.status is unsupported`);
152
- if (!["high", "medium"].includes(entry.confidence)) throw new TypeError(`${field}.confidence is unsupported`);
153
- if (!Array.isArray(entry.supersedes) || entry.supersedes.length > 64) {
154
- throw new TypeError(`${field}.supersedes must be an array with at most 64 entries`);
155
- }
156
- const supersedes = entry.supersedes.map((id, index) => requiredString(id, `${field}.supersedes[${index}]`, 80));
157
- if (!Array.isArray(entry.conflictsWith) || entry.conflictsWith.length > 64) {
158
- throw new TypeError(`${field}.conflictsWith must be an array with at most 64 entries`);
159
- }
160
- const conflictsWith = entry.conflictsWith.map((id, index) => requiredString(id, `${field}.conflictsWith[${index}]`, 80));
161
- if (!Array.isArray(entry.provenance)
162
- || entry.provenance.length === 0
163
- || entry.provenance.length > MAX_MEMORY_PROVENANCE) {
164
- throw new TypeError(`${field}.provenance must contain 1-${MAX_MEMORY_PROVENANCE} entries`);
165
- }
166
- const provenance = entry.provenance.map((item, index) => validateProvenance(item, `${field}.provenance[${index}]`));
167
- const createdAt = safeInteger(entry.createdAt, `${field}.createdAt`);
168
- const updatedAt = safeInteger(entry.updatedAt, `${field}.updatedAt`);
169
- if (updatedAt < createdAt) throw new TypeError(`${field}.updatedAt must not precede createdAt`);
170
- const occurrences = safeInteger(entry.occurrences, `${field}.occurrences`);
171
- if (occurrences < 1) throw new TypeError(`${field}.occurrences must be positive`);
172
- return Object.freeze({
173
- id: entry.id,
174
- scope,
175
- category: entry.category,
176
- subject: entry.subject,
177
- value: entry.value,
178
- status: entry.status,
179
- confidence: entry.confidence,
180
- supersedes: Object.freeze(supersedes),
181
- conflictsWith: Object.freeze(conflictsWith),
182
- provenance: Object.freeze(provenance),
183
- createdAt,
184
- updatedAt,
185
- occurrences,
186
- });
187
- }
188
-
189
- function validateStore(value, field) {
190
- const store = assertPlainObject(value, field);
191
- rejectUnknown(store, STORE_FIELDS, field);
192
- if (store.schemaVersion !== MEMORY_STORE_SCHEMA_VERSION) {
193
- throw new TypeError(`${field} has unsupported schemaVersion ${String(store.schemaVersion)}`);
194
- }
195
- const settingsValue = store.settings === undefined ? {} : assertPlainObject(store.settings, `${field}.settings`);
196
- rejectUnknown(settingsValue, SETTINGS_FIELDS, `${field}.settings`);
197
- const settings = Object.freeze({
198
- ...(settingsValue.mode === undefined ? {} : { mode: validateMode(settingsValue.mode, `${field}.settings.mode`) }),
199
- });
200
- if (!Array.isArray(store.entries) || store.entries.length > MAX_MEMORY_ENTRIES) {
201
- throw new TypeError(`${field}.entries must be an array with at most ${MAX_MEMORY_ENTRIES} records`);
202
- }
203
- const entries = store.entries.map((entry, index) => validateEntry(entry, `${field}.entries[${index}]`));
204
- const ids = new Set();
205
- for (const entry of entries) {
206
- if (ids.has(entry.id)) throw new TypeError(`${field}.entries contains duplicate id ${entry.id}`);
207
- ids.add(entry.id);
208
- }
209
- return Object.freeze({
210
- schemaVersion: MEMORY_STORE_SCHEMA_VERSION,
211
- settings,
212
- entries: Object.freeze(entries),
213
- });
214
- }
215
-
216
- function emptyStore() {
217
- return Object.freeze({
218
- schemaVersion: MEMORY_STORE_SCHEMA_VERSION,
219
- settings: Object.freeze({}),
220
- entries: Object.freeze([]),
221
- });
222
- }
223
-
224
- function assertNoSymlink(path, label) {
225
- if (!existsSync(path)) return;
226
- const stat = lstatSync(path);
227
- if (stat.isSymbolicLink()) throw new Error(`${label} must not be a symbolic link: ${path}`);
228
- }
229
-
230
- function assertSafeStorePath(storePath) {
231
- const parent = dirname(storePath);
232
- const grandparent = dirname(parent);
233
- assertNoSymlink(grandparent, "Odai memory parent");
234
- assertNoSymlink(parent, "Odai memory directory");
235
- assertNoSymlink(storePath, "Odai memory store");
236
- if (existsSync(parent) && !lstatSync(parent).isDirectory()) {
237
- throw new Error(`Odai memory directory is not a directory: ${parent}`);
238
- }
239
- if (existsSync(storePath) && !lstatSync(storePath).isFile()) {
240
- throw new Error(`Odai memory store is not a regular file: ${storePath}`);
241
- }
242
- return [grandparent, parent]
243
- .filter((path) => existsSync(path))
244
- .map((path) => {
245
- const stat = lstatSync(path);
246
- return Object.freeze({ path, dev: stat.dev, ino: stat.ino });
247
- });
248
- }
249
-
250
- function assertDirectoryIdentities(identities) {
251
- for (const identity of identities) {
252
- const stat = lstatSync(identity.path);
253
- if (stat.isSymbolicLink() || !stat.isDirectory() || stat.dev !== identity.dev || stat.ino !== identity.ino) {
254
- throw new Error(`Odai memory directory identity changed during an operation: ${identity.path}`);
255
- }
256
- }
257
- }
258
-
259
- function readRegularFileNoFollow(path) {
260
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
261
- let fd;
262
- try {
263
- fd = openSync(path, constants.O_RDONLY | noFollow);
264
- const stat = fstatSync(fd);
265
- if (!stat.isFile()) throw new Error(`Odai memory store is not a regular file: ${path}`);
266
- return readFileSync(fd, "utf8");
267
- } finally {
268
- if (fd !== undefined) closeSync(fd);
269
- }
270
- }
271
-
272
- export function resolveMemoryStorePath(configuredPath, env = process.env) {
273
- if (configuredPath !== undefined) {
274
- if (typeof configuredPath !== "string" || configuredPath.trim() === "") {
275
- throw new TypeError("config.memory.storePath must be a non-empty string");
276
- }
277
- return resolve(configuredPath.trim());
278
- }
279
- const dshHome = typeof env.DSH_HOME === "string" && env.DSH_HOME.trim() !== ""
280
- ? resolve(env.DSH_HOME.trim())
281
- : resolve(homedir(), ".dsh");
282
- return resolve(dshHome, "odai", "memory", "store.json");
283
- }
284
-
285
- export function readMemoryStore(storePath) {
286
- const identities = assertSafeStorePath(storePath);
287
- let text;
288
- try {
289
- text = readRegularFileNoFollow(storePath);
290
- } catch (error) {
291
- assertDirectoryIdentities(identities);
292
- if (error?.code === "ENOENT") return emptyStore();
293
- throw new Error(`cannot read Odai semantic memory ${storePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
294
- }
295
- assertDirectoryIdentities(identities);
296
- let parsed;
297
- try {
298
- parsed = JSON.parse(text);
299
- } catch (error) {
300
- throw new MemoryStoreValidationError(`Odai semantic memory ${storePath} is not valid JSON`, { cause: error });
301
- }
302
- try {
303
- return validateStore(parsed, `Odai semantic memory ${storePath}`);
304
- } catch (error) {
305
- if (error instanceof MemoryStoreValidationError) throw error;
306
- throw new MemoryStoreValidationError(
307
- `Odai semantic memory ${storePath} failed validation: ${error instanceof Error ? error.message : String(error)}`,
308
- { cause: error },
309
- );
310
- }
311
- }
312
-
313
- function writeMemoryStore(storePath, store) {
314
- const value = validateStore(store, "Odai semantic memory write");
315
- const parent = dirname(storePath);
316
- assertSafeStorePath(storePath);
317
- mkdirSync(parent, { recursive: true, mode: 0o700 });
318
- const identities = assertSafeStorePath(storePath);
319
- const temporary = `${storePath}.tmp-${process.pid}-${randomUUID()}`;
320
- const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
321
- try {
322
- let fd;
323
- try {
324
- fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
325
- writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8" });
326
- fsyncSync(fd);
327
- } finally {
328
- if (fd !== undefined) closeSync(fd);
329
- }
330
- assertSafeStorePath(storePath);
331
- assertDirectoryIdentities(identities);
332
- renameSync(temporary, storePath);
333
- assertDirectoryIdentities(identities);
334
- } finally {
335
- rmSync(temporary, { force: true });
336
- }
337
- return value;
338
- }
339
-
340
- export function mutateMemoryStore(storePath, mutate) {
341
- if (typeof mutate !== "function") throw new TypeError("memory mutator must be a function");
342
- const identities = assertSafeStorePath(storePath);
343
- const releaseLock = acquireOwnedStoreLock(storePath, "Odai semantic memory");
344
- try {
345
- assertDirectoryIdentities(identities);
346
- const current = readMemoryStore(storePath);
347
- const mutable = {
348
- schemaVersion: MEMORY_STORE_SCHEMA_VERSION,
349
- settings: { ...current.settings },
350
- entries: current.entries.map((entry) => ({
351
- ...entry,
352
- scope: { ...entry.scope },
353
- supersedes: [...entry.supersedes],
354
- conflictsWith: [...entry.conflictsWith],
355
- provenance: entry.provenance.map((source) => ({ ...source })),
356
- })),
357
- };
358
- const outcome = mutate(mutable);
359
- if (!outcome || outcome.changed !== true) return Object.freeze({ store: current, ...(outcome ?? {}) });
360
- const store = writeMemoryStore(storePath, mutable);
361
- return Object.freeze({ ...outcome, changed: true, store });
362
- } finally {
363
- releaseLock();
364
- }
365
- }
366
-
367
- export function resetMemoryStore(storePath) {
368
- const identities = assertSafeStorePath(storePath);
369
- const releaseLock = acquireOwnedStoreLock(storePath, "Odai semantic memory");
370
- try {
371
- assertSafeStorePath(storePath);
372
- assertDirectoryIdentities(identities);
373
- rmSync(storePath, { force: true });
374
- assertDirectoryIdentities(identities);
375
- return writeMemoryStore(storePath, {
376
- schemaVersion: MEMORY_STORE_SCHEMA_VERSION,
377
- settings: {},
378
- entries: [],
379
- });
380
- } finally {
381
- releaseLock();
382
- }
383
- }
384
-
385
- export function effectiveMemorySettings(storePath, configured = {}) {
386
- const configuredMode = validateMode(configured.mode ?? DEFAULT_MEMORY_SETTINGS.mode, "config.memory.mode");
387
- const store = readMemoryStore(storePath);
388
- if (configuredMode === "off") return Object.freeze({ mode: "off", source: "deployment-config" });
389
- return Object.freeze({
390
- mode: store.settings.mode ?? configuredMode,
391
- source: store.settings.mode === undefined ? "deployment-default" : "persisted",
392
- });
393
- }
394
-
395
- export function globalMemoryScope() {
396
- return Object.freeze({ kind: "global", key: "global", label: "global" });
397
- }
398
-
399
- export function projectMemoryScope(cwd) {
400
- if (typeof cwd !== "string" || cwd.trim() === "") return undefined;
401
- let canonical;
402
- try {
403
- canonical = realpathSync.native(resolve(cwd.trim()));
404
- if (!lstatSync(canonical).isDirectory()) return undefined;
405
- } catch {
406
- return undefined;
407
- }
408
- return Object.freeze({
409
- kind: "project",
410
- key: hash(canonical),
411
- label: basename(canonical) || "project",
412
- });
413
- }
414
-
415
- function eventSeqFor(agent, messageId) {
416
- const events = agent?.session?.events;
417
- if (!Array.isArray(events)) return undefined;
418
- for (let index = events.length - 1; index >= 0; index -= 1) {
419
- const event = events[index];
420
- if (event?.type === "user/message" && event.data?.id === messageId && Number.isSafeInteger(event.seq)) return event.seq;
421
- }
422
- return undefined;
423
- }
424
-
425
- export function directUserProvenance({ agent, message, turn, excerpt, extraction, now = Date.now() }) {
426
- const sessionId = agent?.session?.header?.id;
427
- const messageId = message?.id;
428
- if (typeof sessionId !== "string" || sessionId === "" || typeof messageId !== "string" || messageId === "") {
429
- return undefined;
430
- }
431
- if (!Number.isSafeInteger(turn) || turn < 0) return undefined;
432
- const eventSeq = eventSeqFor(agent, messageId);
433
- return Object.freeze({
434
- sourceKind: "direct-user",
435
- sessionHash: hash(sessionId),
436
- messageHash: hash(messageId),
437
- turn,
438
- ...(eventSeq === undefined ? {} : { eventSeq }),
439
- excerptSha256: hash(excerpt),
440
- observedAt: now,
441
- extraction,
442
- extractorVersion: MEMORY_EXTRACTOR_VERSION,
443
- });
444
- }
445
-
446
- export function memoryRecordId(scope, category, subject, value) {
447
- return `mem-${hash(JSON.stringify([scope.kind, scope.key, category, subject, value])).slice(0, 32)}`;
448
- }
449
-
450
- export function memoryValueDigest(value) {
451
- return hash(value);
452
- }
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ constants,
5
+ existsSync,
6
+ fstatSync,
7
+ fsyncSync,
8
+ lstatSync,
9
+ mkdirSync,
10
+ openSync,
11
+ readFileSync,
12
+ realpathSync,
13
+ renameSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { basename, dirname, resolve } from "node:path";
19
+
20
+ import { acquireOwnedStoreLock } from "./store-lock.mjs";
21
+
22
+ export const MEMORY_STORE_SCHEMA_VERSION = 1;
23
+ export const MEMORY_EXTRACTOR_VERSION = 1;
24
+ export const MEMORY_MODES = Object.freeze(["auto", "off"]);
25
+ export const MEMORY_CATEGORIES = Object.freeze(["preference", "decision", "constraint", "fact"]);
26
+ export const MEMORY_STATUSES = Object.freeze(["pending", "active", "superseded"]);
27
+ export const DEFAULT_MEMORY_SETTINGS = Object.freeze({ mode: "auto" });
28
+ export const MAX_MEMORY_ENTRIES = 1_000;
29
+ export const MAX_MEMORY_PROVENANCE = 8;
30
+ export const MAX_MEMORY_VALUE_CHARS = 600;
31
+
32
+ const STORE_FIELDS = new Set(["schemaVersion", "settings", "entries"]);
33
+ const SETTINGS_FIELDS = new Set(["mode"]);
34
+ const ENTRY_FIELDS = new Set([
35
+ "id",
36
+ "scope",
37
+ "category",
38
+ "subject",
39
+ "value",
40
+ "status",
41
+ "confidence",
42
+ "supersedes",
43
+ "conflictsWith",
44
+ "provenance",
45
+ "createdAt",
46
+ "updatedAt",
47
+ "occurrences",
48
+ ]);
49
+ const SCOPE_FIELDS = new Set(["kind", "key", "label"]);
50
+ const PROVENANCE_FIELDS = new Set([
51
+ "sourceKind",
52
+ "sessionHash",
53
+ "messageHash",
54
+ "turn",
55
+ "eventSeq",
56
+ "excerptSha256",
57
+ "observedAt",
58
+ "extraction",
59
+ "extractorVersion",
60
+ ]);
61
+
62
+ export class MemoryStoreValidationError extends Error {}
63
+
64
+ function hash(value) {
65
+ return createHash("sha256").update(value).digest("hex");
66
+ }
67
+
68
+ function assertPlainObject(value, field) {
69
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
70
+ throw new TypeError(`${field} must be an object`);
71
+ }
72
+ return value;
73
+ }
74
+
75
+ function rejectUnknown(value, allowed, field) {
76
+ const unknown = Object.keys(value).filter((name) => !allowed.has(name));
77
+ if (unknown.length > 0) throw new TypeError(`${field} has unknown fields: ${unknown.join(", ")}`);
78
+ }
79
+
80
+ function requiredString(value, field, max = 1_000) {
81
+ if (typeof value !== "string" || value.length === 0 || value.length > max) {
82
+ throw new TypeError(`${field} must be a non-empty string of at most ${max} characters`);
83
+ }
84
+ return value;
85
+ }
86
+
87
+ function safeInteger(value, field) {
88
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${field} must be a non-negative integer`);
89
+ return value;
90
+ }
91
+
92
+ function validateMode(mode, field = "memory mode") {
93
+ if (!MEMORY_MODES.includes(mode)) throw new TypeError(`${field} must be auto or off`);
94
+ return mode;
95
+ }
96
+
97
+ function validateScope(value, field) {
98
+ const scope = assertPlainObject(value, field);
99
+ rejectUnknown(scope, SCOPE_FIELDS, field);
100
+ if (!["global", "project"].includes(scope.kind)) throw new TypeError(`${field}.kind must be global or project`);
101
+ requiredString(scope.key, `${field}.key`, 64);
102
+ if (scope.kind === "global" && scope.key !== "global") throw new TypeError(`${field}.key must be global for global scope`);
103
+ if (scope.kind === "project" && !/^[a-f0-9]{64}$/u.test(scope.key)) {
104
+ throw new TypeError(`${field}.key must be a sha256 project key`);
105
+ }
106
+ if (scope.label !== undefined) requiredString(scope.label, `${field}.label`, 120);
107
+ return Object.freeze({
108
+ kind: scope.kind,
109
+ key: scope.key,
110
+ ...(scope.label === undefined ? {} : { label: scope.label }),
111
+ });
112
+ }
113
+
114
+ function validateProvenance(value, field) {
115
+ const provenance = assertPlainObject(value, field);
116
+ rejectUnknown(provenance, PROVENANCE_FIELDS, field);
117
+ if (provenance.sourceKind !== "direct-user") throw new TypeError(`${field}.sourceKind must be direct-user`);
118
+ if (!/^[a-f0-9]{64}$/u.test(provenance.sessionHash)) throw new TypeError(`${field}.sessionHash must be sha256`);
119
+ if (!/^[a-f0-9]{64}$/u.test(provenance.messageHash)) throw new TypeError(`${field}.messageHash must be sha256`);
120
+ if (!/^[a-f0-9]{64}$/u.test(provenance.excerptSha256)) throw new TypeError(`${field}.excerptSha256 must be sha256`);
121
+ safeInteger(provenance.turn, `${field}.turn`);
122
+ if (provenance.eventSeq !== undefined) safeInteger(provenance.eventSeq, `${field}.eventSeq`);
123
+ safeInteger(provenance.observedAt, `${field}.observedAt`);
124
+ if (!["local-explicit", "tool-exact-excerpt"].includes(provenance.extraction)) {
125
+ throw new TypeError(`${field}.extraction is unsupported`);
126
+ }
127
+ if (provenance.extractorVersion !== MEMORY_EXTRACTOR_VERSION) {
128
+ throw new TypeError(`${field}.extractorVersion is unsupported`);
129
+ }
130
+ return Object.freeze({
131
+ sourceKind: "direct-user",
132
+ sessionHash: provenance.sessionHash,
133
+ messageHash: provenance.messageHash,
134
+ turn: provenance.turn,
135
+ ...(provenance.eventSeq === undefined ? {} : { eventSeq: provenance.eventSeq }),
136
+ excerptSha256: provenance.excerptSha256,
137
+ observedAt: provenance.observedAt,
138
+ extraction: provenance.extraction,
139
+ extractorVersion: provenance.extractorVersion,
140
+ });
141
+ }
142
+
143
+ function validateEntry(value, field) {
144
+ const entry = assertPlainObject(value, field);
145
+ rejectUnknown(entry, ENTRY_FIELDS, field);
146
+ requiredString(entry.id, `${field}.id`, 80);
147
+ const scope = validateScope(entry.scope, `${field}.scope`);
148
+ if (!MEMORY_CATEGORIES.includes(entry.category)) throw new TypeError(`${field}.category is unsupported`);
149
+ requiredString(entry.subject, `${field}.subject`, 64);
150
+ requiredString(entry.value, `${field}.value`, MAX_MEMORY_VALUE_CHARS);
151
+ if (!MEMORY_STATUSES.includes(entry.status)) throw new TypeError(`${field}.status is unsupported`);
152
+ if (!["high", "medium"].includes(entry.confidence)) throw new TypeError(`${field}.confidence is unsupported`);
153
+ if (!Array.isArray(entry.supersedes) || entry.supersedes.length > 64) {
154
+ throw new TypeError(`${field}.supersedes must be an array with at most 64 entries`);
155
+ }
156
+ const supersedes = entry.supersedes.map((id, index) => requiredString(id, `${field}.supersedes[${index}]`, 80));
157
+ if (!Array.isArray(entry.conflictsWith) || entry.conflictsWith.length > 64) {
158
+ throw new TypeError(`${field}.conflictsWith must be an array with at most 64 entries`);
159
+ }
160
+ const conflictsWith = entry.conflictsWith.map((id, index) => requiredString(id, `${field}.conflictsWith[${index}]`, 80));
161
+ if (!Array.isArray(entry.provenance)
162
+ || entry.provenance.length === 0
163
+ || entry.provenance.length > MAX_MEMORY_PROVENANCE) {
164
+ throw new TypeError(`${field}.provenance must contain 1-${MAX_MEMORY_PROVENANCE} entries`);
165
+ }
166
+ const provenance = entry.provenance.map((item, index) => validateProvenance(item, `${field}.provenance[${index}]`));
167
+ const createdAt = safeInteger(entry.createdAt, `${field}.createdAt`);
168
+ const updatedAt = safeInteger(entry.updatedAt, `${field}.updatedAt`);
169
+ if (updatedAt < createdAt) throw new TypeError(`${field}.updatedAt must not precede createdAt`);
170
+ const occurrences = safeInteger(entry.occurrences, `${field}.occurrences`);
171
+ if (occurrences < 1) throw new TypeError(`${field}.occurrences must be positive`);
172
+ return Object.freeze({
173
+ id: entry.id,
174
+ scope,
175
+ category: entry.category,
176
+ subject: entry.subject,
177
+ value: entry.value,
178
+ status: entry.status,
179
+ confidence: entry.confidence,
180
+ supersedes: Object.freeze(supersedes),
181
+ conflictsWith: Object.freeze(conflictsWith),
182
+ provenance: Object.freeze(provenance),
183
+ createdAt,
184
+ updatedAt,
185
+ occurrences,
186
+ });
187
+ }
188
+
189
+ function validateStore(value, field) {
190
+ const store = assertPlainObject(value, field);
191
+ rejectUnknown(store, STORE_FIELDS, field);
192
+ if (store.schemaVersion !== MEMORY_STORE_SCHEMA_VERSION) {
193
+ throw new TypeError(`${field} has unsupported schemaVersion ${String(store.schemaVersion)}`);
194
+ }
195
+ const settingsValue = store.settings === undefined ? {} : assertPlainObject(store.settings, `${field}.settings`);
196
+ rejectUnknown(settingsValue, SETTINGS_FIELDS, `${field}.settings`);
197
+ const settings = Object.freeze({
198
+ ...(settingsValue.mode === undefined ? {} : { mode: validateMode(settingsValue.mode, `${field}.settings.mode`) }),
199
+ });
200
+ if (!Array.isArray(store.entries) || store.entries.length > MAX_MEMORY_ENTRIES) {
201
+ throw new TypeError(`${field}.entries must be an array with at most ${MAX_MEMORY_ENTRIES} records`);
202
+ }
203
+ const entries = store.entries.map((entry, index) => validateEntry(entry, `${field}.entries[${index}]`));
204
+ const ids = new Set();
205
+ for (const entry of entries) {
206
+ if (ids.has(entry.id)) throw new TypeError(`${field}.entries contains duplicate id ${entry.id}`);
207
+ ids.add(entry.id);
208
+ }
209
+ return Object.freeze({
210
+ schemaVersion: MEMORY_STORE_SCHEMA_VERSION,
211
+ settings,
212
+ entries: Object.freeze(entries),
213
+ });
214
+ }
215
+
216
+ function emptyStore() {
217
+ return Object.freeze({
218
+ schemaVersion: MEMORY_STORE_SCHEMA_VERSION,
219
+ settings: Object.freeze({}),
220
+ entries: Object.freeze([]),
221
+ });
222
+ }
223
+
224
+ function assertNoSymlink(path, label) {
225
+ if (!existsSync(path)) return;
226
+ const stat = lstatSync(path);
227
+ if (stat.isSymbolicLink()) throw new Error(`${label} must not be a symbolic link: ${path}`);
228
+ }
229
+
230
+ function assertSafeStorePath(storePath) {
231
+ const parent = dirname(storePath);
232
+ const grandparent = dirname(parent);
233
+ assertNoSymlink(grandparent, "Odai memory parent");
234
+ assertNoSymlink(parent, "Odai memory directory");
235
+ assertNoSymlink(storePath, "Odai memory store");
236
+ if (existsSync(parent) && !lstatSync(parent).isDirectory()) {
237
+ throw new Error(`Odai memory directory is not a directory: ${parent}`);
238
+ }
239
+ if (existsSync(storePath) && !lstatSync(storePath).isFile()) {
240
+ throw new Error(`Odai memory store is not a regular file: ${storePath}`);
241
+ }
242
+ return [grandparent, parent]
243
+ .filter((path) => existsSync(path))
244
+ .map((path) => {
245
+ const stat = lstatSync(path);
246
+ return Object.freeze({ path, dev: stat.dev, ino: stat.ino });
247
+ });
248
+ }
249
+
250
+ function assertDirectoryIdentities(identities) {
251
+ for (const identity of identities) {
252
+ const stat = lstatSync(identity.path);
253
+ if (stat.isSymbolicLink() || !stat.isDirectory() || stat.dev !== identity.dev || stat.ino !== identity.ino) {
254
+ throw new Error(`Odai memory directory identity changed during an operation: ${identity.path}`);
255
+ }
256
+ }
257
+ }
258
+
259
+ function readRegularFileNoFollow(path) {
260
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
261
+ let fd;
262
+ try {
263
+ fd = openSync(path, constants.O_RDONLY | noFollow);
264
+ const stat = fstatSync(fd);
265
+ if (!stat.isFile()) throw new Error(`Odai memory store is not a regular file: ${path}`);
266
+ return readFileSync(fd, "utf8");
267
+ } finally {
268
+ if (fd !== undefined) closeSync(fd);
269
+ }
270
+ }
271
+
272
+ export function resolveMemoryStorePath(configuredPath, env = process.env) {
273
+ if (configuredPath !== undefined) {
274
+ if (typeof configuredPath !== "string" || configuredPath.trim() === "") {
275
+ throw new TypeError("config.memory.storePath must be a non-empty string");
276
+ }
277
+ return resolve(configuredPath.trim());
278
+ }
279
+ const dshHome = typeof env.DSH_HOME === "string" && env.DSH_HOME.trim() !== ""
280
+ ? resolve(env.DSH_HOME.trim())
281
+ : resolve(homedir(), ".dsh");
282
+ return resolve(dshHome, "odai", "memory", "store.json");
283
+ }
284
+
285
+ export function readMemoryStore(storePath) {
286
+ const identities = assertSafeStorePath(storePath);
287
+ let text;
288
+ try {
289
+ text = readRegularFileNoFollow(storePath);
290
+ } catch (error) {
291
+ assertDirectoryIdentities(identities);
292
+ if (error?.code === "ENOENT") return emptyStore();
293
+ throw new Error(`cannot read Odai semantic memory ${storePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
294
+ }
295
+ assertDirectoryIdentities(identities);
296
+ let parsed;
297
+ try {
298
+ parsed = JSON.parse(text);
299
+ } catch (error) {
300
+ throw new MemoryStoreValidationError(`Odai semantic memory ${storePath} is not valid JSON`, { cause: error });
301
+ }
302
+ try {
303
+ return validateStore(parsed, `Odai semantic memory ${storePath}`);
304
+ } catch (error) {
305
+ if (error instanceof MemoryStoreValidationError) throw error;
306
+ throw new MemoryStoreValidationError(
307
+ `Odai semantic memory ${storePath} failed validation: ${error instanceof Error ? error.message : String(error)}`,
308
+ { cause: error },
309
+ );
310
+ }
311
+ }
312
+
313
+ function writeMemoryStore(storePath, store) {
314
+ const value = validateStore(store, "Odai semantic memory write");
315
+ const parent = dirname(storePath);
316
+ assertSafeStorePath(storePath);
317
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
318
+ const identities = assertSafeStorePath(storePath);
319
+ const temporary = `${storePath}.tmp-${process.pid}-${randomUUID()}`;
320
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
321
+ try {
322
+ let fd;
323
+ try {
324
+ fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
325
+ writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8" });
326
+ fsyncSync(fd);
327
+ } finally {
328
+ if (fd !== undefined) closeSync(fd);
329
+ }
330
+ assertSafeStorePath(storePath);
331
+ assertDirectoryIdentities(identities);
332
+ renameSync(temporary, storePath);
333
+ assertDirectoryIdentities(identities);
334
+ } finally {
335
+ rmSync(temporary, { force: true });
336
+ }
337
+ return value;
338
+ }
339
+
340
+ export function mutateMemoryStore(storePath, mutate) {
341
+ if (typeof mutate !== "function") throw new TypeError("memory mutator must be a function");
342
+ const identities = assertSafeStorePath(storePath);
343
+ const releaseLock = acquireOwnedStoreLock(storePath, "Odai semantic memory");
344
+ try {
345
+ assertDirectoryIdentities(identities);
346
+ const current = readMemoryStore(storePath);
347
+ const mutable = {
348
+ schemaVersion: MEMORY_STORE_SCHEMA_VERSION,
349
+ settings: { ...current.settings },
350
+ entries: current.entries.map((entry) => ({
351
+ ...entry,
352
+ scope: { ...entry.scope },
353
+ supersedes: [...entry.supersedes],
354
+ conflictsWith: [...entry.conflictsWith],
355
+ provenance: entry.provenance.map((source) => ({ ...source })),
356
+ })),
357
+ };
358
+ const outcome = mutate(mutable);
359
+ if (!outcome || outcome.changed !== true) return Object.freeze({ store: current, ...(outcome ?? {}) });
360
+ const store = writeMemoryStore(storePath, mutable);
361
+ return Object.freeze({ ...outcome, changed: true, store });
362
+ } finally {
363
+ releaseLock();
364
+ }
365
+ }
366
+
367
+ export function resetMemoryStore(storePath) {
368
+ const identities = assertSafeStorePath(storePath);
369
+ const releaseLock = acquireOwnedStoreLock(storePath, "Odai semantic memory");
370
+ try {
371
+ assertSafeStorePath(storePath);
372
+ assertDirectoryIdentities(identities);
373
+ rmSync(storePath, { force: true });
374
+ assertDirectoryIdentities(identities);
375
+ return writeMemoryStore(storePath, {
376
+ schemaVersion: MEMORY_STORE_SCHEMA_VERSION,
377
+ settings: {},
378
+ entries: [],
379
+ });
380
+ } finally {
381
+ releaseLock();
382
+ }
383
+ }
384
+
385
+ export function effectiveMemorySettings(storePath, configured = {}) {
386
+ const configuredMode = validateMode(configured.mode ?? DEFAULT_MEMORY_SETTINGS.mode, "config.memory.mode");
387
+ const store = readMemoryStore(storePath);
388
+ if (configuredMode === "off") return Object.freeze({ mode: "off", source: "deployment-config" });
389
+ return Object.freeze({
390
+ mode: store.settings.mode ?? configuredMode,
391
+ source: store.settings.mode === undefined ? "deployment-default" : "persisted",
392
+ });
393
+ }
394
+
395
+ export function globalMemoryScope() {
396
+ return Object.freeze({ kind: "global", key: "global", label: "global" });
397
+ }
398
+
399
+ export function projectMemoryScope(cwd) {
400
+ if (typeof cwd !== "string" || cwd.trim() === "") return undefined;
401
+ let canonical;
402
+ try {
403
+ canonical = realpathSync.native(resolve(cwd.trim()));
404
+ if (!lstatSync(canonical).isDirectory()) return undefined;
405
+ } catch {
406
+ return undefined;
407
+ }
408
+ return Object.freeze({
409
+ kind: "project",
410
+ key: hash(canonical),
411
+ label: basename(canonical) || "project",
412
+ });
413
+ }
414
+
415
+ function eventSeqFor(agent, messageId) {
416
+ const events = agent?.session?.events;
417
+ if (!Array.isArray(events)) return undefined;
418
+ for (let index = events.length - 1; index >= 0; index -= 1) {
419
+ const event = events[index];
420
+ if (event?.type === "user/message" && event.data?.id === messageId && Number.isSafeInteger(event.seq)) return event.seq;
421
+ }
422
+ return undefined;
423
+ }
424
+
425
+ export function directUserProvenance({ agent, message, turn, excerpt, extraction, now = Date.now() }) {
426
+ const sessionId = agent?.session?.header?.id;
427
+ const messageId = message?.id;
428
+ if (typeof sessionId !== "string" || sessionId === "" || typeof messageId !== "string" || messageId === "") {
429
+ return undefined;
430
+ }
431
+ if (!Number.isSafeInteger(turn) || turn < 0) return undefined;
432
+ const eventSeq = eventSeqFor(agent, messageId);
433
+ return Object.freeze({
434
+ sourceKind: "direct-user",
435
+ sessionHash: hash(sessionId),
436
+ messageHash: hash(messageId),
437
+ turn,
438
+ ...(eventSeq === undefined ? {} : { eventSeq }),
439
+ excerptSha256: hash(excerpt),
440
+ observedAt: now,
441
+ extraction,
442
+ extractorVersion: MEMORY_EXTRACTOR_VERSION,
443
+ });
444
+ }
445
+
446
+ export function memoryRecordId(scope, category, subject, value) {
447
+ return `mem-${hash(JSON.stringify([scope.kind, scope.key, category, subject, value])).slice(0, 32)}`;
448
+ }
449
+
450
+ export function memoryValueDigest(value) {
451
+ return hash(value);
452
+ }