@zenera/neo 1.1.22 → 1.1.24

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 (42) 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 +2 -0
  4. package/dist/index.js +2 -0
  5. package/dist/inspect/client.d.ts +1 -1
  6. package/dist/inspect/client.js +7 -2
  7. package/dist/memory/graph.d.ts +14 -0
  8. package/dist/memory/graph.js +28 -0
  9. package/dist/memory/grep.d.ts +57 -0
  10. package/dist/memory/grep.js +182 -0
  11. package/dist/memory/index.d.ts +12 -2
  12. package/dist/memory/index.js +16 -5
  13. package/dist/memory/match.d.ts +18 -0
  14. package/dist/memory/match.js +61 -0
  15. package/dist/memory/merge.d.ts +65 -0
  16. package/dist/memory/merge.js +379 -0
  17. package/dist/memory/recall.d.ts +1 -1
  18. package/dist/memory/recall.js +1 -1
  19. package/dist/memory/store.d.ts +18 -0
  20. package/dist/memory/store.js +31 -4
  21. package/dist/memory/tools.js +105 -9
  22. package/dist/memory/types.d.ts +7 -2
  23. package/dist/memory/vectors.d.ts +2 -0
  24. package/dist/memory/vectors.js +7 -0
  25. package/dist/models/factory.d.ts +43 -2
  26. package/dist/models/factory.js +108 -1
  27. package/dist/project/config.js +1 -1
  28. package/dist/project/load.d.ts +7 -0
  29. package/dist/project/load.js +29 -6
  30. package/dist/tools/exa.js +1 -1
  31. package/dist/tools/files.d.ts +81 -0
  32. package/dist/tools/files.js +1472 -0
  33. package/dist/tools/index.d.ts +1 -1
  34. package/dist/tools/index.js +1 -1
  35. package/dist/tools/sandbox.d.ts +44 -0
  36. package/dist/tools/sandbox.js +122 -3
  37. package/dist/tools/workspace.js +81 -3
  38. package/dist/trajectory.d.ts +19 -1
  39. package/dist/trajectory.js +53 -0
  40. package/dist/types.d.ts +1 -0
  41. package/dist/types.js +1 -0
  42. package/package.json +1 -1
@@ -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 { hostPath } from "./files.js";
3
+ import { PatternError, matcher } from "./match.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 matcher(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
@@ -91,4 +99,6 @@ export declare class MemoryIndex {
91
99
  commit(tx: MemoryTransaction, opts: CommitOptions): Promise<CommitResult>;
92
100
  forget(ids: readonly string[], sees: readonly string[]): Promise<MemoryNode[]>;
93
101
  }
102
+ /** Whitespace and case are not what makes two memories different. */
103
+ export declare function flatten(text: string): string;
94
104
  //# sourceMappingURL=index.d.ts.map
@@ -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) {
@@ -383,7 +394,7 @@ export class MemoryIndex {
383
394
  }
384
395
  }
385
396
  /** Whitespace and case are not what makes two memories different. */
