@zenera/neo 1.1.23 → 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.
@@ -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
@@ -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) {
@@ -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
@@ -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;
@@ -29,11 +29,18 @@ export interface OpenOptions {
29
29
  embedding?: MemoryEmbedding;
30
30
  /** take the directory lock; off for read-only inspection (`zen memory ls`) */
31
31
  lock?: boolean;
32
+ /**
33
+ * Recall, and nothing else. Writing is refused rather than dropped, the
34
+ * directory is never created, and no lock is taken — so any number of runs
35
+ * may share one graph at once, which is the whole point of it.
36
+ */
37
+ readOnly?: boolean;
32
38
  }
33
39
  export declare class MemoryStore {
34
40
  #private;
35
41
  readonly dir: string;
36
42
  readonly graph: MemoryGraph;
43
+ readonly readOnly: boolean;
37
44
  private constructor();
38
45
  static open(dir: string, opts?: OpenOptions): Promise<MemoryStore>;
39
46
  get embedding(): MemoryEmbedding | undefined;
@@ -34,18 +34,29 @@ export const LOCK_FILE = '.lock';
34
34
  export class MemoryStore {
35
35
  dir;
36
36
  graph;
37
+ readOnly;
37
38
  #embedding;
38
39
  #vectors;
39
40
  #locked = false;
40
- constructor(dir, graph, vectors, embedding) {
41
+ constructor(dir, graph, vectors, embedding, readOnly) {
41
42
  this.dir = dir;
42
43
  this.graph = graph;
43
44
  this.#vectors = vectors;
44
45
  this.#embedding = embedding;
46
+ this.readOnly = readOnly;
45
47
  }
46
48
  static async open(dir, opts = {}) {
47
- await mkdir(join(dir, FILES_DIR), { recursive: true });
49
+ const readOnly = opts.readOnly === true;
50
+ if (!readOnly) {
51
+ await mkdir(join(dir, FILES_DIR), { recursive: true });
52
+ }
48
53
  const manifest = await readManifest(dir);
54
+ // Opening for writing brings a memory into existence; opening one to
55
+ // read from has to find it, or a mistyped path is an empty graph that
56
+ // answers every question with silence and reports no error.
57
+ if (readOnly && !manifest) {
58
+ throw new MemoryError(`${dir} is not a memory`, `no ${MANIFEST_FILE} in it, and a read-only open cannot create one`);
59
+ }
49
60
  if (manifest?.embedding && opts.embedding) {
50
61
  const a = manifest.embedding;
51
62
  const b = opts.embedding;
@@ -59,8 +70,10 @@ export class MemoryStore {
59
70
  : new MemoryGraph();
60
71
  const embedding = opts.embedding ?? manifest?.embedding;
61
72
  const vectors = manifest && embedding ? await readVectors(dir, embedding) : undefined;
62
- const store = new MemoryStore(dir, graph, vectors, embedding);
63
- if (opts.lock !== false) {
73
+ const store = new MemoryStore(dir, graph, vectors, embedding, readOnly);
74
+ // A reader that claimed the lock would only be refusing itself, and the
75
+ // readers of one graph are meant to be able to refuse each other least.
76
+ if (!readOnly && opts.lock !== false) {
64
77
  store.#claim();
65
78
  }
66
79
  return store;
@@ -97,6 +110,11 @@ export class MemoryStore {
97
110
  }
98
111
  /** Manifest last: a torn commit reads as no memory, never as a partial one. */
99
112
  async commit() {
113
+ // Loudly, not quietly. A write that is silently dropped is a run that
114
+ // believes it remembered something and did not.
115
+ if (this.readOnly) {
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
+ }
100
118
  await atomic(join(this.dir, GRAPH_FILE), JSON.stringify(this.graph.export()));
101
119
  const vectors = this.#vectors;
102
120
  if (vectors) {
@@ -1,25 +1,30 @@
1
1
  /**
2
- * The four tools an agent gets. Deliberately four and not seven: `search` and
3
- * `load` are split because finding is not reading — search returns stitched
4
- * context the model never asked for, and counting that as use would poison
5
- * recency. `commit` is one transaction because a remembered thing is a
6
- * subgraph, and building it with three calls leaves the graph half-formed if
7
- * the model stops early. `forget` is separate because deleting rarely happens
8
- * in the same breath as creating, and a `forget` buried inside a truncated
9
- * commit preview would be both irreversible and invisible.
2
+ * The five tools an agent gets. `search` and `load` are split because finding
3
+ * is not reading — search returns stitched context the model never asked for,
4
+ * and counting that as use would poison recency. `grep` is separate from
5
+ * `search` because exactness is not a tuning of nearness: a ranking returns the
6
+ * top of a list, so it can say what is closest but never that nothing is there.
7
+ * `commit` is one transaction because a remembered thing is a subgraph, and
8
+ * building it with three calls leaves the graph half-formed if the model stops
9
+ * early. `forget` is separate because deleting rarely happens in the same
10
+ * breath as creating, and a `forget` buried inside a truncated commit preview
11
+ * would be both irreversible and invisible.
10
12
  *
11
13
  * `audience` reaches the schema only when the binding grants more than one
12
14
  * label, and `sees` never does: an agent that could name its own audience
13
15
  * could read another agent's slice just by asking for it.
14
16
  */
15
17
  import { hash } from "../payload.js";
16
- import { MEMORY_COMMIT_TOOL, MEMORY_FORGET_TOOL, MEMORY_LOAD_TOOL, MEMORY_SEARCH_TOOL, tool, withEffects, } from "../types.js";
18
+ import { MEMORY_COMMIT_TOOL, MEMORY_FORGET_TOOL, MEMORY_GREP_TOOL, MEMORY_LOAD_TOOL, MEMORY_SEARCH_TOOL, tool, withEffects, } from "../types.js";
19
+ import { GREP_FIELDS, grepMemory } from "./grep.js";
17
20
  import { renderRecollection } from "./render.js";
18
21
  import { canForget, canWrite, KIND_HELP, MemoryError, RELATION_HELP, } from "./types.js";
19
22
  /** Deterministic, so a replayed call is the same op and not a second one. */
20
23
  export function memoryOpId(runId, callId) {
21
24
  return hash(`${runId}\u0000${callId}`);
22
25
  }
26
+ /** Enough nodes to settle a question, few enough not to be a context dump. */
27
+ const GREPPED = 20;
23
28
  export function memoryTools(opts) {
24
29
  const { index, binding } = opts;
25
30
  const kinds = [...index.kinds];
@@ -131,6 +136,97 @@ export function memoryTools(opts) {
131
136
  })), { kind: 'memory_op', spec });
132
137
  },
133
138
  }),
139
+ tool({
140
+ name: MEMORY_GREP_TOOL,
141
+ description: 'Find an exact string in memory, the way grep does: every node whose text, ' +
142
+ 'metadata or remembered file contains it, with the matching lines. Use this ' +
143
+ `instead of ${MEMORY_SEARCH_TOOL} when the answer has to be complete rather ` +
144
+ 'than close — whether something was already recorded, or where a name, path, ' +
145
+ 'id or command appears. This is the only correct way to search the memory ' +
146
+ 'directly; never run a shell grep over /memory. Read a whole node with ' +
147
+ `${MEMORY_LOAD_TOOL}.`,
148
+ parameters: {
149
+ type: 'object',
150
+ properties: {
151
+ pattern: {
152
+ type: 'string',
153
+ description: 'the exact text to look for; matched case-insensitively',
154
+ },
155
+ regex: {
156
+ type: 'boolean',
157
+ description: 'read the pattern as a regular expression, matched per line',
158
+ },
159
+ case_sensitive: { type: 'boolean', description: 'match case exactly' },
160
+ kinds: {
161
+ type: 'array',
162
+ items: { type: 'string', enum: kinds },
163
+ description: 'restrict to these kinds',
164
+ },
165
+ in: {
166
+ type: 'array',
167
+ items: { type: 'string', enum: [...GREP_FIELDS] },
168
+ description: 'which parts to look in; all of them by default',
169
+ },
170
+ include_superseded: {
171
+ type: 'boolean',
172
+ description: 'also search nodes that have been corrected by a newer one; they come back marked stale',
173
+ },
174
+ limit: { type: 'integer', description: `nodes to return; default ${GREPPED}` },
175
+ },
176
+ required: ['pattern'],
177
+ additionalProperties: false,
178
+ },
179
+ execute: async (args, tc) => {
180
+ const opId = memoryOpId(tc.state.runId, tc.callId);
181
+ let res;
182
+ try {
183
+ res = await grepMemory(index.store, args.pattern, {
184
+ sees: binding.sees,
185
+ regex: args.regex,
186
+ caseSensitive: args.case_sensitive,
187
+ kinds: args.kinds,
188
+ in: args.in,
189
+ stale: args.include_superseded ? 'include' : 'exclude',
190
+ limit: args.limit ?? GREPPED,
191
+ });
192
+ }
193
+ catch (err) {
194
+ return refusal(err);
195
+ }
196
+ // A complete answer, so nothing found is information rather than
197
+ // a failed lookup — that is the whole reason this tool exists.
198
+ if (!res.found && !res.skipped.length) {
199
+ return `nothing in memory contains ${JSON.stringify(args.pattern)}`;
200
+ }
201
+ const spec = {
202
+ kind: 'op',
203
+ op: 'grep',
204
+ opId,
205
+ nodes: res.matches.map((m) => ({
206
+ id: m.node.id,
207
+ kind: m.node.kind,
208
+ revision: m.node.revision,
209
+ })),
210
+ edges: [],
211
+ files: 0,
212
+ };
213
+ return withEffects({
214
+ found: res.found,
215
+ truncated: res.truncated || undefined,
216
+ matches: res.matches.map((m) => ({
217
+ id: m.node.id,
218
+ kind: m.node.kind,
219
+ stale: m.stale || undefined,
220
+ file: m.node.file?.path,
221
+ hits: m.hits.map((h) => ({ in: h.where, line: h.line, text: h.text })),
222
+ more: m.more || undefined,
223
+ })),
224
+ // Named, because a file that was not read means a hit in
225
+ // it would not have been reported either.
226
+ unsearched: res.skipped.length ? res.skipped : undefined,
227
+ }, { kind: 'memory_op', spec });
228
+ },
229
+ }),
134
230
  ];
135
231
  if (!canWrite(binding)) {
136
232
  return tools;
@@ -169,8 +169,13 @@ export interface MemoryRecallSpec {
169
169
  }
170
170
  export interface MemoryOpSpec {
171
171
  kind: 'op';
172
- /** `load` is here rather than under recall: it hands the model whole bodies, and it writes. */
173
- op: 'commit' | 'forget' | 'load';
172
+ /**
173
+ * `load` is here rather than under recall: it hands the model whole bodies,
174
+ * and it writes. `grep` is here because it is not a recall either — there is
175
+ * no query vector, no subgraph and no ranking to record, only the nodes a
176
+ * pattern happened to hit.
177
+ */
178
+ op: 'commit' | 'forget' | 'load' | 'grep';
174
179
  /** sha256(runId, callId) — deterministic, so a replay is deduplicated */
175
180
  opId: string;
176
181
  /** nodes this op created, changed, removed or read in full */
@@ -83,8 +83,9 @@ export interface ProviderSpec extends Credentials {
83
83
  }
84
84
  /**
85
85
  * What to ask for, and of whom. Vendor differences live in the *provider*, so
86
- * there is nothing here to discriminate on: the option interfaces are unioned
87
- * and the knobs that do not apply to the chosen vendor are ignored.
86
+ * there is nothing here to discriminate on: the option interfaces are unioned,
87
+ * which makes every vendor's knobs type-legal on every model. `model()` rejects
88
+ * the ones the resolved adapter would not read — see `KNOBS`.
88
89
  *
89
90
  * `Credentials` are still accepted inline, for a one-off model that does not
90
91
  * warrant a declared provider. Using them opts out of the shared client.
@@ -149,6 +150,39 @@ export interface ModelRequirement {
149
150
  }
150
151
  export declare const providerKinds: ProviderKind[];
151
152
  export declare function isProviderKind(name: string): name is ProviderKind;
153
+ /**
154
+ * Which knobs each adapter actually reads.
155
+ *
156
+ * `ModelSpec` unions every vendor's option interface, so `reasoningEffort` on a
157
+ * Gemini model is type-legal and the yaml schema accepts it too — and until
158
+ * this table it was dropped in silence, which is the worst outcome available:
159
+ * the config claims a setting is in force, the request never carries it, and
160
+ * the run looks healthy. No static type could have caught it either, because
161
+ * `provider` is a free name and its kind is not known until resolution.
162
+ *
163
+ * Translating instead was the alternative, and it is worse. The vendors do not
164
+ * mean the same thing by the same word: effort is a coarse level on OpenAI, a
165
+ * token budget on Anthropic, and *both* on Google depending on the model
166
+ * generation. A silent remap would make one line mean four things and bury the
167
+ * 2.5/3 split that `gemini.ts` deliberately refuses to paper over.
168
+ */
169
+ declare const KNOBS: {
170
+ readonly 'openai/chat': readonly ["reasoningEffort"];
171
+ readonly 'openai/responses': readonly ["reasoningEffort", "reasoningSummary", "store"];
172
+ readonly anthropic: readonly ["maxTokens", "thinkingBudgetTokens"];
173
+ readonly gemini: readonly ["maxTokens", "thinkingBudget", "thinkingLevel", "includeThoughts"];
174
+ readonly openrouter: readonly ["maxTokens", "reasoningEffort", "reasoningSummary", "routing", "fallbacks", "plugins", "serviceTier"];
175
+ };
176
+ /** An adapter, in the terms `KNOBS` is keyed by: a protocol, split by api where there are two. */
177
+ export type KnobTarget = keyof typeof KNOBS;
178
+ /** A knob a spec carries that the adapter it resolves to will never read. */
179
+ export interface KnobIssue {
180
+ knob: string;
181
+ /** the adapter that would have had to read it */
182
+ target: KnobTarget;
183
+ /** what that adapter does read */
184
+ supported: readonly string[];
185
+ }
152
186
  /**
153
187
  * Substitutes `${VAR}` references. An unset variable with no fallback is an
154
188
  * error naming both the variable and where it was written, because the
@@ -176,6 +210,12 @@ export declare class ModelRegistry {
176
210
  names(): string[];
177
211
  /** The shared client for a provider, built on first use. */
178
212
  client(name: string): ProviderClient;
213
+ /**
214
+ * Knobs a ref carries that its adapter will never read — the same check
215
+ * `model()` refuses on, minus the client, so a report can name them before
216
+ * a credential is resolved or anything is contacted.
217
+ */
218
+ knobs(ref: ModelRef): KnobIssue[];
179
219
  /** Turns a shorthand or a spec into a `Model`. */
180
220
  model(ref: ModelRef): Model;
181
221
  /**
@@ -215,4 +255,5 @@ export declare const defaultModels: ModelRegistry;
215
255
  export declare function createModel(ref: ModelRef, registry?: ModelRegistry): Model;
216
256
  /** `createModel`'s counterpart for vectors, over the same registry and clients. */
217
257
  export declare function createEmbedder(ref: EmbeddingRef, registry?: ModelRegistry): Embedder;
258
+ export {};
218
259
  //# sourceMappingURL=factory.d.ts.map