@zenera/neo 1.1.24 → 1.1.26
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/dist/agent.js +2 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/inspect/client.d.ts +1 -1
- package/dist/inspect/client.js +82 -11
- package/dist/inspect/css.d.ts +1 -1
- package/dist/inspect/css.js +1 -0
- package/dist/inspect/index.d.ts +1 -1
- package/dist/inspect/report.d.ts +9 -0
- package/dist/inspect/report.js +1 -0
- package/dist/lock.d.ts +27 -0
- package/dist/lock.js +66 -0
- package/dist/match.d.ts +44 -0
- package/dist/match.js +100 -0
- package/dist/memory/diff.d.ts +39 -0
- package/dist/memory/diff.js +95 -0
- package/dist/memory/files.d.ts +1 -1
- package/dist/memory/grep.d.ts +1 -1
- package/dist/memory/grep.js +2 -2
- package/dist/memory/instructions.d.ts +15 -0
- package/dist/memory/instructions.js +103 -0
- package/dist/memory/merge.js +1 -31
- package/dist/memory/store.d.ts +1 -5
- package/dist/memory/store.js +7 -53
- package/dist/memory/twin.d.ts +6 -0
- package/dist/memory/twin.js +40 -0
- package/dist/memory-stores/factory.d.ts +15 -0
- package/dist/memory-stores/factory.js +29 -0
- package/dist/memory-stores/file.d.ts +37 -0
- package/dist/memory-stores/file.js +174 -0
- package/dist/memory-stores/in-memory.d.ts +17 -0
- package/dist/memory-stores/in-memory.js +80 -0
- package/dist/memory-stores/index.d.ts +4 -0
- package/dist/memory-stores/index.js +5 -0
- package/dist/memory.d.ts +115 -0
- package/dist/memory.js +213 -0
- package/dist/models/factory.d.ts +24 -0
- package/dist/models/factory.js +153 -14
- package/dist/project/config.d.ts +9 -9
- package/dist/runner.js +7 -0
- package/dist/tools/sandbox.js +40 -2
- package/package.json +1 -1
package/dist/memory/merge.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join, resolve } from 'node:path';
|
|
|
4
4
|
import { hostPath } from "./files.js";
|
|
5
5
|
import { DUPLICATE_SCORE, flatten } from "./index.js";
|
|
6
6
|
import { MANIFEST_FILE, MemoryStore, lockHolder } from "./store.js";
|
|
7
|
+
import { comparable, sameContent, twinKey } from "./twin.js";
|
|
7
8
|
import { MemoryError } from "./types.js";
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
9
10
|
// Folding several memories into one
|
|
@@ -251,15 +252,6 @@ function twinOf(target, src, node, lexical, stale) {
|
|
|
251
252
|
const id = lexical.get(twinKey(node));
|
|
252
253
|
return id && !stale.has(id) ? id : undefined;
|
|
253
254
|
}
|
|
254
|
-
function comparable(a, b) {
|
|
255
|
-
return a.id !== b.id && a.kind === b.kind && !a.file && audienceKey(a) === audienceKey(b);
|
|
256
|
-
}
|
|
257
|
-
function twinKey(node) {
|
|
258
|
-
return [node.kind, audienceKey(node), flatten(node.text)].join('\u0000');
|
|
259
|
-
}
|
|
260
|
-
function audienceKey(node) {
|
|
261
|
-
return [...node.audience].sort().join('|');
|
|
262
|
-
}
|
|
263
255
|
function carryVector(from, to, id) {
|
|
264
256
|
const vector = from.vectors?.get(id);
|
|
265
257
|
const block = to.vectors;
|
|
@@ -284,28 +276,6 @@ function reconcile(keep, other) {
|
|
|
284
276
|
function newer(a, b) {
|
|
285
277
|
return a.revision === b.revision ? a.updatedAt > b.updatedAt : a.revision > b.revision;
|
|
286
278
|
}
|
|
287
|
-
function sameContent(a, b) {
|
|
288
|
-
return (a.kind === b.kind &&
|
|
289
|
-
a.text === b.text &&
|
|
290
|
-
audienceKey(a) === audienceKey(b) &&
|
|
291
|
-
a.file?.sha256 === b.file?.sha256 &&
|
|
292
|
-
deep(a.metadata, b.metadata));
|
|
293
|
-
}
|
|
294
|
-
function deep(a, b) {
|
|
295
|
-
if (a === b) {
|
|
296
|
-
return true;
|
|
297
|
-
}
|
|
298
|
-
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
|
|
299
|
-
return false;
|
|
300
|
-
}
|
|
301
|
-
if (Array.isArray(a) !== Array.isArray(b)) {
|
|
302
|
-
return false;
|
|
303
|
-
}
|
|
304
|
-
const left = a;
|
|
305
|
-
const right = b;
|
|
306
|
-
const keys = Object.keys(left);
|
|
307
|
-
return keys.length === Object.keys(right).length && keys.every((k) => deep(left[k], right[k]));
|
|
308
|
-
}
|
|
309
279
|
/** Stat every file the merge would copy before it copies any of them. */
|
|
310
280
|
async function checkFiles(plan) {
|
|
311
281
|
for (const planned of plan.values()) {
|
package/dist/memory/store.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Lock } from '../lock.ts';
|
|
1
2
|
import { MemoryGraph } from './graph.ts';
|
|
2
3
|
import { VectorBlock } from './vectors.ts';
|
|
3
4
|
export declare const MEMORY_VERSION = 1;
|
|
@@ -19,11 +20,6 @@ export interface MemoryManifest {
|
|
|
19
20
|
edges: number;
|
|
20
21
|
updatedAt: string;
|
|
21
22
|
}
|
|
22
|
-
export interface Lock {
|
|
23
|
-
pid: number;
|
|
24
|
-
host: string;
|
|
25
|
-
startedAt: string;
|
|
26
|
-
}
|
|
27
23
|
export interface OpenOptions {
|
|
28
24
|
/** the embedder the caller intends to use; a mismatch with the manifest is fatal */
|
|
29
25
|
embedding?: MemoryEmbedding;
|
package/dist/memory/store.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { unlinkSync } from 'node:fs';
|
|
2
2
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { hostname } from 'node:os';
|
|
4
3
|
import { join } from 'node:path';
|
|
4
|
+
import { claimLock, liveHolder, ownLock } from "../lock.js";
|
|
5
5
|
import { MemoryGraph } from "./graph.js";
|
|
6
6
|
import { MemoryError } from "./types.js";
|
|
7
7
|
import { VectorBlock } from "./vectors.js";
|
|
@@ -115,12 +115,13 @@ export class MemoryStore {
|
|
|
115
115
|
if (this.readOnly) {
|
|
116
116
|
throw new MemoryError('this memory is open read-only', 'nothing this run learns can be kept — point it at a memory of its own');
|
|
117
117
|
}
|
|
118
|
-
|
|
118
|
+
// Indented so a committed memory diffs per node rather than as one line.
|
|
119
|
+
await atomic(join(this.dir, GRAPH_FILE), `${JSON.stringify(this.graph.export(), null, 2)}\n`);
|
|
119
120
|
const vectors = this.#vectors;
|
|
120
121
|
if (vectors) {
|
|
121
122
|
const raw = vectors.bytes();
|
|
122
123
|
await atomic(join(this.dir, VECTORS_FILE), new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength));
|
|
123
|
-
await atomic(join(this.dir, VECTOR_IDS_FILE), JSON.stringify({ dims: vectors.dims, ids: vectors.ids }));
|
|
124
|
+
await atomic(join(this.dir, VECTOR_IDS_FILE), `${JSON.stringify({ dims: vectors.dims, ids: vectors.ids }, null, 2)}\n`);
|
|
124
125
|
}
|
|
125
126
|
const manifest = {
|
|
126
127
|
version: MEMORY_VERSION,
|
|
@@ -143,33 +144,8 @@ export class MemoryStore {
|
|
|
143
144
|
// Already gone, or taken over as stale: neither is worth failing a run for.
|
|
144
145
|
}
|
|
145
146
|
}
|
|
146
|
-
/**
|
|
147
|
-
* `wx` makes the create and the check one operation, so two runs racing for
|
|
148
|
-
* the same project cannot both win.
|
|
149
|
-
*/
|
|
150
147
|
#claim() {
|
|
151
|
-
|
|
152
|
-
const lock = {
|
|
153
|
-
pid: process.pid,
|
|
154
|
-
host: hostname(),
|
|
155
|
-
startedAt: new Date().toISOString(),
|
|
156
|
-
};
|
|
157
|
-
const body = `${JSON.stringify(lock, null, 4)}\n`;
|
|
158
|
-
try {
|
|
159
|
-
writeFileSync(path, body, { flag: 'wx' });
|
|
160
|
-
this.#locked = true;
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
catch (err) {
|
|
164
|
-
if (err.code !== 'EEXIST') {
|
|
165
|
-
throw err;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
const held = readLock(path);
|
|
169
|
-
if (held && held.host === hostname() && alive(held.pid)) {
|
|
170
|
-
throw new MemoryError(`this memory is in use (pid ${held.pid}, since ${held.startedAt})`, 'wait for that run to finish, or remove .lock if it crashed');
|
|
171
|
-
}
|
|
172
|
-
writeFileSync(path, body);
|
|
148
|
+
claimLock(join(this.dir, LOCK_FILE), ownLock(), (held) => new MemoryError(`this memory is in use (pid ${held.pid}, since ${held.startedAt})`, 'wait for that run to finish, or remove .lock if it crashed'));
|
|
173
149
|
this.#locked = true;
|
|
174
150
|
}
|
|
175
151
|
}
|
|
@@ -226,28 +202,6 @@ async function atomic(path, body) {
|
|
|
226
202
|
* directory this process has no intention of opening for writing.
|
|
227
203
|
*/
|
|
228
204
|
export function lockHolder(dir) {
|
|
229
|
-
|
|
230
|
-
return held && held.host === hostname() && alive(held.pid) ? held : undefined;
|
|
231
|
-
}
|
|
232
|
-
function readLock(path) {
|
|
233
|
-
try {
|
|
234
|
-
return JSON.parse(readFileSync(path, 'utf8'));
|
|
235
|
-
}
|
|
236
|
-
catch {
|
|
237
|
-
return undefined;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
/**
|
|
241
|
-
* `kill(pid, 0)` sends no signal and only asks whether the process exists.
|
|
242
|
-
* EPERM means it exists and belongs to someone else, which still counts.
|
|
243
|
-
*/
|
|
244
|
-
function alive(pid) {
|
|
245
|
-
try {
|
|
246
|
-
process.kill(pid, 0);
|
|
247
|
-
return true;
|
|
248
|
-
}
|
|
249
|
-
catch (err) {
|
|
250
|
-
return err.code === 'EPERM';
|
|
251
|
-
}
|
|
205
|
+
return liveHolder(join(dir, LOCK_FILE));
|
|
252
206
|
}
|
|
253
207
|
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { MemoryNode } from './types.ts';
|
|
2
|
+
export declare function comparable(a: MemoryNode, b: MemoryNode): boolean;
|
|
3
|
+
export declare function twinKey(node: MemoryNode): string;
|
|
4
|
+
export declare function audienceKey(node: MemoryNode): string;
|
|
5
|
+
export declare function sameContent(a: MemoryNode, b: MemoryNode): boolean;
|
|
6
|
+
//# sourceMappingURL=twin.d.ts.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { flatten } from "./index.js";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// When two memories are the same memory
|
|
4
|
+
//
|
|
5
|
+
// Shared by `merge`, which folds a twin onto the node already held, and `diff`,
|
|
6
|
+
// which reports it. One rule, so what diff calls a re-commit is exactly what
|
|
7
|
+
// merge would have folded.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
export function comparable(a, b) {
|
|
10
|
+
return a.id !== b.id && a.kind === b.kind && !a.file && audienceKey(a) === audienceKey(b);
|
|
11
|
+
}
|
|
12
|
+
export function twinKey(node) {
|
|
13
|
+
return [node.kind, audienceKey(node), flatten(node.text)].join('\u0000');
|
|
14
|
+
}
|
|
15
|
+
export function audienceKey(node) {
|
|
16
|
+
return [...node.audience].sort().join('|');
|
|
17
|
+
}
|
|
18
|
+
export function sameContent(a, b) {
|
|
19
|
+
return (a.kind === b.kind &&
|
|
20
|
+
a.text === b.text &&
|
|
21
|
+
audienceKey(a) === audienceKey(b) &&
|
|
22
|
+
a.file?.sha256 === b.file?.sha256 &&
|
|
23
|
+
deep(a.metadata, b.metadata));
|
|
24
|
+
}
|
|
25
|
+
function deep(a, b) {
|
|
26
|
+
if (a === b) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(a) !== Array.isArray(b)) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
const left = a;
|
|
36
|
+
const right = b;
|
|
37
|
+
const keys = Object.keys(left);
|
|
38
|
+
return keys.length === Object.keys(right).length && keys.every((k) => deep(left[k], right[k]));
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=twin.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MemoryStore } from '../memory.ts';
|
|
2
|
+
import { type FileMemoryStoreOptions } from './file.ts';
|
|
3
|
+
export interface MemoryMemoryStoreSpec {
|
|
4
|
+
kind: 'memory';
|
|
5
|
+
id?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface FileMemoryStoreSpec extends FileMemoryStoreOptions {
|
|
8
|
+
kind: 'file';
|
|
9
|
+
}
|
|
10
|
+
/** Grows a member per backend (`pgvector`, `redis`, …). */
|
|
11
|
+
export type MemoryStoreSpec = MemoryMemoryStoreSpec | FileMemoryStoreSpec;
|
|
12
|
+
/** Shorthand: `mem`, `memory`, `file:./.data/memory`. */
|
|
13
|
+
export type MemoryStoreRef = MemoryStoreSpec | string;
|
|
14
|
+
export declare function createMemoryStore(ref: MemoryStoreRef): MemoryStore;
|
|
15
|
+
//# sourceMappingURL=factory.d.ts.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { FileMemoryStore } from "./file.js";
|
|
2
|
+
import { InMemoryMemoryStore } from "./in-memory.js";
|
|
3
|
+
export function createMemoryStore(ref) {
|
|
4
|
+
const spec = typeof ref === 'string' ? parseRef(ref) : ref;
|
|
5
|
+
switch (spec.kind) {
|
|
6
|
+
case 'memory':
|
|
7
|
+
return new InMemoryMemoryStore(spec.id);
|
|
8
|
+
case 'file':
|
|
9
|
+
return new FileMemoryStore(spec);
|
|
10
|
+
default:
|
|
11
|
+
throw new TypeError(`unknown memory store kind: ${spec.kind}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function parseRef(ref) {
|
|
15
|
+
if (ref === 'mem' || ref === 'memory') {
|
|
16
|
+
return { kind: 'memory' };
|
|
17
|
+
}
|
|
18
|
+
const colon = ref.indexOf(':');
|
|
19
|
+
const scheme = colon < 0 ? ref : ref.slice(0, colon);
|
|
20
|
+
const rest = colon < 0 ? '' : ref.slice(colon + 1);
|
|
21
|
+
if (scheme === 'file') {
|
|
22
|
+
if (!rest) {
|
|
23
|
+
throw new TypeError(`missing directory in "${ref}" (expected file:<dir>)`);
|
|
24
|
+
}
|
|
25
|
+
return { kind: 'file', dir: rest };
|
|
26
|
+
}
|
|
27
|
+
throw new TypeError(`unknown memory store ref: "${ref}"`);
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=factory.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type MemoryDraft, type MemoryHit, type MemoryPatch, type MemoryQuery, type MemoryRecord, type MemoryStore } from '../memory.ts';
|
|
2
|
+
export interface FileMemoryStoreOptions {
|
|
3
|
+
/** root directory; created on demand */
|
|
4
|
+
dir: string;
|
|
5
|
+
/** logical store id agents bind to; defaults to `file` */
|
|
6
|
+
id?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* One JSON file per record, under one directory per scope:
|
|
10
|
+
*
|
|
11
|
+
* ```
|
|
12
|
+
* <dir>/<scope>/scope.json the unescaped scope name, for humans
|
|
13
|
+
* <dir>/<scope>/records/<id>.json
|
|
14
|
+
* <dir>/<scope>/ops/<opId>.json idempotency ledger
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* A file per record (rather than one appended log) keeps writes atomic and
|
|
18
|
+
* deletes cheap without a compaction step. Search reads the whole scope and
|
|
19
|
+
* ranks in process, which is exactly the trade this backend makes: no server,
|
|
20
|
+
* no index, fine up to a few thousand records per scope.
|
|
21
|
+
*
|
|
22
|
+
* There is no cross-process lock. Concurrent writers to the *same* record can
|
|
23
|
+
* lose an update; `expectedRevision` narrows that window, and a deployment
|
|
24
|
+
* that needs real isolation should use a database-backed store.
|
|
25
|
+
*/
|
|
26
|
+
export declare class FileMemoryStore implements MemoryStore {
|
|
27
|
+
#private;
|
|
28
|
+
readonly id: string;
|
|
29
|
+
constructor(opts: FileMemoryStoreOptions | string);
|
|
30
|
+
get dir(): string;
|
|
31
|
+
search(scope: string, q: MemoryQuery): Promise<MemoryHit[]>;
|
|
32
|
+
get(scope: string, id: string): Promise<MemoryRecord | undefined>;
|
|
33
|
+
write(scope: string, rec: MemoryDraft, opId: string): Promise<MemoryRecord>;
|
|
34
|
+
update(scope: string, id: string, patch: MemoryPatch, opId: string): Promise<MemoryRecord>;
|
|
35
|
+
delete(scope: string, id: string, opId: string): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=file.d.ts.map
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { rankMemories, } from "../memory.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Filesystem-backed memory store
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
/**
|
|
9
|
+
* Record ids and op ids reach this store from model-authored tool arguments
|
|
10
|
+
* (`memory_update`, `memory_delete`), so they are validated before becoming
|
|
11
|
+
* path segments. Dots are excluded outright, which makes `.` and `..`
|
|
12
|
+
* unrepresentable.
|
|
13
|
+
*/
|
|
14
|
+
const SAFE_ID = /^[A-Za-z0-9_-]+$/;
|
|
15
|
+
/**
|
|
16
|
+
* One JSON file per record, under one directory per scope:
|
|
17
|
+
*
|
|
18
|
+
* ```
|
|
19
|
+
* <dir>/<scope>/scope.json the unescaped scope name, for humans
|
|
20
|
+
* <dir>/<scope>/records/<id>.json
|
|
21
|
+
* <dir>/<scope>/ops/<opId>.json idempotency ledger
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* A file per record (rather than one appended log) keeps writes atomic and
|
|
25
|
+
* deletes cheap without a compaction step. Search reads the whole scope and
|
|
26
|
+
* ranks in process, which is exactly the trade this backend makes: no server,
|
|
27
|
+
* no index, fine up to a few thousand records per scope.
|
|
28
|
+
*
|
|
29
|
+
* There is no cross-process lock. Concurrent writers to the *same* record can
|
|
30
|
+
* lose an update; `expectedRevision` narrows that window, and a deployment
|
|
31
|
+
* that needs real isolation should use a database-backed store.
|
|
32
|
+
*/
|
|
33
|
+
export class FileMemoryStore {
|
|
34
|
+
id;
|
|
35
|
+
#dir;
|
|
36
|
+
constructor(opts) {
|
|
37
|
+
const o = typeof opts === 'string' ? { dir: opts } : opts;
|
|
38
|
+
this.#dir = resolve(o.dir);
|
|
39
|
+
this.id = o.id ?? 'file';
|
|
40
|
+
}
|
|
41
|
+
get dir() {
|
|
42
|
+
return this.#dir;
|
|
43
|
+
}
|
|
44
|
+
async search(scope, q) {
|
|
45
|
+
return rankMemories(await this.#all(scope), q);
|
|
46
|
+
}
|
|
47
|
+
async get(scope, id) {
|
|
48
|
+
return this.#readJson(this.#recordPath(scope, id));
|
|
49
|
+
}
|
|
50
|
+
async write(scope, rec, opId) {
|
|
51
|
+
const known = await this.#readOp(scope, opId);
|
|
52
|
+
if (known) {
|
|
53
|
+
const existing = await this.get(scope, known);
|
|
54
|
+
if (existing) {
|
|
55
|
+
return existing;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const now = new Date().toISOString();
|
|
59
|
+
const record = {
|
|
60
|
+
id: randomUUID(),
|
|
61
|
+
scope,
|
|
62
|
+
kind: rec.kind ?? 'fact',
|
|
63
|
+
text: rec.text,
|
|
64
|
+
metadata: rec.metadata,
|
|
65
|
+
createdAt: now,
|
|
66
|
+
updatedAt: now,
|
|
67
|
+
revision: 1,
|
|
68
|
+
};
|
|
69
|
+
await this.#writeRecord(record);
|
|
70
|
+
await this.#writeOp(scope, opId, record.id);
|
|
71
|
+
return record;
|
|
72
|
+
}
|
|
73
|
+
async update(scope, id, patch, opId) {
|
|
74
|
+
const cur = await this.get(scope, id);
|
|
75
|
+
if (!cur) {
|
|
76
|
+
throw new Error(`memory record not found: ${scope}/${id}`);
|
|
77
|
+
}
|
|
78
|
+
if ((await this.#readOp(scope, opId)) === id) {
|
|
79
|
+
return cur;
|
|
80
|
+
}
|
|
81
|
+
if (patch.expectedRevision !== undefined && patch.expectedRevision !== cur.revision) {
|
|
82
|
+
throw new Error(`memory conflict on ${scope}/${id}: expected revision ` +
|
|
83
|
+
`${patch.expectedRevision}, found ${cur.revision}`);
|
|
84
|
+
}
|
|
85
|
+
const next = {
|
|
86
|
+
...cur,
|
|
87
|
+
kind: patch.kind ?? cur.kind,
|
|
88
|
+
text: patch.text ?? cur.text,
|
|
89
|
+
metadata: patch.metadata ?? cur.metadata,
|
|
90
|
+
updatedAt: new Date().toISOString(),
|
|
91
|
+
revision: cur.revision + 1,
|
|
92
|
+
};
|
|
93
|
+
await this.#writeRecord(next);
|
|
94
|
+
await this.#writeOp(scope, opId, id);
|
|
95
|
+
return next;
|
|
96
|
+
}
|
|
97
|
+
async delete(scope, id, opId) {
|
|
98
|
+
await rm(this.#recordPath(scope, id), { force: true });
|
|
99
|
+
await this.#writeOp(scope, opId, id);
|
|
100
|
+
}
|
|
101
|
+
// --- layout -----------------------------------------------------------
|
|
102
|
+
/**
|
|
103
|
+
* Percent-encoding is reversible and, with dots escaped too, cannot
|
|
104
|
+
* produce a traversal segment out of a scope like `../etc`.
|
|
105
|
+
*/
|
|
106
|
+
#scopeDir(scope) {
|
|
107
|
+
return join(this.#dir, encodeURIComponent(scope).replace(/\./g, '%2E'));
|
|
108
|
+
}
|
|
109
|
+
#recordPath(scope, id) {
|
|
110
|
+
return join(this.#scopeDir(scope), 'records', `${safeId(id, 'record id')}.json`);
|
|
111
|
+
}
|
|
112
|
+
#opPath(scope, opId) {
|
|
113
|
+
return join(this.#scopeDir(scope), 'ops', `${safeId(opId, 'op id')}.json`);
|
|
114
|
+
}
|
|
115
|
+
// --- io ---------------------------------------------------------------
|
|
116
|
+
async #all(scope) {
|
|
117
|
+
const dir = join(this.#scopeDir(scope), 'records');
|
|
118
|
+
let names;
|
|
119
|
+
try {
|
|
120
|
+
names = await readdir(dir);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
if (err.code === 'ENOENT') {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
const records = await Promise.all(names
|
|
129
|
+
.filter((n) => n.endsWith('.json'))
|
|
130
|
+
.map((n) => this.#readJson(join(dir, n))));
|
|
131
|
+
return records.filter((r) => r !== undefined);
|
|
132
|
+
}
|
|
133
|
+
async #readOp(scope, opId) {
|
|
134
|
+
const op = await this.#readJson(this.#opPath(scope, opId));
|
|
135
|
+
return op?.recordId;
|
|
136
|
+
}
|
|
137
|
+
async #writeOp(scope, opId, recordId) {
|
|
138
|
+
await writeJson(this.#opPath(scope, opId), { recordId });
|
|
139
|
+
}
|
|
140
|
+
async #writeRecord(record) {
|
|
141
|
+
await writeJson(join(this.#scopeDir(record.scope), 'scope.json'), { scope: record.scope });
|
|
142
|
+
await writeJson(this.#recordPath(record.scope, record.id), record);
|
|
143
|
+
}
|
|
144
|
+
async #readJson(path) {
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
if (err.code === 'ENOENT') {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function safeId(value, what) {
|
|
157
|
+
if (!SAFE_ID.test(value)) {
|
|
158
|
+
throw new Error(`invalid ${what}: ${JSON.stringify(value)}`);
|
|
159
|
+
}
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
async function writeJson(path, value) {
|
|
163
|
+
await mkdir(join(path, '..'), { recursive: true });
|
|
164
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
165
|
+
await writeFile(tmp, JSON.stringify(value), 'utf8');
|
|
166
|
+
try {
|
|
167
|
+
await rename(tmp, path);
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
await rm(tmp, { force: true });
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=file.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type MemoryDraft, type MemoryHit, type MemoryPatch, type MemoryQuery, type MemoryRecord, type MemoryStore } from '../memory.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Reference implementation: scoped maps plus token-overlap scoring. Good enough
|
|
4
|
+
* for tests and local runs; production deployments swap in a vector store
|
|
5
|
+
* without touching the kernel.
|
|
6
|
+
*/
|
|
7
|
+
export declare class InMemoryMemoryStore implements MemoryStore {
|
|
8
|
+
#private;
|
|
9
|
+
readonly id: string;
|
|
10
|
+
constructor(id?: string);
|
|
11
|
+
search(scope: string, q: MemoryQuery): Promise<MemoryHit[]>;
|
|
12
|
+
get(scope: string, id: string): Promise<MemoryRecord | undefined>;
|
|
13
|
+
write(scope: string, rec: MemoryDraft, opId: string): Promise<MemoryRecord>;
|
|
14
|
+
update(scope: string, id: string, patch: MemoryPatch, opId: string): Promise<MemoryRecord>;
|
|
15
|
+
delete(scope: string, id: string, opId: string): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=in-memory.d.ts.map
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { rankMemories, } from "../memory.js";
|
|
2
|
+
/**
|
|
3
|
+
* Reference implementation: scoped maps plus token-overlap scoring. Good enough
|
|
4
|
+
* for tests and local runs; production deployments swap in a vector store
|
|
5
|
+
* without touching the kernel.
|
|
6
|
+
*/
|
|
7
|
+
export class InMemoryMemoryStore {
|
|
8
|
+
id;
|
|
9
|
+
#scopes = new Map();
|
|
10
|
+
/** opId → record id, so a replayed op returns the original result */
|
|
11
|
+
#ops = new Map();
|
|
12
|
+
#seq = 0;
|
|
13
|
+
constructor(id = 'mem') {
|
|
14
|
+
this.id = id;
|
|
15
|
+
}
|
|
16
|
+
async search(scope, q) {
|
|
17
|
+
return rankMemories(this.#space(scope).values(), q);
|
|
18
|
+
}
|
|
19
|
+
async get(scope, id) {
|
|
20
|
+
return this.#space(scope).get(id);
|
|
21
|
+
}
|
|
22
|
+
async write(scope, rec, opId) {
|
|
23
|
+
const known = this.#ops.get(opId);
|
|
24
|
+
if (known) {
|
|
25
|
+
const existing = this.#space(scope).get(known);
|
|
26
|
+
if (existing) {
|
|
27
|
+
return existing;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const now = new Date().toISOString();
|
|
31
|
+
const record = {
|
|
32
|
+
id: `m${++this.#seq}`,
|
|
33
|
+
scope,
|
|
34
|
+
kind: rec.kind ?? 'fact',
|
|
35
|
+
text: rec.text,
|
|
36
|
+
metadata: rec.metadata,
|
|
37
|
+
createdAt: now,
|
|
38
|
+
updatedAt: now,
|
|
39
|
+
revision: 1,
|
|
40
|
+
};
|
|
41
|
+
this.#space(scope).set(record.id, record);
|
|
42
|
+
this.#ops.set(opId, record.id);
|
|
43
|
+
return record;
|
|
44
|
+
}
|
|
45
|
+
async update(scope, id, patch, opId) {
|
|
46
|
+
const space = this.#space(scope);
|
|
47
|
+
const cur = space.get(id);
|
|
48
|
+
if (!cur) {
|
|
49
|
+
throw new Error(`memory record not found: ${scope}/${id}`);
|
|
50
|
+
}
|
|
51
|
+
if (this.#ops.get(opId) === id) {
|
|
52
|
+
return cur;
|
|
53
|
+
}
|
|
54
|
+
if (patch.expectedRevision !== undefined && patch.expectedRevision !== cur.revision) {
|
|
55
|
+
throw new Error(`memory conflict on ${scope}/${id}: expected revision ` +
|
|
56
|
+
`${patch.expectedRevision}, found ${cur.revision}`);
|
|
57
|
+
}
|
|
58
|
+
const next = {
|
|
59
|
+
...cur,
|
|
60
|
+
kind: patch.kind ?? cur.kind,
|
|
61
|
+
text: patch.text ?? cur.text,
|
|
62
|
+
metadata: patch.metadata ?? cur.metadata,
|
|
63
|
+
updatedAt: new Date().toISOString(),
|
|
64
|
+
revision: cur.revision + 1,
|
|
65
|
+
};
|
|
66
|
+
space.set(id, next);
|
|
67
|
+
this.#ops.set(opId, id);
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
70
|
+
async delete(scope, id, opId) {
|
|
71
|
+
this.#space(scope).delete(id);
|
|
72
|
+
this.#ops.set(opId, id);
|
|
73
|
+
}
|
|
74
|
+
#space(scope) {
|
|
75
|
+
const s = this.#scopes.get(scope) ?? new Map();
|
|
76
|
+
this.#scopes.set(scope, s);
|
|
77
|
+
return s;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=in-memory.js.map
|