386
- function flatten(text) {
397
+ export function flatten(text) {
387
398
  return text.replace(/\s+/g, ' ').trim().toLowerCase();
388
399
  }
389
400
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,18 @@
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
+ /** The offset of the first match in `text`, or -1. */
11
+ export type Matcher = (text: string) => number;
12
+ /**
13
+ * A finder over a string. Literal by default: someone typing `user.id` means
14
+ * those seven characters, and a dot that quietly matched anything would be a
15
+ * worse answer than no answer.
16
+ */
17
+ export declare function matcher(pattern: string, options?: MatchOptions): Matcher;
18
+ //# sourceMappingURL=match.d.ts.map
@@ -0,0 +1,61 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Matching, with nothing learned in between
3
+ //
4
+ // Everything here is exact. A substring is a substring, which is the whole
5
+ // point of the surface built on it: the vector index answers "what is near
6
+ // this", and near is a ranking, so it can only ever return the top of a list.
7
+ // When the question is "does the word `password` appear anywhere at all", a
8
+ // ranking is the wrong instrument and no amount of tuning makes it the right
9
+ // one.
10
+ //
11
+ // This is a copy of `common/match.ts` in @zenera/rag, and has to be: the
12
+ // dependency runs rag → cli → neo, so neo importing rag would close a cycle.
13
+ // Two differences earn their place rather than being drift. A matcher returns
14
+ // the offset it matched at, because grep reports *where*, not merely whether;
15
+ // and there are no globs, because the haystack here is prose and a glob is
16
+ // anchored to the whole string, so `error*` would match a line only when the
17
+ // line began with the word.
18
+ //
19
+ // A pattern may arrive from a model, so a regex is a bounded promise: the
20
+ // length is capped here and the scan that uses it keeps a deadline.
21
+ // ---------------------------------------------------------------------------
22
+ /** Long enough for any honest pattern, short enough to bound a bad one. */
23
+ export const MAX_PATTERN = 200;
24
+ export class PatternError extends Error {
25
+ }
26
+ /**
27
+ * A finder over a string. Literal by default: someone typing `user.id` means
28
+ * those seven characters, and a dot that quietly matched anything would be a
29
+ * worse answer than no answer.
30
+ */
31
+ export function matcher(pattern, options = {}) {
32
+ guard(pattern);
33
+ if (!options.regex) {
34
+ if (options.caseSensitive) {
35
+ return (text) => text.indexOf(pattern);
36
+ }
37
+ const needle = pattern.toLowerCase();
38
+ return (text) => text.toLowerCase().indexOf(needle);
39
+ }
40
+ const expression = compile(pattern, options.caseSensitive ? '' : 'i');
41
+ // `lastIndex` is not carried between calls: the flags never include `g`.
42
+ return (text) => text.search(expression);
43
+ }
44
+ // ---------------------------------------------------------------------------
45
+ function guard(pattern) {
46
+ if (pattern.length === 0) {
47
+ throw new PatternError('the pattern is empty');
48
+ }
49
+ if (pattern.length > MAX_PATTERN) {
50
+ throw new PatternError(`the pattern is longer than ${MAX_PATTERN} characters`);
51
+ }
52
+ }
53
+ function compile(source, flags) {
54
+ try {
55
+ return new RegExp(source, flags);
56
+ }
57
+ catch (err) {
58
+ throw new PatternError(`invalid pattern: ${err.message}`);
59
+ }
60
+ }
61
+ //# sourceMappingURL=match.js.map
@@ -0,0 +1,65 @@
1
+ import { MemoryStore, type MemoryEmbedding } from './store.ts';
2
+ import { MemoryError } from './types.ts';
3
+ export interface MergeOptions {
4
+ /** fold near-identical memories onto the one already there; on by default */
5
+ dedupe?: boolean;
6
+ /** resolve divergence by taking the highest revision instead of refusing */
7
+ force?: boolean;
8
+ /** plan and report, write nothing */
9
+ dryRun?: boolean;
10
+ }
11
+ export interface MergeSource {
12
+ dir: string;
13
+ /** what the source holds */
14
+ nodes: number;
15
+ edges: number;
16
+ /** what it contributed */
17
+ added: number;
18
+ /** ids the target already had, reconciled rather than duplicated */
19
+ shared: number;
20
+ /** new ids that turned out to be a memory the target already held */
21
+ twins: number;
22
+ files: number;
23
+ }
24
+ export interface MergeConflict {
25
+ id: string;
26
+ /** the source that disagreed */
27
+ dir: string;
28
+ /** revision already in the merge, and the one the source brought */
29
+ mine: number;
30
+ theirs: number;
31
+ text: string;
32
+ }
33
+ export interface MergeReport {
34
+ /** the memory everything was folded into */
35
+ dir: string;
36
+ sources: MergeSource[];
37
+ added: number;
38
+ shared: number;
39
+ twins: number;
40
+ /** edges the merge added, after re-pointing folded ends */
41
+ edges: number;
42
+ files: number;
43
+ embedding?: MemoryEmbedding;
44
+ dryRun: boolean;
45
+ }
46
+ /**
47
+ * Refusal carrying what to look at. Thrown before anything is written, so a
48
+ * conflicted merge leaves the target exactly as it was.
49
+ */
50
+ export declare class MergeConflicts extends MemoryError {
51
+ readonly conflicts: readonly MergeConflict[];
52
+ constructor(conflicts: readonly MergeConflict[]);
53
+ }
54
+ /**
55
+ * Fold `sources` into `target`, left to right.
56
+ *
57
+ * Order is visible: a memory folded onto a twin keeps the id of whichever copy
58
+ * arrived first, so `merge a b` and `merge b a` can differ in which id
59
+ * survives. They never differ in what is remembered.
60
+ *
61
+ * On `dryRun` the target is left with the merge applied in memory and nothing
62
+ * on disk. Report the result and discard the store; do not commit it.
63
+ */
64
+ export declare function mergeMemories(target: MemoryStore, sources: readonly string[], opts?: MergeOptions): Promise<MergeReport>;
65
+ //# sourceMappingURL=merge.d.ts.map