@zenera/neo 1.1.21 → 1.1.23
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 +1 -1
- package/dist/fork.js +4 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/inspect/client.d.ts +1 -1
- package/dist/inspect/client.js +95 -14
- package/dist/inspect/css.d.ts +1 -1
- package/dist/inspect/css.js +28 -3
- package/dist/kernel.js +22 -1
- package/dist/memory/files.d.ts +2 -0
- package/dist/memory/files.js +10 -9
- package/dist/memory/graph.d.ts +14 -0
- package/dist/memory/graph.js +28 -0
- package/dist/memory/index.d.ts +3 -0
- package/dist/memory/index.js +49 -17
- package/dist/memory/merge.d.ts +65 -0
- package/dist/memory/merge.js +379 -0
- package/dist/memory/store.d.ts +11 -0
- package/dist/memory/store.js +9 -0
- package/dist/memory/tools.js +66 -16
- package/dist/memory/vectors.d.ts +2 -0
- package/dist/memory/vectors.js +7 -0
- package/dist/project/config.d.ts +12 -0
- package/dist/project/config.js +14 -0
- package/dist/project/load.js +61 -0
- package/dist/tools/sandbox.d.ts +25 -0
- package/dist/tools/sandbox.js +58 -8
- package/dist/tools/workspace.js +2 -2
- package/dist/types.js +25 -10
- package/package.json +1 -1
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { hostPath } from "./files.js";
|
|
5
|
+
import { DUPLICATE_SCORE, flatten } from "./index.js";
|
|
6
|
+
import { MANIFEST_FILE, MemoryStore, lockHolder } from "./store.js";
|
|
7
|
+
import { MemoryError } from "./types.js";
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Folding several memories into one
|
|
10
|
+
//
|
|
11
|
+
// Warming a memory is embarrassingly parallel — N runs, N questions, N answers
|
|
12
|
+
// — but the lock is per directory, so N runs cannot share one. They each write
|
|
13
|
+
// their own, and this puts the results back together.
|
|
14
|
+
//
|
|
15
|
+
// Ids are ULIDs and are preserved, which makes the interesting case the one
|
|
16
|
+
// where the same id appears twice: the runs started from a copy of the same
|
|
17
|
+
// memory, so most of what they hold is a shared ancestor rather than a
|
|
18
|
+
// collision. That case reconciles to nothing more than counters, and the
|
|
19
|
+
// counters take the MAX rather than the sum, because merging the same source
|
|
20
|
+
// twice has to be a no-op. Anything that is not a shared ancestor is a real
|
|
21
|
+
// divergence and refuses the whole merge; `force` is the escape hatch, not the
|
|
22
|
+
// default, because picking a winner silently loses a memory.
|
|
23
|
+
//
|
|
24
|
+
// Nothing here embeds. A merge is offline and fast, and the price of that is
|
|
25
|
+
// that every side must already agree on the embedder.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
/** How much of a diverged memory's text a conflict report carries. */
|
|
28
|
+
const CLIP = 80;
|
|
29
|
+
/**
|
|
30
|
+
* Refusal carrying what to look at. Thrown before anything is written, so a
|
|
31
|
+
* conflicted merge leaves the target exactly as it was.
|
|
32
|
+
*/
|
|
33
|
+
export class MergeConflicts extends MemoryError {
|
|
34
|
+
conflicts;
|
|
35
|
+
constructor(conflicts) {
|
|
36
|
+
super(`${conflicts.length} ${conflicts.length === 1 ? 'memory' : 'memories'} changed in more than one place`, 'compare them with `zen memory show <id>`, or take the newest with --force');
|
|
37
|
+
this.name = 'MergeConflicts';
|
|
38
|
+
this.conflicts = conflicts;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Fold `sources` into `target`, left to right.
|
|
43
|
+
*
|
|
44
|
+
* Order is visible: a memory folded onto a twin keeps the id of whichever copy
|
|
45
|
+
* arrived first, so `merge a b` and `merge b a` can differ in which id
|
|
46
|
+
* survives. They never differ in what is remembered.
|
|
47
|
+
*
|
|
48
|
+
* On `dryRun` the target is left with the merge applied in memory and nothing
|
|
49
|
+
* on disk. Report the result and discard the store; do not commit it.
|
|
50
|
+
*/
|
|
51
|
+
export async function mergeMemories(target, sources, opts = {}) {
|
|
52
|
+
const dedupe = opts.dedupe !== false;
|
|
53
|
+
const opened = await openSources(target, sources, opts.force === true);
|
|
54
|
+
const reports = new Map(opened.map((src) => [
|
|
55
|
+
src,
|
|
56
|
+
{
|
|
57
|
+
dir: src.dir,
|
|
58
|
+
nodes: src.graph.order,
|
|
59
|
+
edges: src.graph.size,
|
|
60
|
+
added: 0,
|
|
61
|
+
shared: 0,
|
|
62
|
+
twins: 0,
|
|
63
|
+
files: 0,
|
|
64
|
+
},
|
|
65
|
+
]));
|
|
66
|
+
const embedding = agree(target, opened);
|
|
67
|
+
if (embedding) {
|
|
68
|
+
target.adopt(embedding);
|
|
69
|
+
}
|
|
70
|
+
// --- plan: everything refusable is refused here, before a single write ---
|
|
71
|
+
const plan = new Map();
|
|
72
|
+
for (const node of target.graph.nodes()) {
|
|
73
|
+
plan.set(node.id, { node, fresh: false });
|
|
74
|
+
}
|
|
75
|
+
const conflicts = [];
|
|
76
|
+
for (const src of opened) {
|
|
77
|
+
const report = reports.get(src);
|
|
78
|
+
for (const node of src.graph.nodes()) {
|
|
79
|
+
const held = plan.get(node.id);
|
|
80
|
+
if (!held) {
|
|
81
|
+
plan.set(node.id, { node, from: src, fresh: true });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (held.node.revision === node.revision && sameContent(held.node, node)) {
|
|
85
|
+
plan.set(node.id, { ...held, node: reconcile(held.node, node) });
|
|
86
|
+
report.shared++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (!opts.force) {
|
|
90
|
+
conflicts.push({
|
|
91
|
+
id: node.id,
|
|
92
|
+
dir: src.dir,
|
|
93
|
+
mine: held.node.revision,
|
|
94
|
+
theirs: node.revision,
|
|
95
|
+
text: clip(node.text),
|
|
96
|
+
});
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
report.shared++;
|
|
100
|
+
plan.set(node.id, newer(node, held.node)
|
|
101
|
+
? { node: reconcile(node, held.node), from: src, fresh: held.fresh }
|
|
102
|
+
: { ...held, node: reconcile(held.node, node) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (conflicts.length) {
|
|
106
|
+
throw new MergeConflicts(conflicts);
|
|
107
|
+
}
|
|
108
|
+
await checkFiles(plan);
|
|
109
|
+
// --- fold, in memory ---
|
|
110
|
+
const before = target.graph.size;
|
|
111
|
+
const moved = new Map();
|
|
112
|
+
for (const planned of plan.values()) {
|
|
113
|
+
if (planned.fresh) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
target.graph.adopt(planned.node);
|
|
117
|
+
if (planned.from) {
|
|
118
|
+
carryVector(planned.from, target, planned.node.id);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const lexical = new Map();
|
|
122
|
+
for (const node of target.graph.nodes()) {
|
|
123
|
+
lexical.set(twinKey(node), node.id);
|
|
124
|
+
}
|
|
125
|
+
for (const src of opened) {
|
|
126
|
+
const report = reports.get(src);
|
|
127
|
+
const stale = target.graph.superseded();
|
|
128
|
+
for (const node of src.graph.nodes()) {
|
|
129
|
+
const planned = plan.get(node.id);
|
|
130
|
+
if (!planned.fresh || planned.from !== src || moved.has(node.id)) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const twin = dedupe ? twinOf(target, src, planned.node, lexical, stale) : undefined;
|
|
134
|
+
if (twin) {
|
|
135
|
+
moved.set(node.id, twin);
|
|
136
|
+
report.twins++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
target.graph.adopt(planned.node);
|
|
140
|
+
carryVector(src, target, node.id);
|
|
141
|
+
lexical.set(twinKey(planned.node), node.id);
|
|
142
|
+
moved.set(node.id, node.id);
|
|
143
|
+
report.added++;
|
|
144
|
+
}
|
|
145
|
+
for (const link of src.graph.links()) {
|
|
146
|
+
const from = moved.get(link.source) ?? link.source;
|
|
147
|
+
const to = moved.get(link.target) ?? link.target;
|
|
148
|
+
// A self-edge is what a fold leaves behind when both ends collapsed
|
|
149
|
+
// onto the same memory; it says nothing and is dropped.
|
|
150
|
+
if (from === to || !target.graph.has(from) || !target.graph.has(to)) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
target.graph.adoptEdge(from, to, link.attrs);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const report = {
|
|
157
|
+
dir: target.dir,
|
|
158
|
+
sources: opened.map((src) => reports.get(src)),
|
|
159
|
+
added: 0,
|
|
160
|
+
shared: 0,
|
|
161
|
+
twins: 0,
|
|
162
|
+
edges: target.graph.size - before,
|
|
163
|
+
files: 0,
|
|
164
|
+
...(target.embedding ? { embedding: target.embedding } : {}),
|
|
165
|
+
dryRun: opts.dryRun === true,
|
|
166
|
+
};
|
|
167
|
+
if (opts.dryRun) {
|
|
168
|
+
for (const [id, planned] of plan) {
|
|
169
|
+
if (copies(id, planned, moved)) {
|
|
170
|
+
reports.get(planned.from).files++;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return total(report);
|
|
174
|
+
}
|
|
175
|
+
await copyFiles(target, plan, moved, reports);
|
|
176
|
+
await target.commit();
|
|
177
|
+
return total(report);
|
|
178
|
+
}
|
|
179
|
+
async function openSources(target, sources, force) {
|
|
180
|
+
if (!sources.length) {
|
|
181
|
+
throw new MemoryError('no memories to merge', 'name at least one memory directory');
|
|
182
|
+
}
|
|
183
|
+
const here = resolve(target.dir);
|
|
184
|
+
const seen = new Set();
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const dir of sources) {
|
|
187
|
+
const path = resolve(dir);
|
|
188
|
+
if (path === here) {
|
|
189
|
+
throw new MemoryError(`${dir} is the memory being merged into`, 'name only the memories to fold in');
|
|
190
|
+
}
|
|
191
|
+
if (seen.has(path)) {
|
|
192
|
+
throw new MemoryError(`${dir} was named twice`, 'name each memory once');
|
|
193
|
+
}
|
|
194
|
+
seen.add(path);
|
|
195
|
+
// `MemoryStore.open` creates what is missing, so a mistyped path would
|
|
196
|
+
// otherwise merge an empty memory it had just brought into existence.
|
|
197
|
+
try {
|
|
198
|
+
await stat(join(path, MANIFEST_FILE));
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
throw new MemoryError(`${dir} is not a memory`, `no ${MANIFEST_FILE} in it`);
|
|
202
|
+
}
|
|
203
|
+
const held = lockHolder(path);
|
|
204
|
+
if (held && !force) {
|
|
205
|
+
throw new MemoryError(`${dir} is in use (pid ${held.pid}, since ${held.startedAt})`, 'wait for that run to finish, or merge with --force');
|
|
206
|
+
}
|
|
207
|
+
out.push(await MemoryStore.open(path, { lock: false }));
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The one embedder everything must already share. Sources that hold nothing are
|
|
213
|
+
* not asked: a warmup run that found nothing worth keeping never adopted one.
|
|
214
|
+
*/
|
|
215
|
+
function agree(target, sources) {
|
|
216
|
+
const holding = sources.filter((src) => src.graph.order > 0);
|
|
217
|
+
const chosen = target.embedding ?? holding.find((src) => src.embedding)?.embedding;
|
|
218
|
+
for (const src of holding) {
|
|
219
|
+
const theirs = src.embedding;
|
|
220
|
+
if (chosen && theirs) {
|
|
221
|
+
if (theirs.model !== chosen.model || theirs.dimensions !== chosen.dimensions) {
|
|
222
|
+
throw new MemoryError(`${src.dir} was embedded with ${theirs.model} (${theirs.dimensions}d), ` +
|
|
223
|
+
`but this merge is ${chosen.model} (${chosen.dimensions}d)`, 'merge memories warmed with the same embedder');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
else if (chosen) {
|
|
227
|
+
throw new MemoryError(`${src.dir} has no embedder, but this merge is ${chosen.model} (${chosen.dimensions}d)`, 'its memories would be invisible to search; re-warm it with that embedder');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return chosen;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The existing twin rule, applied across memories: same kind, same audience, no
|
|
234
|
+
* file, not superseded, and either near-identical by cosine or identical once
|
|
235
|
+
* whitespace and case stop counting.
|
|
236
|
+
*/
|
|
237
|
+
function twinOf(target, src, node, lexical, stale) {
|
|
238
|
+
if (node.file) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
const vector = src.vectors?.get(node.id);
|
|
242
|
+
const block = target.vectors;
|
|
243
|
+
if (vector && block && block.dims === vector.length && block.rows) {
|
|
244
|
+
const allow = (id) => {
|
|
245
|
+
const other = target.graph.get(id);
|
|
246
|
+
return !stale.has(id) && !!other && comparable(other, node);
|
|
247
|
+
};
|
|
248
|
+
const hit = block.topK(vector, 1, allow)[0];
|
|
249
|
+
return hit && hit.score >= DUPLICATE_SCORE ? hit.id : undefined;
|
|
250
|
+
}
|
|
251
|
+
const id = lexical.get(twinKey(node));
|
|
252
|
+
return id && !stale.has(id) ? id : undefined;
|
|
253
|
+
}
|
|
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
|
+
function carryVector(from, to, id) {
|
|
264
|
+
const vector = from.vectors?.get(id);
|
|
265
|
+
const block = to.vectors;
|
|
266
|
+
if (vector && block && block.dims === vector.length) {
|
|
267
|
+
block.set(id, vector);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Use is not change: `touch` moves these and deliberately leaves `revision`
|
|
272
|
+
* alone, which is what lets two runs of the same memory reconcile instead of
|
|
273
|
+
* colliding. MAX and not sum, so merging a source twice changes nothing.
|
|
274
|
+
*/
|
|
275
|
+
function reconcile(keep, other) {
|
|
276
|
+
return {
|
|
277
|
+
...keep,
|
|
278
|
+
// ISO-8601 in UTC is fixed width, so string order is time order.
|
|
279
|
+
createdAt: keep.createdAt <= other.createdAt ? keep.createdAt : other.createdAt,
|
|
280
|
+
lastUsedAt: keep.lastUsedAt >= other.lastUsedAt ? keep.lastUsedAt : other.lastUsedAt,
|
|
281
|
+
useCount: Math.max(keep.useCount, other.useCount),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function newer(a, b) {
|
|
285
|
+
return a.revision === b.revision ? a.updatedAt > b.updatedAt : a.revision > b.revision;
|
|
286
|
+
}
|
|
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
|
+
/** Stat every file the merge would copy before it copies any of them. */
|
|
310
|
+
async function checkFiles(plan) {
|
|
311
|
+
for (const planned of plan.values()) {
|
|
312
|
+
const file = planned.node.file;
|
|
313
|
+
if (!planned.from || !file) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const source = hostPath(planned.from.dir, file);
|
|
317
|
+
const info = await stat(source).catch(() => undefined);
|
|
318
|
+
if (!info?.isFile()) {
|
|
319
|
+
throw new MemoryError(`${file.path} is missing from ${planned.from.dir}`, 'that memory is incomplete; re-run it, or forget the node that names the file');
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Bytes last, and all or nothing: the digest is checked against the one the
|
|
325
|
+
* node records, and anything written before a failure is taken back, so a
|
|
326
|
+
* refused merge cannot leave files no graph mentions.
|
|
327
|
+
*/
|
|
328
|
+
async function copyFiles(target, plan, moved, reports) {
|
|
329
|
+
const written = [];
|
|
330
|
+
try {
|
|
331
|
+
for (const [id, planned] of plan) {
|
|
332
|
+
const file = planned.node.file;
|
|
333
|
+
if (!copies(id, planned, moved)) {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const source = hostPath(planned.from.dir, file);
|
|
337
|
+
const destination = hostPath(target.dir, file);
|
|
338
|
+
if (source === destination) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const bytes = await readFile(source);
|
|
342
|
+
const sha = createHash('sha256').update(bytes).digest('hex');
|
|
343
|
+
if (sha !== file.sha256) {
|
|
344
|
+
throw new MemoryError(`${file.path} in ${planned.from.dir} does not match the digest its memory records`, 'that memory was modified outside zen; merge it after re-running it');
|
|
345
|
+
}
|
|
346
|
+
const existed = await stat(destination).then(() => true, () => false);
|
|
347
|
+
await writeFile(destination, bytes);
|
|
348
|
+
if (!existed) {
|
|
349
|
+
written.push(destination);
|
|
350
|
+
}
|
|
351
|
+
reports.get(planned.from).files++;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
await Promise.all(written.map((path) => rm(path, { force: true })));
|
|
356
|
+
throw err;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/** A file comes across when its node came from a source and survived the fold. */
|
|
360
|
+
function copies(id, planned, moved) {
|
|
361
|
+
if (!planned.from || !planned.node.file) {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
return !planned.fresh || moved.get(id) === id;
|
|
365
|
+
}
|
|
366
|
+
function total(report) {
|
|
367
|
+
for (const src of report.sources) {
|
|
368
|
+
report.added += src.added;
|
|
369
|
+
report.shared += src.shared;
|
|
370
|
+
report.twins += src.twins;
|
|
371
|
+
report.files += src.files;
|
|
372
|
+
}
|
|
373
|
+
return report;
|
|
374
|
+
}
|
|
375
|
+
function clip(text) {
|
|
376
|
+
const flat = flatten(text);
|
|
377
|
+
return flat.length > CLIP ? `${flat.slice(0, CLIP - 1)}…` : flat;
|
|
378
|
+
}
|
|
379
|
+
//# sourceMappingURL=merge.js.map
|
package/dist/memory/store.d.ts
CHANGED
|
@@ -19,6 +19,11 @@ export interface MemoryManifest {
|
|
|
19
19
|
edges: number;
|
|
20
20
|
updatedAt: string;
|
|
21
21
|
}
|
|
22
|
+
export interface Lock {
|
|
23
|
+
pid: number;
|
|
24
|
+
host: string;
|
|
25
|
+
startedAt: string;
|
|
26
|
+
}
|
|
22
27
|
export interface OpenOptions {
|
|
23
28
|
/** the embedder the caller intends to use; a mismatch with the manifest is fatal */
|
|
24
29
|
embedding?: MemoryEmbedding;
|
|
@@ -46,4 +51,10 @@ export declare class MemoryStore {
|
|
|
46
51
|
commit(): Promise<void>;
|
|
47
52
|
release(): void;
|
|
48
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Who is writing a memory right now, if anyone. Read-only on purpose: it never
|
|
56
|
+
* claims the lock and never clears a stale one, so it is safe to ask about a
|
|
57
|
+
* directory this process has no intention of opening for writing.
|
|
58
|
+
*/
|
|
59
|
+
export declare function lockHolder(dir: string): Lock | undefined;
|
|
49
60
|
//# sourceMappingURL=store.d.ts.map
|
package/dist/memory/store.js
CHANGED
|
@@ -202,6 +202,15 @@ async function atomic(path, body) {
|
|
|
202
202
|
throw err;
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Who is writing a memory right now, if anyone. Read-only on purpose: it never
|
|
207
|
+
* claims the lock and never clears a stale one, so it is safe to ask about a
|
|
208
|
+
* directory this process has no intention of opening for writing.
|
|
209
|
+
*/
|
|
210
|
+
export function lockHolder(dir) {
|
|
211
|
+
const held = readLock(join(dir, LOCK_FILE));
|
|
212
|
+
return held && held.host === hostname() && alive(held.pid) ? held : undefined;
|
|
213
|
+
}
|
|
205
214
|
function readLock(path) {
|
|
206
215
|
try {
|
|
207
216
|
return JSON.parse(readFileSync(path, 'utf8'));
|
package/dist/memory/tools.js
CHANGED
|
@@ -161,11 +161,11 @@ export function memoryTools(opts) {
|
|
|
161
161
|
properties: {
|
|
162
162
|
ref: {
|
|
163
163
|
type: 'string',
|
|
164
|
-
description: 'a short name for a new node, used by edges in this call',
|
|
164
|
+
description: 'a short local name for a new node (e.g. "task_1", "file_1"), used by edges in this call. Use ref (not id) when creating new nodes.',
|
|
165
165
|
},
|
|
166
166
|
id: {
|
|
167
167
|
type: 'string',
|
|
168
|
-
description: '
|
|
168
|
+
description: 'the permanent id of an existing node to update in place (from search/load). Do not use for new nodes; use ref instead.',
|
|
169
169
|
},
|
|
170
170
|
kind: { type: 'string', enum: kinds, description: kindHelp },
|
|
171
171
|
text: {
|
|
@@ -173,8 +173,25 @@ export function memoryTools(opts) {
|
|
|
173
173
|
description: 'what to remember, in your own words',
|
|
174
174
|
},
|
|
175
175
|
file: {
|
|
176
|
-
|
|
177
|
-
|
|
176
|
+
oneOf: [
|
|
177
|
+
{
|
|
178
|
+
type: 'string',
|
|
179
|
+
description: 'path of an existing file in the workspace to keep a copy of (e.g. "report.py" or "/workspace/report.py"), for kind "file"',
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
type: 'object',
|
|
183
|
+
properties: {
|
|
184
|
+
path: {
|
|
185
|
+
type: 'string',
|
|
186
|
+
description: 'path of an existing file in the workspace',
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
required: ['path'],
|
|
190
|
+
additionalProperties: false,
|
|
191
|
+
description: 'file object with path',
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
description: 'path of an existing file in the workspace to keep a copy of (e.g. "report.py" or "/workspace/report.py"), for kind "file"',
|
|
178
195
|
},
|
|
179
196
|
...audience,
|
|
180
197
|
expected_revision: {
|
|
@@ -192,11 +209,11 @@ export function memoryTools(opts) {
|
|
|
192
209
|
properties: {
|
|
193
210
|
from: {
|
|
194
211
|
type: 'string',
|
|
195
|
-
description: 'a ref from this call, or an id',
|
|
212
|
+
description: 'a ref from this call, or an existing node id',
|
|
196
213
|
},
|
|
197
214
|
to: {
|
|
198
215
|
type: 'string',
|
|
199
|
-
description: 'a ref from this call, or an id',
|
|
216
|
+
description: 'a ref from this call, or an existing node id',
|
|
200
217
|
},
|
|
201
218
|
relation: {
|
|
202
219
|
type: 'string',
|
|
@@ -216,15 +233,28 @@ export function memoryTools(opts) {
|
|
|
216
233
|
const opId = memoryOpId(tc.state.runId, tc.callId);
|
|
217
234
|
let nodes;
|
|
218
235
|
try {
|
|
219
|
-
nodes = args.nodes.map((n) =>
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
236
|
+
nodes = args.nodes.map((n) => {
|
|
237
|
+
let fileSpec;
|
|
238
|
+
if (n.file !== undefined) {
|
|
239
|
+
const rawPath = extractFilePath(n.file);
|
|
240
|
+
if (!rawPath.trim()) {
|
|
241
|
+
throw new MemoryError('file path is required', 'pass the path of the file in the workspace to remember, e.g. "report.py"');
|
|
242
|
+
}
|
|
243
|
+
fileSpec = {
|
|
244
|
+
source: source(tc, rawPath),
|
|
245
|
+
path: rawPath,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
ref: n.ref,
|
|
250
|
+
id: n.id,
|
|
251
|
+
kind: n.kind,
|
|
252
|
+
text: n.text,
|
|
253
|
+
audience: n.audience,
|
|
254
|
+
expectedRevision: n.expected_revision,
|
|
255
|
+
file: fileSpec,
|
|
256
|
+
};
|
|
257
|
+
});
|
|
228
258
|
}
|
|
229
259
|
catch (err) {
|
|
230
260
|
return refusal(err);
|
|
@@ -322,11 +352,31 @@ function refusal(err) {
|
|
|
322
352
|
}
|
|
323
353
|
throw err;
|
|
324
354
|
}
|
|
355
|
+
function extractFilePath(raw) {
|
|
356
|
+
if (typeof raw === 'string') {
|
|
357
|
+
return raw;
|
|
358
|
+
}
|
|
359
|
+
if (raw &&
|
|
360
|
+
typeof raw === 'object' &&
|
|
361
|
+
'path' in raw &&
|
|
362
|
+
typeof raw.path === 'string') {
|
|
363
|
+
return raw.path;
|
|
364
|
+
}
|
|
365
|
+
return '';
|
|
366
|
+
}
|
|
325
367
|
function source(tc, path) {
|
|
326
368
|
if (!tc.services.resolveFile) {
|
|
327
369
|
throw new MemoryError('this agent cannot remember files', 'commit the node without `file`, or give the agent a workspace');
|
|
328
370
|
}
|
|
329
|
-
|
|
371
|
+
try {
|
|
372
|
+
return tc.services.resolveFile(path);
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
if (err instanceof MemoryError) {
|
|
376
|
+
throw err;
|
|
377
|
+
}
|
|
378
|
+
throw new MemoryError(err instanceof Error ? err.message : String(err), 'remember a file the run actually wrote, by the path the file tools use');
|
|
379
|
+
}
|
|
330
380
|
}
|
|
331
381
|
function changed(index, ids) {
|
|
332
382
|
return Object.values(ids).flatMap((id) => {
|
package/dist/memory/vectors.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export declare class VectorBlock {
|
|
|
9
9
|
get rows(): number;
|
|
10
10
|
get ids(): readonly string[];
|
|
11
11
|
has(id: string): boolean;
|
|
12
|
+
/** A copy, not a view: the row it came from moves the next time something is deleted. */
|
|
13
|
+
get(id: string): Float32Array | undefined;
|
|
12
14
|
set(id: string, vector: ArrayLike<number>): void;
|
|
13
15
|
remove(id: string): boolean;
|
|
14
16
|
/** Exact kNN over the rows `allow` accepts. Ties break toward the earlier row. */
|
package/dist/memory/vectors.js
CHANGED
|
@@ -39,6 +39,13 @@ export class VectorBlock {
|
|
|
39
39
|
has(id) {
|
|
40
40
|
return this.#index.has(id);
|
|
41
41
|
}
|
|
42
|
+
/** A copy, not a view: the row it came from moves the next time something is deleted. */
|
|
43
|
+
get(id) {
|
|
44
|
+
const row = this.#index.get(id);
|
|
45
|
+
return row === undefined
|
|
46
|
+
? undefined
|
|
47
|
+
: this.#data.slice(row * this.#dims, (row + 1) * this.#dims);
|
|
48
|
+
}
|
|
42
49
|
set(id, vector) {
|
|
43
50
|
if (vector.length !== this.#dims) {
|
|
44
51
|
throw new Error(`expected a ${this.#dims}-dimension vector, got ${vector.length}`);
|
package/dist/project/config.d.ts
CHANGED
|
@@ -169,6 +169,10 @@ declare const sandbox: z.ZodObject<{
|
|
|
169
169
|
}>>;
|
|
170
170
|
workdir: z.ZodOptional<z.ZodString>;
|
|
171
171
|
timeout: z.ZodOptional<z.ZodInt>;
|
|
172
|
+
hardening: z.ZodOptional<z.ZodEnum<{
|
|
173
|
+
strict: "strict";
|
|
174
|
+
standard: "standard";
|
|
175
|
+
}>>;
|
|
172
176
|
user: z.ZodOptional<z.ZodString>;
|
|
173
177
|
persist: z.ZodOptional<z.ZodBoolean>;
|
|
174
178
|
env: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -295,6 +299,10 @@ export declare const projectSchema: z.ZodObject<{
|
|
|
295
299
|
}>>;
|
|
296
300
|
workdir: z.ZodOptional<z.ZodString>;
|
|
297
301
|
timeout: z.ZodOptional<z.ZodInt>;
|
|
302
|
+
hardening: z.ZodOptional<z.ZodEnum<{
|
|
303
|
+
strict: "strict";
|
|
304
|
+
standard: "standard";
|
|
305
|
+
}>>;
|
|
298
306
|
user: z.ZodOptional<z.ZodString>;
|
|
299
307
|
persist: z.ZodOptional<z.ZodBoolean>;
|
|
300
308
|
env: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -355,6 +363,10 @@ export declare const projectSchema: z.ZodObject<{
|
|
|
355
363
|
}>>;
|
|
356
364
|
workdir: z.ZodOptional<z.ZodString>;
|
|
357
365
|
timeout: z.ZodOptional<z.ZodInt>;
|
|
366
|
+
hardening: z.ZodOptional<z.ZodEnum<{
|
|
367
|
+
strict: "strict";
|
|
368
|
+
standard: "standard";
|
|
369
|
+
}>>;
|
|
358
370
|
user: z.ZodOptional<z.ZodString>;
|
|
359
371
|
persist: z.ZodOptional<z.ZodBoolean>;
|
|
360
372
|
env: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
package/dist/project/config.js
CHANGED
|
@@ -263,6 +263,20 @@ const sandbox = z
|
|
|
263
263
|
workdir: z.string().min(1).optional(),
|
|
264
264
|
/** seconds one command may take before it is killed */
|
|
265
265
|
timeout: z.int().positive().optional(),
|
|
266
|
+
/**
|
|
267
|
+
* How much of the host the container is allowed to be.
|
|
268
|
+
*
|
|
269
|
+
* `standard` (the default) keeps the kernel capabilities and a
|
|
270
|
+
* writable root filesystem, so `apt` and `pip` work — which is what a
|
|
271
|
+
* project whose agents install their own dependencies needs.
|
|
272
|
+
*
|
|
273
|
+
* `strict` drops every capability, mounts the root filesystem
|
|
274
|
+
* read-only with an in-memory `noexec` `/tmp`, runs as an
|
|
275
|
+
* unprivileged uid and turns the network off unless this block asks
|
|
276
|
+
* for one. Nothing can be installed at run time, so the image has to
|
|
277
|
+
* carry everything the agent will need. It requires rootless podman.
|
|
278
|
+
*/
|
|
279
|
+
hardening: z.enum(['standard', 'strict']).optional(),
|
|
266
280
|
/** uid, name or `uid:gid`; unset means the image's own user */
|
|
267
281
|
user: z.string().min(1).optional(),
|
|
268
282
|
/** keep the container between sessions instead of removing it */
|