@zenera/neo 1.1.11 → 1.1.12
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 +3 -3
- package/dist/agent.d.ts +8 -7
- package/dist/agent.js +15 -8
- package/dist/architecture.d.ts +7 -5
- package/dist/architecture.js +20 -12
- package/dist/index.d.ts +12 -2
- package/dist/index.js +12 -2
- package/dist/inspect/client.d.ts +1 -1
- package/dist/inspect/client.js +42 -26
- package/dist/kernel.d.ts +1 -1
- package/dist/kernel.js +40 -23
- package/dist/memory/files.d.ts +24 -0
- package/dist/memory/files.js +85 -0
- package/dist/memory/graph.d.ts +56 -0
- package/dist/memory/graph.js +159 -0
- package/dist/memory/index.d.ts +93 -0
- package/dist/memory/index.js +357 -0
- package/dist/memory/instructions.d.ts +15 -0
- package/dist/memory/instructions.js +103 -0
- package/dist/memory/page.d.ts +8 -0
- package/dist/memory/page.js +797 -0
- package/dist/memory/recall.d.ts +35 -0
- package/dist/memory/recall.js +161 -0
- package/dist/memory/render.d.ts +10 -0
- package/dist/memory/render.js +228 -0
- package/dist/memory/report.d.ts +57 -0
- package/dist/memory/report.js +113 -0
- package/dist/memory/store.d.ts +49 -0
- package/dist/memory/store.js +226 -0
- package/dist/memory/tools.d.ts +11 -0
- package/dist/memory/tools.js +310 -0
- package/dist/memory/types.d.ts +192 -0
- package/dist/memory/types.js +105 -0
- package/dist/memory/vectors.d.ts +29 -0
- package/dist/memory/vectors.js +152 -0
- package/dist/project/config.d.ts +49 -1
- package/dist/project/config.js +63 -0
- package/dist/project/index.d.ts +2 -2
- package/dist/project/index.js +1 -1
- package/dist/project/load.d.ts +16 -2
- package/dist/project/load.js +67 -1
- package/dist/runner.d.ts +4 -2
- package/dist/runner.js +29 -30
- package/dist/services.d.ts +23 -6
- package/dist/services.js +25 -15
- package/dist/tools/workspace.js +195 -17
- package/dist/trajectory.d.ts +14 -12
- package/dist/types.d.ts +5 -5
- package/dist/types.js +3 -3
- package/package.json +2 -5
- package/dist/memory-stores/factory.d.ts +0 -15
- package/dist/memory-stores/factory.js +0 -29
- package/dist/memory-stores/file.d.ts +0 -37
- package/dist/memory-stores/file.js +0 -174
- package/dist/memory-stores/in-memory.d.ts +0 -17
- package/dist/memory-stores/in-memory.js +0 -80
- package/dist/memory-stores/index.d.ts +0 -4
- package/dist/memory-stores/index.js +0 -5
- package/dist/memory.d.ts +0 -115
- package/dist/memory.js +0 -213
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Embedder } from '../embedding.ts';
|
|
2
|
+
import type { IdClock } from '../ids.ts';
|
|
3
|
+
import type { MemoryGraph } from './graph.ts';
|
|
4
|
+
import type { MemoryStore } from './store.ts';
|
|
5
|
+
import { type MemoryNode, type MemoryQuery, type Recollection } from './types.ts';
|
|
6
|
+
/** Past this a `load` returns the path and lets the agent open it itself. */
|
|
7
|
+
export declare const INLINE_FILE_BYTES: number;
|
|
8
|
+
/**
|
|
9
|
+
* Cosine at or above this is one memory written twice, not two memories. Set
|
|
10
|
+
* high on purpose: merging two things that only resembled each other loses a
|
|
11
|
+
* memory outright, while missing a merge only wastes a node.
|
|
12
|
+
*/
|
|
13
|
+
export declare const DUPLICATE_SCORE = 0.97;
|
|
14
|
+
export interface MemoryIndexOptions {
|
|
15
|
+
store: MemoryStore;
|
|
16
|
+
/** absent ranks by term overlap and stores no vectors */
|
|
17
|
+
embedder?: Embedder;
|
|
18
|
+
/** the project's vocabulary, when it extends the built-in one */
|
|
19
|
+
kinds?: readonly string[];
|
|
20
|
+
relations?: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
export interface CommitNode {
|
|
23
|
+
/** names this node within the transaction; for a node being created */
|
|
24
|
+
ref?: string;
|
|
25
|
+
/** an existing node to update; mutually exclusive with `ref` */
|
|
26
|
+
id?: string;
|
|
27
|
+
kind?: string;
|
|
28
|
+
text?: string;
|
|
29
|
+
audience?: string[];
|
|
30
|
+
metadata?: Record<string, unknown>;
|
|
31
|
+
/** absolute host path of a file to copy in, already resolved by the caller */
|
|
32
|
+
file?: {
|
|
33
|
+
source: string;
|
|
34
|
+
};
|
|
35
|
+
expectedRevision?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface CommitEdge {
|
|
38
|
+
from: string;
|
|
39
|
+
to: string;
|
|
40
|
+
relation: string;
|
|
41
|
+
}
|
|
42
|
+
export interface MemoryTransaction {
|
|
43
|
+
nodes?: CommitNode[];
|
|
44
|
+
edges?: CommitEdge[];
|
|
45
|
+
}
|
|
46
|
+
export interface CommitOptions {
|
|
47
|
+
/** audience labels this agent may assign */
|
|
48
|
+
writes: readonly string[];
|
|
49
|
+
/** labels it may read; a duplicate is only a duplicate if it can see it */
|
|
50
|
+
sees: readonly string[];
|
|
51
|
+
clock: IdClock;
|
|
52
|
+
}
|
|
53
|
+
export interface CommitResult {
|
|
54
|
+
/** local ref (or id, for an update) to the id it resolved to */
|
|
55
|
+
ids: Record<string, string>;
|
|
56
|
+
created: number;
|
|
57
|
+
updated: number;
|
|
58
|
+
edges: number;
|
|
59
|
+
files: number;
|
|
60
|
+
/** new nodes that turned out to already exist, and were folded into them */
|
|
61
|
+
merged: number;
|
|
62
|
+
}
|
|
63
|
+
export interface LoadedNode {
|
|
64
|
+
node: MemoryNode;
|
|
65
|
+
/** the file's bytes, when it was small enough to inline */
|
|
66
|
+
content?: string;
|
|
67
|
+
}
|
|
68
|
+
export declare class MemoryIndex {
|
|
69
|
+
#private;
|
|
70
|
+
readonly store: MemoryStore;
|
|
71
|
+
constructor(opts: MemoryIndexOptions);
|
|
72
|
+
get graph(): MemoryGraph;
|
|
73
|
+
get kinds(): readonly string[];
|
|
74
|
+
get relations(): readonly string[];
|
|
75
|
+
/** Read-only: a search returns nodes the model has not read, so nothing is touched. */
|
|
76
|
+
search(query: MemoryQuery, sees: readonly string[]): Promise<Recollection>;
|
|
77
|
+
/**
|
|
78
|
+
* Listed rather than ranked, and deliberately not routed through `recall`:
|
|
79
|
+
* ranking decays by `lastUsedAt`, only `memory_load` bumps that, and a
|
|
80
|
+
* preference is injected rather than loaded — so it would age out of its
|
|
81
|
+
* own list. Ordered so the rendered block is byte-stable between runs,
|
|
82
|
+
* because an unstable one would invalidate the prompt prefix for nothing.
|
|
83
|
+
*/
|
|
84
|
+
preferences(sees: readonly string[]): MemoryNode[];
|
|
85
|
+
/**
|
|
86
|
+
* The only path that counts as use — recency decay is meant to track what
|
|
87
|
+
* an agent actually read, and a search hands back context it never opened.
|
|
88
|
+
*/
|
|
89
|
+
load(ids: readonly string[], sees: readonly string[], clock: IdClock): Promise<LoadedNode[]>;
|
|
90
|
+
commit(tx: MemoryTransaction, opts: CommitOptions): Promise<CommitResult>;
|
|
91
|
+
forget(ids: readonly string[], sees: readonly string[]): Promise<MemoryNode[]>;
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { forgetFile, hostPath, rememberFile } from "./files.js";
|
|
3
|
+
import { recall } from "./recall.js";
|
|
4
|
+
import { ALL_AGENTS, MEMORY_KINDS, MEMORY_RELATIONS, MemoryError, PREFERENCE_KIND, visible, } from "./types.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// The memory, as the engine uses it
|
|
7
|
+
//
|
|
8
|
+
// `MemoryStore` knows how to persist a graph; this knows what the operations
|
|
9
|
+
// mean. It owns the two things that must not drift: the vector for a node's
|
|
10
|
+
// text, and the bytes of a remembered file.
|
|
11
|
+
//
|
|
12
|
+
// `commit` is one transaction. The model names nodes it is creating by a local
|
|
13
|
+
// `ref` — it cannot know an id that has not been minted — and edges join refs
|
|
14
|
+
// and existing ids indiscriminately, so a whole subgraph arrives in one call.
|
|
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
|
|
17
|
+
// commit that fails must leave no half-built graph behind.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
/** Past this a `load` returns the path and lets the agent open it itself. */
|
|
20
|
+
export const INLINE_FILE_BYTES = 32 * 1024;
|
|
21
|
+
/**
|
|
22
|
+
* Cosine at or above this is one memory written twice, not two memories. Set
|
|
23
|
+
* high on purpose: merging two things that only resembled each other loses a
|
|
24
|
+
* memory outright, while missing a merge only wastes a node.
|
|
25
|
+
*/
|
|
26
|
+
export const DUPLICATE_SCORE = 0.97;
|
|
27
|
+
export class MemoryIndex {
|
|
28
|
+
store;
|
|
29
|
+
#embedder;
|
|
30
|
+
#kinds;
|
|
31
|
+
#relations;
|
|
32
|
+
constructor(opts) {
|
|
33
|
+
this.store = opts.store;
|
|
34
|
+
this.#embedder = opts.embedder;
|
|
35
|
+
this.#kinds = new Set([...(opts.kinds ?? MEMORY_KINDS), PREFERENCE_KIND]);
|
|
36
|
+
this.#relations = new Set(opts.relations ?? MEMORY_RELATIONS);
|
|
37
|
+
}
|
|
38
|
+
get graph() {
|
|
39
|
+
return this.store.graph;
|
|
40
|
+
}
|
|
41
|
+
get kinds() {
|
|
42
|
+
return [...this.#kinds];
|
|
43
|
+
}
|
|
44
|
+
get relations() {
|
|
45
|
+
return [...this.#relations];
|
|
46
|
+
}
|
|
47
|
+
/** Read-only: a search returns nodes the model has not read, so nothing is touched. */
|
|
48
|
+
async search(query, sees) {
|
|
49
|
+
const vector = query.text ? await this.#embed([query.text], 'query') : undefined;
|
|
50
|
+
return recall({
|
|
51
|
+
graph: this.graph,
|
|
52
|
+
query,
|
|
53
|
+
sees,
|
|
54
|
+
vector: vector?.[0],
|
|
55
|
+
vectors: this.store.vectors,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Listed rather than ranked, and deliberately not routed through `recall`:
|
|
60
|
+
* ranking decays by `lastUsedAt`, only `memory_load` bumps that, and a
|
|
61
|
+
* preference is injected rather than loaded — so it would age out of its
|
|
62
|
+
* own list. Ordered so the rendered block is byte-stable between runs,
|
|
63
|
+
* because an unstable one would invalidate the prompt prefix for nothing.
|
|
64
|
+
*/
|
|
65
|
+
preferences(sees) {
|
|
66
|
+
const stale = this.graph.superseded(sees);
|
|
67
|
+
return this.graph
|
|
68
|
+
.nodes(sees)
|
|
69
|
+
.filter((n) => n.kind === PREFERENCE_KIND && !stale.has(n.id))
|
|
70
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The only path that counts as use — recency decay is meant to track what
|
|
74
|
+
* an agent actually read, and a search hands back context it never opened.
|
|
75
|
+
*/
|
|
76
|
+
async load(ids, sees, clock) {
|
|
77
|
+
const at = clock.now();
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const id of ids) {
|
|
80
|
+
const node = this.graph.get(id);
|
|
81
|
+
// Indistinguishable from "no such node" on purpose: a refusal that
|
|
82
|
+
// said "you may not see this" would confirm the node exists.
|
|
83
|
+
if (!node || !visible(node, sees)) {
|
|
84
|
+
throw new MemoryError(`no memory ${id}`, 'use an id from the legend of a memory_search result');
|
|
85
|
+
}
|
|
86
|
+
const touched = this.graph.touch(id, at) ?? node;
|
|
87
|
+
const file = touched.file;
|
|
88
|
+
out.push({
|
|
89
|
+
node: touched,
|
|
90
|
+
content: file && file.bytes <= INLINE_FILE_BYTES
|
|
91
|
+
? await readFile(hostPath(this.store.dir, file), 'utf8')
|
|
92
|
+
: undefined,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
await this.store.commit();
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
async commit(tx, opts) {
|
|
99
|
+
const nodes = tx.nodes ?? [];
|
|
100
|
+
const edges = tx.edges ?? [];
|
|
101
|
+
if (!nodes.length && !edges.length) {
|
|
102
|
+
throw new MemoryError('a commit needs at least one node or edge');
|
|
103
|
+
}
|
|
104
|
+
const plan = this.#plan(nodes, edges, opts);
|
|
105
|
+
const vectors = await this.#embed(plan.embed.map((e) => e.text), 'document');
|
|
106
|
+
// Keyed before anything is dropped: `#merge` reshapes `plan.embed`, and
|
|
107
|
+
// the vectors only line up with it positionally until it does.
|
|
108
|
+
const vector = new Map(plan.embed.map((e, i) => [e.id, vectors?.[i]]));
|
|
109
|
+
const merged = this.#merge(plan, vector, opts.sees);
|
|
110
|
+
// Files first, so a failed copy can be undone before the graph knows.
|
|
111
|
+
const written = [];
|
|
112
|
+
try {
|
|
113
|
+
for (const item of plan.files) {
|
|
114
|
+
const file = await rememberFile(this.store.dir, item.source, item.id);
|
|
115
|
+
written.push(file);
|
|
116
|
+
item.target.file = file;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
await Promise.all(written.map((f) => forgetFile(this.store.dir, f)));
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
const at = opts.clock.now();
|
|
124
|
+
let created = 0;
|
|
125
|
+
let updated = 0;
|
|
126
|
+
for (const item of plan.nodes) {
|
|
127
|
+
if (item.existing) {
|
|
128
|
+
this.graph.update(item.id, item.patch, at);
|
|
129
|
+
updated++;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
this.graph.add(item.draft, at);
|
|
133
|
+
created++;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const edge of plan.edges) {
|
|
137
|
+
this.graph.link(edge.source, edge.target, edge.relation, at);
|
|
138
|
+
}
|
|
139
|
+
for (const e of plan.embed) {
|
|
140
|
+
const v = vector.get(e.id);
|
|
141
|
+
if (v) {
|
|
142
|
+
this.store.vectors?.set(e.id, v);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
await this.store.commit();
|
|
146
|
+
return {
|
|
147
|
+
ids: plan.ids,
|
|
148
|
+
created,
|
|
149
|
+
updated,
|
|
150
|
+
edges: plan.edges.length,
|
|
151
|
+
files: plan.files.length,
|
|
152
|
+
merged,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* A second run that learns the same thing should join the graph, not fork
|
|
157
|
+
* it. Nothing tells an agent the id of a node it is about to write, so two
|
|
158
|
+
* runs of one job commit two identical tasks and split every edge between
|
|
159
|
+
* them — which is what makes a five-seed recall return two memories.
|
|
160
|
+
*
|
|
161
|
+
* Detected here rather than in `#plan` because the vectors that answer it
|
|
162
|
+
* have just been paid for, so the check is a dot product over what is
|
|
163
|
+
* already in memory and costs no extra call. A merged node keeps its
|
|
164
|
+
* original id, and every edge and ref in this commit is re-pointed at it,
|
|
165
|
+
* so the caller is told the canonical id and can link to it next time.
|
|
166
|
+
*
|
|
167
|
+
* File-bearing nodes never merge: their text is a summary of the artifact,
|
|
168
|
+
* and two summaries can read alike while the bytes underneath differ.
|
|
169
|
+
*/
|
|
170
|
+
#merge(plan, vector, sees) {
|
|
171
|
+
const minted = new Set(plan.nodes.map((n) => n.id));
|
|
172
|
+
const carries = new Set(plan.files.map((f) => f.id));
|
|
173
|
+
const stale = this.graph.superseded(sees);
|
|
174
|
+
const twins = new Map();
|
|
175
|
+
for (const item of plan.nodes) {
|
|
176
|
+
if (item.existing || carries.has(item.id)) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const { kind, text } = item.draft;
|
|
180
|
+
const eligible = (id) => {
|
|
181
|
+
if (minted.has(id) || stale.has(id)) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
const node = this.graph.get(id);
|
|
185
|
+
return !!node && node.kind === kind && !node.file && visible(node, sees);
|
|
186
|
+
};
|
|
187
|
+
const mine = vector.get(item.id);
|
|
188
|
+
const twin = mine
|
|
189
|
+
? this.store.vectors
|
|
190
|
+
?.topK(mine, 1, eligible)
|
|
191
|
+
.find((h) => h.score >= DUPLICATE_SCORE)?.id
|
|
192
|
+
: this.graph
|
|
193
|
+
.nodes(sees)
|
|
194
|
+
.find((n) => eligible(n.id) && flatten(n.text) === flatten(text))?.id;
|
|
195
|
+
if (twin) {
|
|
196
|
+
twins.set(item.id, twin);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!twins.size) {
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
const to = (id) => twins.get(id) ?? id;
|
|
203
|
+
plan.nodes = plan.nodes.filter((n) => !twins.has(n.id));
|
|
204
|
+
plan.embed = plan.embed.filter((e) => !twins.has(e.id));
|
|
205
|
+
for (const [ref, id] of Object.entries(plan.ids)) {
|
|
206
|
+
plan.ids[ref] = to(id);
|
|
207
|
+
}
|
|
208
|
+
// A self-edge is what two refs collapsing onto one node leaves behind.
|
|
209
|
+
plan.edges = plan.edges
|
|
210
|
+
.map((e) => ({ ...e, source: to(e.source), target: to(e.target) }))
|
|
211
|
+
.filter((e) => e.source !== e.target);
|
|
212
|
+
return twins.size;
|
|
213
|
+
}
|
|
214
|
+
async forget(ids, sees) {
|
|
215
|
+
const doomed = ids.map((id) => {
|
|
216
|
+
const node = this.graph.get(id);
|
|
217
|
+
if (!node || !visible(node, sees)) {
|
|
218
|
+
throw new MemoryError(`no memory ${id}`);
|
|
219
|
+
}
|
|
220
|
+
return node;
|
|
221
|
+
});
|
|
222
|
+
for (const node of doomed) {
|
|
223
|
+
this.graph.forget(node.id);
|
|
224
|
+
this.store.vectors?.remove(node.id);
|
|
225
|
+
if (node.file) {
|
|
226
|
+
await forgetFile(this.store.dir, node.file);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
await this.store.commit();
|
|
230
|
+
return doomed;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Everything that can be refused, refused here — before a file is copied or
|
|
234
|
+
* a node is added, so a rejected commit leaves the graph exactly as it was.
|
|
235
|
+
*/
|
|
236
|
+
#plan(nodes, edges, opts) {
|
|
237
|
+
const ids = {};
|
|
238
|
+
const planned = [];
|
|
239
|
+
const files = [];
|
|
240
|
+
const embed = [];
|
|
241
|
+
for (const [i, node] of nodes.entries()) {
|
|
242
|
+
const where = node.ref ?? node.id ?? `nodes[${i}]`;
|
|
243
|
+
if ((node.ref === undefined) === (node.id === undefined)) {
|
|
244
|
+
throw new MemoryError(`${where}: give either a ref (to create) or an id (to update), not both`);
|
|
245
|
+
}
|
|
246
|
+
const audience = this.#audience(node, opts.writes, where);
|
|
247
|
+
if (node.id) {
|
|
248
|
+
const existing = this.graph.get(node.id);
|
|
249
|
+
if (!existing) {
|
|
250
|
+
throw new MemoryError(`${where}: no such memory`);
|
|
251
|
+
}
|
|
252
|
+
if (node.kind !== undefined) {
|
|
253
|
+
this.#kind(node.kind, where);
|
|
254
|
+
}
|
|
255
|
+
ids[node.id] = node.id;
|
|
256
|
+
const patch = {
|
|
257
|
+
...(node.kind !== undefined ? { kind: node.kind } : {}),
|
|
258
|
+
...(node.text !== undefined ? { text: node.text } : {}),
|
|
259
|
+
...(audience ? { audience } : {}),
|
|
260
|
+
...(node.metadata !== undefined ? { metadata: node.metadata } : {}),
|
|
261
|
+
...(node.expectedRevision !== undefined
|
|
262
|
+
? { expectedRevision: node.expectedRevision }
|
|
263
|
+
: {}),
|
|
264
|
+
};
|
|
265
|
+
planned.push({ existing: true, id: node.id, patch });
|
|
266
|
+
// Only a text change invalidates the vector; re-embedding on an
|
|
267
|
+
// audience edit would burn a call to store the same numbers.
|
|
268
|
+
if (node.text !== undefined && node.text !== existing.text) {
|
|
269
|
+
embed.push({ id: node.id, text: node.text });
|
|
270
|
+
}
|
|
271
|
+
if (node.file) {
|
|
272
|
+
files.push({ id: node.id, source: node.file.source, target: patch });
|
|
273
|
+
}
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const ref = node.ref;
|
|
277
|
+
if (ref in ids) {
|
|
278
|
+
throw new MemoryError(`${where}: two nodes share the ref "${ref}"`);
|
|
279
|
+
}
|
|
280
|
+
if (node.text === undefined || !node.text.trim()) {
|
|
281
|
+
throw new MemoryError(`${where}: a new node needs text`, 'text is the only thing a search can match, so it has to describe the node');
|
|
282
|
+
}
|
|
283
|
+
const kind = this.#kind(node.kind, where);
|
|
284
|
+
const id = opts.clock.newId();
|
|
285
|
+
ids[ref] = id;
|
|
286
|
+
const draft = {
|
|
287
|
+
id,
|
|
288
|
+
kind,
|
|
289
|
+
text: node.text,
|
|
290
|
+
audience: audience ?? [ALL_AGENTS],
|
|
291
|
+
...(node.metadata !== undefined ? { metadata: node.metadata } : {}),
|
|
292
|
+
};
|
|
293
|
+
planned.push({ existing: false, id, draft });
|
|
294
|
+
embed.push({ id, text: node.text });
|
|
295
|
+
if (node.file) {
|
|
296
|
+
files.push({ id, source: node.file.source, target: draft });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const resolved = edges.map((edge, i) => {
|
|
300
|
+
const where = `edges[${i}]`;
|
|
301
|
+
const relation = edge.relation;
|
|
302
|
+
if (!this.#relations.has(relation)) {
|
|
303
|
+
throw new MemoryError(`${where}: unknown relation "${relation}"`, `use one of ${[...this.#relations].join(', ')}`);
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
source: this.#endpoint(edge.from, ids, where),
|
|
307
|
+
target: this.#endpoint(edge.to, ids, where),
|
|
308
|
+
relation: relation,
|
|
309
|
+
};
|
|
310
|
+
});
|
|
311
|
+
return { ids, nodes: planned, edges: resolved, files, embed };
|
|
312
|
+
}
|
|
313
|
+
#endpoint(name, ids, where) {
|
|
314
|
+
const id = ids[name];
|
|
315
|
+
if (id) {
|
|
316
|
+
return id;
|
|
317
|
+
}
|
|
318
|
+
if (this.graph.has(name)) {
|
|
319
|
+
return name;
|
|
320
|
+
}
|
|
321
|
+
throw new MemoryError(`${where}: "${name}" is neither a ref in this commit nor an existing memory id`);
|
|
322
|
+
}
|
|
323
|
+
#kind(kind, where) {
|
|
324
|
+
if (kind === undefined) {
|
|
325
|
+
throw new MemoryError(`${where}: a new node needs a kind`);
|
|
326
|
+
}
|
|
327
|
+
if (!this.#kinds.has(kind)) {
|
|
328
|
+
throw new MemoryError(`${where}: unknown kind "${kind}"`, `use one of ${[...this.#kinds].join(', ')}`);
|
|
329
|
+
}
|
|
330
|
+
return kind;
|
|
331
|
+
}
|
|
332
|
+
/** An agent can only label a node with something it was granted. */
|
|
333
|
+
#audience(node, writes, where) {
|
|
334
|
+
if (!node.audience?.length) {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
for (const label of node.audience) {
|
|
338
|
+
if (!writes.includes(label)) {
|
|
339
|
+
throw new MemoryError(`${where}: not allowed to label a memory "${label}"`, `this agent may write ${writes.join(', ') || 'nothing'}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return [...node.audience];
|
|
343
|
+
}
|
|
344
|
+
async #embed(texts, taskType) {
|
|
345
|
+
if (!this.#embedder || !texts.length) {
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
const res = await this.#embedder.embed({ input: texts, taskType });
|
|
349
|
+
this.store.adopt({ model: this.#embedder.id, dimensions: res.dimensions });
|
|
350
|
+
return res.vectors;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** Whitespace and case are not what makes two memories different. */
|
|
354
|
+
function flatten(text) {
|
|
355
|
+
return text.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
356
|
+
}
|
|
357
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type MemoryNode, type ResolvedMemoryBinding } from './types.ts';
|
|
2
|
+
export declare const PREFERENCES_TAG = "memory-preferences";
|
|
3
|
+
/**
|
|
4
|
+
* Deliberately not a function of `sees`: those labels come from the run's
|
|
5
|
+
* context, and naming them here would rewrite the prompt whenever the context
|
|
6
|
+
* moved. It also tells an agent the shape of a slice it was not given.
|
|
7
|
+
*/
|
|
8
|
+
export declare function memoryInstructions(binding: ResolvedMemoryBinding): string;
|
|
9
|
+
/**
|
|
10
|
+
* The id on each line is what makes a preference revisable rather than merely
|
|
11
|
+
* readable: without it the model can read the instruction but cannot name the
|
|
12
|
+
* thing it would supersede.
|
|
13
|
+
*/
|
|
14
|
+
export declare function renderPreferences(nodes: readonly MemoryNode[]): string;
|
|
15
|
+
//# sourceMappingURL=instructions.d.ts.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { MEMORY_COMMIT_TOOL, MEMORY_FORGET_TOOL, MEMORY_LOAD_TOOL, MEMORY_SEARCH_TOOL, } from "../types.js";
|
|
2
|
+
import { RECOLLECTION_TAG } from "./render.js";
|
|
3
|
+
import { canForget, canWrite } from "./types.js";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Teaching the model to read and write memory
|
|
6
|
+
//
|
|
7
|
+
// The tool schemas say what the arguments are; none of them says when a thing
|
|
8
|
+
// is worth remembering, or that a recollection is a subgraph rather than a
|
|
9
|
+
// ranked list. That is policy, it is the same for every agent, and it belongs
|
|
10
|
+
// in the system prompt — which is also the only place it can be stated once
|
|
11
|
+
// per run instead of once per call.
|
|
12
|
+
//
|
|
13
|
+
// Both halves go in through `composePrompt`'s `derived` array, so they sit in
|
|
14
|
+
// the cacheable prefix and are recorded in the `system_prompt` node like every
|
|
15
|
+
// other part of what the model read.
|
|
16
|
+
//
|
|
17
|
+
// The text is gated on the binding, not on the project: an agent that cannot
|
|
18
|
+
// write is never told how to commit. A rule it cannot act on is a rule it can
|
|
19
|
+
// only be confused by, and an unusable tool name in the prompt invites a call
|
|
20
|
+
// that will be refused.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
export const PREFERENCES_TAG = 'memory-preferences';
|
|
23
|
+
/**
|
|
24
|
+
* Deliberately not a function of `sees`: those labels come from the run's
|
|
25
|
+
* context, and naming them here would rewrite the prompt whenever the context
|
|
26
|
+
* moved. It also tells an agent the shape of a slice it was not given.
|
|
27
|
+
*/
|
|
28
|
+
export function memoryInstructions(binding) {
|
|
29
|
+
const parts = [READING];
|
|
30
|
+
if (canWrite(binding)) {
|
|
31
|
+
parts.push(WRITING);
|
|
32
|
+
}
|
|
33
|
+
if (canForget(binding)) {
|
|
34
|
+
parts.push(FORGETTING);
|
|
35
|
+
}
|
|
36
|
+
return parts.join('\n\n');
|
|
37
|
+
}
|
|
38
|
+
const READING = `Memory is a graph shared with other agents, and it outlives this run.
|
|
39
|
+
|
|
40
|
+
A <${RECOLLECTION_TAG}> block is a subgraph, not a ranked list — the links are
|
|
41
|
+
as much of the answer as the nodes. It is an outline, and the indentation is
|
|
42
|
+
the graph. A line at the left margin reads \`score kind id\` and is something
|
|
43
|
+
the search matched. A line indented under it reads \`<arrow>relation kind id\`
|
|
44
|
+
and is a neighbour: \`\u2192produced\` means the line above produced this one,
|
|
45
|
+
\`\u2190informed\` means this one informed the line above. A neighbour the
|
|
46
|
+
search matched in its own right carries a score too, after the arrow. Under
|
|
47
|
+
each header, indented further, is that node's text — and for a \`file\`, the
|
|
48
|
+
path under /memory you can open or run, its size and how often it has been read.
|
|
49
|
+
|
|
50
|
+
A line beginning \`+\` is a link to an id already shown above, for an edge the
|
|
51
|
+
outline could not nest. Text is clipped: copy an id into \`${MEMORY_LOAD_TOOL}\`
|
|
52
|
+
to read a node whole. \`${MEMORY_SEARCH_TOOL}\` finds ids, it does not return
|
|
53
|
+
contents.
|
|
54
|
+
|
|
55
|
+
You see part of the graph, not all of it. Finding nothing means nothing you can
|
|
56
|
+
see, not that it never happened.`;
|
|
57
|
+
const WRITING = `Commit when a run produced something a later run would otherwise have to work
|
|
58
|
+
out again: a script that ran, an interface that behaved differently from its
|
|
59
|
+
documentation, a call sequence that turned out to be necessary. Commit it in
|
|
60
|
+
one \`${MEMORY_COMMIT_TOOL}\` call — the artifact and what explains it are one
|
|
61
|
+
memory, and splitting them leaves a later run holding a script with no idea why
|
|
62
|
+
it exists.
|
|
63
|
+
|
|
64
|
+
Do not commit the request restated, progress notes, anything you have not
|
|
65
|
+
verified, or anything a search already returned. A node that says what one
|
|
66
|
+
already in memory says is folded into it and its existing id comes back under
|
|
67
|
+
your ref, so the links you asked for still land — on the memory that was
|
|
68
|
+
already there.
|
|
69
|
+
|
|
70
|
+
Never edit a memory to correct it. Add the new one and link it to the old with
|
|
71
|
+
SUPERSEDES; the wrong answer is evidence too, and the link is what hides it
|
|
72
|
+
from later recalls without destroying the history.
|
|
73
|
+
|
|
74
|
+
Commit a preference only when the user's own words generalise — "always", "from
|
|
75
|
+
now on", "I prefer", "never". A single request being fulfilled is not a
|
|
76
|
+
standing instruction.`;
|
|
77
|
+
const FORGETTING = `Forget only what is wrong and has no successor. A superseded memory is already
|
|
78
|
+
hidden from recall and should stay for the history, so \`${MEMORY_FORGET_TOOL}\`
|
|
79
|
+
is for what should never have been written, not for what has been replaced.`;
|
|
80
|
+
/**
|
|
81
|
+
* The id on each line is what makes a preference revisable rather than merely
|
|
82
|
+
* readable: without it the model can read the instruction but cannot name the
|
|
83
|
+
* thing it would supersede.
|
|
84
|
+
*/
|
|
85
|
+
export function renderPreferences(nodes) {
|
|
86
|
+
if (!nodes.length) {
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
const lines = nodes.map((n) => `- ${oneLine(n.text)} [${n.id}]`);
|
|
90
|
+
return [
|
|
91
|
+
`<${PREFERENCES_TAG}>`,
|
|
92
|
+
"Standing instructions from earlier runs. They apply unless this run's",
|
|
93
|
+
'request contradicts them; to change one, commit the replacement and link',
|
|
94
|
+
'it to the id here with SUPERSEDES.',
|
|
95
|
+
...lines,
|
|
96
|
+
`</${PREFERENCES_TAG}>`,
|
|
97
|
+
].join('\n');
|
|
98
|
+
}
|
|
99
|
+
/** Never clipped: half an instruction is a different instruction. */
|
|
100
|
+
function oneLine(text) {
|
|
101
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=instructions.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MemoryReport } from './report.ts';
|
|
2
|
+
/** Pinned once, in the run inspector, and shared so the two pages cannot drift. */
|
|
3
|
+
export interface PageOptions {
|
|
4
|
+
mermaidUrl?: string;
|
|
5
|
+
}
|
|
6
|
+
/** Report → one standalone HTML document. */
|
|
7
|
+
export declare function renderMemoryHtml(report: MemoryReport, opts?: PageOptions): string;
|
|
8
|
+
//# sourceMappingURL=page.d.ts.map
|