@zenera/neo 1.1.23 → 1.1.25

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 (45) hide show
  1. package/dist/embeddings/rate-limit.d.ts +7 -0
  2. package/dist/embeddings/rate-limit.js +33 -0
  3. package/dist/index.d.ts +4 -0
  4. package/dist/index.js +4 -0
  5. package/dist/inspect/client.d.ts +1 -1
  6. package/dist/inspect/client.js +7 -2
  7. package/dist/lock.d.ts +27 -0
  8. package/dist/lock.js +66 -0
  9. package/dist/match.d.ts +44 -0
  10. package/dist/match.js +100 -0
  11. package/dist/memory/diff.d.ts +39 -0
  12. package/dist/memory/diff.js +95 -0
  13. package/dist/memory/files.d.ts +1 -1
  14. package/dist/memory/grep.d.ts +57 -0
  15. package/dist/memory/grep.js +182 -0
  16. package/dist/memory/index.d.ts +10 -2
  17. package/dist/memory/index.js +15 -4
  18. package/dist/memory/merge.js +1 -31
  19. package/dist/memory/recall.d.ts +1 -1
  20. package/dist/memory/recall.js +1 -1
  21. package/dist/memory/store.d.ts +8 -5
  22. package/dist/memory/store.js +26 -55
  23. package/dist/memory/tools.js +105 -9
  24. package/dist/memory/twin.d.ts +6 -0
  25. package/dist/memory/twin.js +40 -0
  26. package/dist/memory/types.d.ts +7 -2
  27. package/dist/models/factory.d.ts +67 -2
  28. package/dist/models/factory.js +256 -10
  29. package/dist/project/config.js +1 -1
  30. package/dist/project/load.d.ts +7 -0
  31. package/dist/project/load.js +29 -6
  32. package/dist/runner.js +7 -0
  33. package/dist/tools/exa.js +1 -1
  34. package/dist/tools/files.d.ts +81 -0
  35. package/dist/tools/files.js +1472 -0
  36. package/dist/tools/index.d.ts +1 -1
  37. package/dist/tools/index.js +1 -1
  38. package/dist/tools/sandbox.d.ts +44 -0
  39. package/dist/tools/sandbox.js +162 -5
  40. package/dist/tools/workspace.js +81 -3
  41. package/dist/trajectory.d.ts +19 -1
  42. package/dist/trajectory.js +53 -0
  43. package/dist/types.d.ts +1 -0
  44. package/dist/types.js +1 -0
  45. package/package.json +1 -1
@@ -0,0 +1,44 @@
1
+ /** Long enough for any honest pattern, short enough to bound a bad one. */
2
+ export declare const MAX_PATTERN = 200;
3
+ export declare class PatternError extends Error {
4
+ }
5
+ export interface MatchOptions {
6
+ /** read the pattern as a regular expression rather than as a literal */
7
+ regex?: boolean;
8
+ caseSensitive?: boolean;
9
+ }
10
+ export type Matcher = (text: string) => boolean;
11
+ /** The offset of the first match in `text`, or -1. */
12
+ export type Finder = (text: string) => number;
13
+ /**
14
+ * Where a pattern first occurs. Literal by default: someone typing `user.id`
15
+ * means those seven characters, and a dot that quietly matched anything would
16
+ * be a worse answer than no answer.
17
+ */
18
+ export declare function finder(pattern: string, options?: MatchOptions): Finder;
19
+ /** Whether a pattern occurs at all, read the same way `finder` reads it. */
20
+ export declare function matcher(pattern: string, options?: MatchOptions): Matcher;
21
+ /**
22
+ * A glob, matched against the whole string. Globs rather than regexes because
23
+ * these are for naming things — a path, a schema — and a star is what everyone
24
+ * reaches for first.
25
+ */
26
+ export declare function wildcard(pattern: string, options?: {
27
+ caseSensitive?: boolean;
28
+ }): Matcher;
29
+ /** Whether a pattern is asking to be read as a glob at all. */
30
+ export declare const isGlob: (pattern: string) => boolean;
31
+ /**
32
+ * What someone means when they type a name into a filter. With a star in it,
33
+ * a glob; without one, a substring — because `password` typed into `--name` is
34
+ * a search for the word, and a whole-string match would answer nothing and
35
+ * look like the field does not exist.
36
+ *
37
+ * `regex` settles it outright, and has to: a star is punctuation in both
38
+ * languages, so `^/(users|teams)/.*` read as a glob would match nothing and
39
+ * never say why.
40
+ */
41
+ export declare function loose(pattern: string, options?: MatchOptions): Matcher;
42
+ /** True when any of the patterns matches; no patterns means no opinion. */
43
+ export declare function anyOf(matchers: readonly Matcher[]): Matcher | undefined;
44
+ //# sourceMappingURL=match.d.ts.map
package/dist/match.js ADDED
@@ -0,0 +1,100 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Matching, with nothing learned in between
3
+ //
4
+ // Everything here is exact. A glob matches the characters it names and a
5
+ // substring is a substring, which is the whole point of the surfaces built on
6
+ // it: a vector index answers "what is near this", and near is a ranking, so it
7
+ // can only ever return the top of a list. When the question is "does the word
8
+ // `password` appear anywhere at all", a ranking is the wrong instrument and no
9
+ // amount of tuning makes it the right one.
10
+ //
11
+ // Memory grep and @zenera/rag's lookups both stand on this. A `finder` says
12
+ // *where*, for grep, which reports the line; a `matcher` says *whether*.
13
+ //
14
+ // A pattern may arrive from a model, so a regex is a bounded promise: the
15
+ // length is capped here and the scan that uses it keeps a deadline.
16
+ // ---------------------------------------------------------------------------
17
+ /** Long enough for any honest pattern, short enough to bound a bad one. */
18
+ export const MAX_PATTERN = 200;
19
+ export class PatternError extends Error {
20
+ }
21
+ /**
22
+ * Where a pattern first occurs. Literal by default: someone typing `user.id`
23
+ * means those seven characters, and a dot that quietly matched anything would
24
+ * be a worse answer than no answer.
25
+ */
26
+ export function finder(pattern, options = {}) {
27
+ guard(pattern);
28
+ if (!options.regex) {
29
+ if (options.caseSensitive) {
30
+ return (text) => text.indexOf(pattern);
31
+ }
32
+ const needle = pattern.toLowerCase();
33
+ return (text) => text.toLowerCase().indexOf(needle);
34
+ }
35
+ const expression = compile(pattern, options.caseSensitive ? '' : 'i');
36
+ // `lastIndex` is not carried between calls: the flags never include `g`.
37
+ return (text) => text.search(expression);
38
+ }
39
+ /** Whether a pattern occurs at all, read the same way `finder` reads it. */
40
+ export function matcher(pattern, options = {}) {
41
+ const find = finder(pattern, options);
42
+ return (text) => find(text) >= 0;
43
+ }
44
+ /**
45
+ * A glob, matched against the whole string. Globs rather than regexes because
46
+ * these are for naming things — a path, a schema — and a star is what everyone
47
+ * reaches for first.
48
+ */
49
+ export function wildcard(pattern, options = {}) {
50
+ guard(pattern);
51
+ const source = [...pattern]
52
+ .map((char) => (char === '*' ? '.*' : char === '?' ? '.' : escape(char)))
53
+ .join('');
54
+ const expression = compile(`^${source}$`, options.caseSensitive ? '' : 'i');
55
+ return (text) => expression.test(text);
56
+ }
57
+ /** Whether a pattern is asking to be read as a glob at all. */
58
+ export const isGlob = (pattern) => /[*?]/.test(pattern);
59
+ /**
60
+ * What someone means when they type a name into a filter. With a star in it,
61
+ * a glob; without one, a substring — because `password` typed into `--name` is
62
+ * a search for the word, and a whole-string match would answer nothing and
63
+ * look like the field does not exist.
64
+ *
65
+ * `regex` settles it outright, and has to: a star is punctuation in both
66
+ * languages, so `^/(users|teams)/.*` read as a glob would match nothing and
67
+ * never say why.
68
+ */
69
+ export function loose(pattern, options = {}) {
70
+ if (options.regex) {
71
+ return matcher(pattern, options);
72
+ }
73
+ return isGlob(pattern) ? wildcard(pattern, options) : matcher(pattern, options);
74
+ }
75
+ /** True when any of the patterns matches; no patterns means no opinion. */
76
+ export function anyOf(matchers) {
77
+ if (matchers.length === 0) {
78
+ return undefined;
79
+ }
80
+ return (text) => matchers.some((match) => match(text));
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ function guard(pattern) {
84
+ if (pattern.length === 0) {
85
+ throw new PatternError('the pattern is empty');
86
+ }
87
+ if (pattern.length > MAX_PATTERN) {
88
+ throw new PatternError(`the pattern is longer than ${MAX_PATTERN} characters`);
89
+ }
90
+ }
91
+ function compile(source, flags) {
92
+ try {
93
+ return new RegExp(source, flags);
94
+ }
95
+ catch (err) {
96
+ throw new PatternError(`invalid pattern: ${err.message}`);
97
+ }
98
+ }
99
+ const escape = (char) => char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
100
+ //# sourceMappingURL=match.js.map
@@ -0,0 +1,39 @@
1
+ import type { MemoryStore } from './store.ts';
2
+ import type { MemoryNode } from './types.ts';
3
+ export interface MemoryNearest {
4
+ id: string;
5
+ score: number;
6
+ text: string;
7
+ }
8
+ export interface MemoryDiffAdded {
9
+ node: MemoryNode;
10
+ /** the closest comparable node the base held, if any */
11
+ nearest?: MemoryNearest;
12
+ /** `merge` would fold it onto `nearest`: the base already said this */
13
+ twin: boolean;
14
+ }
15
+ export interface MemoryDiff {
16
+ base: string;
17
+ other: string;
18
+ /** how `nearest` was scored: cosine when both sides hold vectors, else term overlap */
19
+ by: 'vector' | 'text';
20
+ added: MemoryDiffAdded[];
21
+ revised: {
22
+ before: MemoryNode;
23
+ after: MemoryNode;
24
+ }[];
25
+ /** base nodes `memory_load` returned since — what the run actually read */
26
+ used: {
27
+ node: MemoryNode;
28
+ loads: number;
29
+ }[];
30
+ removed: MemoryNode[];
31
+ /** edges `other` has that `base` did not */
32
+ edges: number;
33
+ supersedes: {
34
+ source: string;
35
+ target: string;
36
+ }[];
37
+ }
38
+ export declare function diffMemories(base: MemoryStore, other: MemoryStore): MemoryDiff;
39
+ //# sourceMappingURL=diff.d.ts.map
@@ -0,0 +1,95 @@
1
+ import { DUPLICATE_SCORE } from "./index.js";
2
+ import { comparable, sameContent, twinKey } from "./twin.js";
3
+ import { overlap, tokenize } from "./vectors.js";
4
+ export function diffMemories(base, other) {
5
+ const stale = base.graph.superseded();
6
+ const block = base.vectors;
7
+ const theirs = other.vectors;
8
+ const byVector = !!block?.rows && !!theirs && theirs.dims === block.dims;
9
+ const held = base.graph.nodes();
10
+ const lexical = new Map(held.filter((n) => !stale.has(n.id)).map((n) => [twinKey(n), n.id]));
11
+ const nearestOf = (node) => {
12
+ if (node.file) {
13
+ return { twin: false };
14
+ }
15
+ const allow = (id) => {
16
+ const n = base.graph.get(id);
17
+ return !stale.has(id) && !!n && comparable(n, node);
18
+ };
19
+ const exact = lexical.get(twinKey(node));
20
+ const vector = byVector ? theirs.get(node.id) : undefined;
21
+ if (vector) {
22
+ const hit = block.topK(vector, 1, allow)[0];
23
+ if (hit) {
24
+ const text = base.graph.get(hit.id).text;
25
+ return {
26
+ nearest: { id: hit.id, score: hit.score, text },
27
+ twin: hit.score >= DUPLICATE_SCORE,
28
+ };
29
+ }
30
+ }
31
+ if (exact) {
32
+ return {
33
+ nearest: { id: exact, score: 1, text: base.graph.get(exact).text },
34
+ twin: true,
35
+ };
36
+ }
37
+ const words = tokenize(node.text);
38
+ let best;
39
+ for (const n of held) {
40
+ if (!allow(n.id)) {
41
+ continue;
42
+ }
43
+ const score = overlap(words, tokenize(n.text));
44
+ if (score > 0 && (!best || score > best.score)) {
45
+ best = { id: n.id, score, text: n.text };
46
+ }
47
+ }
48
+ return { nearest: best, twin: false };
49
+ };
50
+ const diff = {
51
+ base: base.dir,
52
+ other: other.dir,
53
+ by: byVector ? 'vector' : 'text',
54
+ added: [],
55
+ revised: [],
56
+ used: [],
57
+ removed: [],
58
+ edges: 0,
59
+ supersedes: [],
60
+ };
61
+ for (const after of other.graph.nodes()) {
62
+ const before = base.graph.get(after.id);
63
+ if (!before) {
64
+ diff.added.push({ node: after, ...nearestOf(after) });
65
+ continue;
66
+ }
67
+ if (before.revision !== after.revision || !sameContent(before, after)) {
68
+ diff.revised.push({ before, after });
69
+ }
70
+ if (after.useCount > before.useCount) {
71
+ diff.used.push({ node: after, loads: after.useCount - before.useCount });
72
+ }
73
+ }
74
+ for (const node of held) {
75
+ if (!other.graph.has(node.id)) {
76
+ diff.removed.push(node);
77
+ }
78
+ }
79
+ const key = (source, target, relation) => `${source}\u0000${target}\u0000${relation}`;
80
+ const had = new Set(base.graph.links().map((l) => key(l.source, l.target, l.attrs.relation)));
81
+ for (const link of other.graph.links()) {
82
+ if (had.has(key(link.source, link.target, link.attrs.relation))) {
83
+ continue;
84
+ }
85
+ diff.edges++;
86
+ if (link.attrs.relation === 'SUPERSEDES') {
87
+ diff.supersedes.push({ source: link.source, target: link.target });
88
+ }
89
+ }
90
+ const newest = (a, b) => b.node.createdAt.localeCompare(a.node.createdAt);
91
+ diff.added.sort(newest);
92
+ diff.used.sort((a, b) => b.loads - a.loads);
93
+ return diff;
94
+ }
95
+ //# sourceMappingURL=diff.js.map
@@ -20,7 +20,7 @@ export interface RememberOptions {
20
20
  */
21
21
  export declare function rememberFile(dir: string, source: string, id: string, opts?: RememberOptions): Promise<MemoryFile>;
22
22
  /** The host path of a remembered file, for reading it back or deleting it. */
23
- export declare function hostPath(dir: string, file: MemoryFile): string;
23
+ export declare function hostPath(dir: string, file: Pick<MemoryFile, 'path'>): string;
24
24
  /** Drop the bytes of a forgotten node. Missing is success — the goal is absence. */
25
25
  export declare function forgetFile(dir: string, file: MemoryFile): Promise<void>;
26
26
  //# sourceMappingURL=files.d.ts.map
@@ -0,0 +1,57 @@
1
+ import { type MatchOptions } from '../match.ts';
2
+ import type { Omitted } from './report.ts';
3
+ import type { MemoryStore } from './store.ts';
4
+ import { type MemoryNode } from './types.ts';
5
+ /** The three places a pattern is looked for. */
6
+ export declare const GREP_FIELDS: readonly ["text", "metadata", "file"];
7
+ export type GrepField = (typeof GREP_FIELDS)[number];
8
+ /** What to do about nodes something has superseded. */
9
+ export type StaleMode = 'exclude' | 'include' | 'only';
10
+ /** Past this a remembered file is reported as unsearched rather than read. */
11
+ export declare const MAX_GREP_FILE_BYTES: number;
12
+ /** And past this, so does the rest of the scan: one memory can hold hundreds. */
13
+ export declare const MAX_GREP_BYTES: number;
14
+ export interface GrepHit {
15
+ where: GrepField;
16
+ /** 1-based, within that field */
17
+ line: number;
18
+ /** the matched line, clipped around the match */
19
+ text: string;
20
+ }
21
+ export interface MemoryMatch {
22
+ node: MemoryNode;
23
+ hits: GrepHit[];
24
+ /** hits this node had beyond the ones kept */
25
+ more: number;
26
+ /** superseded by a newer node; only ever true when they were asked for */
27
+ stale: boolean;
28
+ }
29
+ /** A remembered file that was not searched, so absence of a hit proves nothing. */
30
+ export interface SkippedFile {
31
+ id: string;
32
+ /** the mount path, as the agent knows it */
33
+ path: string;
34
+ reason: Omitted;
35
+ }
36
+ export interface MemoryGrepFilter extends MatchOptions {
37
+ /** audience labels the reader holds; absent reads the graph unmasked */
38
+ sees?: readonly string[];
39
+ kinds?: readonly string[];
40
+ audience?: string;
41
+ /** only nodes that remember a file */
42
+ files?: boolean;
43
+ /** which fields to read; all three by default */
44
+ in?: readonly GrepField[];
45
+ stale?: StaleMode;
46
+ limit?: number;
47
+ hitsPerNode?: number;
48
+ }
49
+ export interface MemoryGrepResult {
50
+ /** how many nodes matched, whatever was kept */
51
+ found: number;
52
+ matches: MemoryMatch[];
53
+ truncated: boolean;
54
+ skipped: SkippedFile[];
55
+ }
56
+ export declare function grepMemory(store: MemoryStore, pattern: string, filter?: MemoryGrepFilter): Promise<MemoryGrepResult>;
57
+ //# sourceMappingURL=grep.d.ts.map
@@ -0,0 +1,182 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { PatternError, finder } from "../match.js";
3
+ import { hostPath } from "./files.js";
4
+ import { MemoryError } from "./types.js";
5
+ // ---------------------------------------------------------------------------
6
+ // The memory, read exhaustively
7
+ //
8
+ // `search` ranks and `grep` decides. They answer different questions and
9
+ // neither can be made to answer the other's: a ranking hands back the top of a
10
+ // list, so "nothing came back" and "nothing is there" are the same result, and
11
+ // the one question memory keeps raising — *did we already write this down* —
12
+ // needs them to be different results. So this is one pass, no embedder, no
13
+ // credential, and `found` is the true total even when the list was cut.
14
+ //
15
+ // Three fields, because a memory is not only its summary. `text` is what the
16
+ // ranker sees; `metadata` is where an agent puts the identifiers nobody would
17
+ // ever phrase as a sentence — a commit sha, an endpoint, a container name; and
18
+ // the bytes of a remembered file are the thing itself. An exact match belongs
19
+ // against all three.
20
+ //
21
+ // Two rules keep this honest against raw `grep` over /memory, which is what it
22
+ // exists to replace. Superseded nodes are excluded unless asked for, and come
23
+ // back flagged when they are — serving a withdrawn correction as current is
24
+ // the failure mode the mount is warned about. And reading here is not use:
25
+ // recency decay is meant to track what an agent actually opened, and a finder
26
+ // that bumped it would make every scan look like a read.
27
+ // ---------------------------------------------------------------------------
28
+ /** The three places a pattern is looked for. */
29
+ export const GREP_FIELDS = ['text', 'metadata', 'file'];
30
+ /** A scan is bounded, because a pattern may have come from a model. */
31
+ const DEADLINE_MS = 2000;
32
+ const DEADLINE_EVERY = 100;
33
+ /** Past this a remembered file is reported as unsearched rather than read. */
34
+ export const MAX_GREP_FILE_BYTES = 256 * 1024;
35
+ /** And past this, so does the rest of the scan: one memory can hold hundreds. */
36
+ export const MAX_GREP_BYTES = 8 * 1024 * 1024;
37
+ /** Enough to see why a node matched without one file crowding out every other. */
38
+ const HITS_PER_NODE = 5;
39
+ /** A matched line, as much of it as is worth carrying. */
40
+ const EXCERPT = 240;
41
+ export async function grepMemory(store, pattern, filter = {}) {
42
+ const match = compile(pattern, filter);
43
+ const fields = new Set(filter.in?.length ? filter.in : GREP_FIELDS);
44
+ const kinds = filter.kinds?.length ? new Set(filter.kinds) : undefined;
45
+ const cap = filter.hitsPerNode ?? HITS_PER_NODE;
46
+ const want = filter.stale ?? 'exclude';
47
+ const superseded = store.graph.superseded(filter.sees);
48
+ const matches = [];
49
+ const skipped = [];
50
+ const until = Date.now() + DEADLINE_MS;
51
+ let budget = MAX_GREP_BYTES;
52
+ let seen = 0;
53
+ for (const node of store.graph.nodes(filter.sees)) {
54
+ if (++seen % DEADLINE_EVERY === 0 && Date.now() > until) {
55
+ throw new MemoryError(`the pattern is still running after ${DEADLINE_MS / 1000}s — it is too expensive to be useful`, 'anchor the pattern, or narrow the scan with `kinds` or `in`');
56
+ }
57
+ const stale = superseded.has(node.id);
58
+ if (want === 'exclude' ? stale : want === 'only' && !stale) {
59
+ continue;
60
+ }
61
+ if (kinds && !kinds.has(node.kind)) {
62
+ continue;
63
+ }
64
+ if (filter.audience && !node.audience.includes(filter.audience)) {
65
+ continue;
66
+ }
67
+ if (filter.files && !node.file) {
68
+ continue;
69
+ }
70
+ const hits = [];
71
+ let total = 0;
72
+ if (fields.has('text')) {
73
+ total += scan(hits, 'text', node.text, match, cap);
74
+ }
75
+ // Indented rather than compact, so a line number names a key someone
76
+ // can search for: one-line JSON would make every hit "line 1".
77
+ if (fields.has('metadata') && node.metadata) {
78
+ total += scan(hits, 'metadata', JSON.stringify(node.metadata, null, 4), match, cap);
79
+ }
80
+ if (fields.has('file') && node.file) {
81
+ const body = await read(store.dir, node.file, budget);
82
+ if (body.omitted) {
83
+ skipped.push({ id: node.id, path: node.file.path, reason: body.omitted });
84
+ }
85
+ else {
86
+ budget -= body.bytes;
87
+ total += scan(hits, 'file', body.text, match, cap);
88
+ }
89
+ }
90
+ if (hits.length) {
91
+ matches.push({ node, hits, more: total - hits.length, stale });
92
+ }
93
+ }
94
+ // Newest first, as `zen memory ls` lists them: when a pattern matches an
95
+ // old node and the node that corrected it, the correction should read first.
96
+ matches.sort((a, b) => b.node.createdAt.localeCompare(a.node.createdAt) || a.node.id.localeCompare(b.node.id));
97
+ const found = matches.length;
98
+ const limit = filter.limit;
99
+ const truncated = limit !== undefined && limit >= 0 && limit < found;
100
+ return { found, matches: truncated ? matches.slice(0, limit) : matches, truncated, skipped };
101
+ }
102
+ // ---------------------------------------------------------------------------
103
+ /**
104
+ * A bad pattern is the model's mistake to fix, and `MemoryError` is the one
105
+ * shape both surfaces already know how to hand back rather than throw.
106
+ */
107
+ function compile(pattern, options) {
108
+ try {
109
+ return finder(pattern, options);
110
+ }
111
+ catch (err) {
112
+ if (err instanceof PatternError) {
113
+ throw new MemoryError(err.message, options.regex
114
+ ? 'fix the expression, or drop `regex` to search for the literal text'
115
+ : 'pass a shorter, non-empty pattern');
116
+ }
117
+ throw err;
118
+ }
119
+ }
120
+ /** Every matching line, of which the first `cap` are kept; the count is of all of them. */
121
+ function scan(out, where, body, match, cap) {
122
+ const lines = body.split('\n');
123
+ let total = 0;
124
+ for (let i = 0; i < lines.length; i++) {
125
+ const at = match(lines[i]);
126
+ if (at < 0) {
127
+ continue;
128
+ }
129
+ total++;
130
+ if (out.length < cap) {
131
+ out.push({ where, line: i + 1, text: excerpt(lines[i], at) });
132
+ }
133
+ }
134
+ return total;
135
+ }
136
+ /**
137
+ * The line, windowed onto the match. Clipping from the front would be right for
138
+ * prose and useless for the one case that most needs it — a minified file or a
139
+ * long serialized value, where the hit is thousands of characters in.
140
+ */
141
+ function excerpt(line, at) {
142
+ const text = line.replace(/\t/g, ' ').trimEnd();
143
+ if (text.length <= EXCERPT) {
144
+ return text;
145
+ }
146
+ const start = Math.max(0, Math.min(at - Math.floor(EXCERPT / 3), text.length - EXCERPT));
147
+ const end = start + EXCERPT;
148
+ return (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '');
149
+ }
150
+ /**
151
+ * A remembered file's bytes, or why they were not read. The recorded size is
152
+ * not trusted for the decision — what is on disk is what would have to be
153
+ * scanned — and a skip is reported rather than swallowed, because a grep that
154
+ * silently did not look is worse than one that refused to.
155
+ */
156
+ async function read(dir, file, budget) {
157
+ const path = hostPath(dir, file);
158
+ let size;
159
+ try {
160
+ size = (await stat(path)).size;
161
+ }
162
+ catch {
163
+ return { omitted: 'missing' };
164
+ }
165
+ if (size > Math.min(MAX_GREP_FILE_BYTES, budget)) {
166
+ return { omitted: 'too-big' };
167
+ }
168
+ let buf;
169
+ try {
170
+ buf = await readFile(path);
171
+ }
172
+ catch {
173
+ return { omitted: 'unreadable' };
174
+ }
175
+ // A NUL byte in the first few KiB is the cheap, boring test for "this is
176
+ // not text", and it is right about every format anyone remembers on purpose.
177
+ if (buf.subarray(0, 8192).includes(0)) {
178
+ return { omitted: 'binary' };
179
+ }
180
+ return { text: buf.toString('utf8'), bytes: buf.byteLength };
181
+ }
182
+ //# sourceMappingURL=grep.js.map
@@ -73,8 +73,16 @@ export declare class MemoryIndex {
73
73
  get graph(): MemoryGraph;
74
74
  get kinds(): readonly string[];
75
75
  get relations(): readonly string[];
76
- /** Read-only: a search returns nodes the model has not read, so nothing is touched. */
77
- search(query: MemoryQuery, sees: readonly string[]): Promise<Recollection>;
76
+ /**
77
+ * Read-only: a search returns nodes the model has not read, so nothing is
78
+ * touched. `stale` is the audit view — it puts back the superseded nodes
79
+ * recall drops, which is the only way to see the correction and the thing
80
+ * it corrected at once. No tool passes it; it is for `zen memory search`,
81
+ * where the question is why a run recalled what it did.
82
+ */
83
+ search(query: MemoryQuery, sees: readonly string[], opts?: {
84
+ stale?: boolean;
85
+ }): Promise<Recollection>;
78
86
  /**
79
87
  * Listed rather than ranked, and deliberately not routed through `recall`:
80
88
  * ranking decays by `lastUsedAt`, only `memory_load` bumps that, and a
@@ -13,7 +13,7 @@ import { ALL_AGENTS, MEMORY_KINDS, MEMORY_RELATIONS, MemoryError, PREFERENCE_KIN
13
13
  // `ref` — it cannot know an id that has not been minted — and edges join refs
14
14
  // and existing ids indiscriminately, so a whole subgraph arrives in one call.
15
15
  // Everything is validated, and every source file stat-ed, before the first
16
- // mutation, on the same principle as `runPatch` in tools/workspace.ts: a
16
+ // mutation, on the same principle as `runPatch` in tools/files.ts: a
17
17
  // commit that fails must leave no half-built graph behind.
18
18
  // ---------------------------------------------------------------------------
19
19
  /** Past this a `load` returns the path and lets the agent open it itself. */
@@ -44,8 +44,14 @@ export class MemoryIndex {
44
44
  get relations() {
45
45
  return [...this.#relations];
46
46
  }
47
- /** Read-only: a search returns nodes the model has not read, so nothing is touched. */
48
- async search(query, sees) {
47
+ /**
48
+ * Read-only: a search returns nodes the model has not read, so nothing is
49
+ * touched. `stale` is the audit view — it puts back the superseded nodes
50
+ * recall drops, which is the only way to see the correction and the thing
51
+ * it corrected at once. No tool passes it; it is for `zen memory search`,
52
+ * where the question is why a run recalled what it did.
53
+ */
54
+ async search(query, sees, opts = {}) {
49
55
  const vector = query.text ? await this.#embed([query.text], 'query') : undefined;
50
56
  return recall({
51
57
  graph: this.graph,
@@ -53,6 +59,7 @@ export class MemoryIndex {
53
59
  sees,
54
60
  vector: vector?.[0],
55
61
  vectors: this.store.vectors,
62
+ stale: opts.stale,
56
63
  });
57
64
  }
58
65
  /**
@@ -92,7 +99,11 @@ export class MemoryIndex {
92
99
  : undefined,
93
100
  });
94
101
  }
95
- await this.store.commit();
102
+ // The bump is bookkeeping, and persisting it is what would make a
103
+ // read-only recall rewrite the graph every other run is reading.
104
+ if (!this.store.readOnly) {
105
+ await this.store.commit();
106
+ }
96
107
  return out;
97
108
  }
98
109
  async commit(tx, opts) {
@@ -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()) {
@@ -1,7 +1,7 @@
1
1
  import type { MemoryGraph } from './graph.ts';
2
2
  import { type MemoryQuery, type Recollection, type ScoredNode } from './types.ts';
3
3
  import { type VectorBlock } from './vectors.ts';
4
- export declare const DEFAULT_SEEDS = 5;
4
+ export declare const DEFAULT_SEEDS = 10;
5
5
  export declare const DEFAULT_MAX_HOPS = 2;
6
6
  /** Past roughly this many nodes a rendered graph reads as soup, not structure. */
7
7
  export declare const DEFAULT_MAX_NODES = 25;
@@ -17,7 +17,7 @@ import { overlap, recencyDecay, tokenize } from "./vectors.js";
17
17
  // it replaced from every recall, while the history stays on disk for anyone
18
18
  // asking how the correction came about.
19
19
  // ---------------------------------------------------------------------------
20
- export const DEFAULT_SEEDS = 5;
20
+ export const DEFAULT_SEEDS = 10;
21
21
  export const DEFAULT_MAX_HOPS = 2;
22
22
  /** Past roughly this many nodes a rendered graph reads as soup, not structure. */
23
23
  export const DEFAULT_MAX_NODES = 25;