@zenera/neo 1.1.11 → 1.1.14
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/embeddings/limiter.js +14 -3
- 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 +440 -26
- package/dist/inspect/css.d.ts +1 -1
- package/dist/inspect/css.js +49 -0
- package/dist/inspect/index.d.ts +3 -3
- package/dist/inspect/index.js +1 -1
- package/dist/inspect/page.js +16 -0
- package/dist/inspect/report.d.ts +25 -0
- package/dist/inspect/report.js +45 -0
- 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 +69 -0
- package/dist/memory/report.js +131 -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 +337 -0
- package/dist/memory/types.d.ts +193 -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,35 @@
|
|
|
1
|
+
import type { MemoryGraph } from './graph.ts';
|
|
2
|
+
import { type MemoryQuery, type Recollection, type ScoredNode } from './types.ts';
|
|
3
|
+
import { type VectorBlock } from './vectors.ts';
|
|
4
|
+
export declare const DEFAULT_SEEDS = 5;
|
|
5
|
+
export declare const DEFAULT_MAX_HOPS = 2;
|
|
6
|
+
/** Past roughly this many nodes a rendered graph reads as soup, not structure. */
|
|
7
|
+
export declare const DEFAULT_MAX_NODES = 25;
|
|
8
|
+
/** Below this, a "match" is noise that costs context and buys nothing. */
|
|
9
|
+
export declare const DEFAULT_MIN_SCORE = 0.15;
|
|
10
|
+
export interface RecallInput {
|
|
11
|
+
graph: MemoryGraph;
|
|
12
|
+
query: MemoryQuery;
|
|
13
|
+
sees: readonly string[];
|
|
14
|
+
/** the embedded query; absent ranks by term overlap instead */
|
|
15
|
+
vector?: ArrayLike<number>;
|
|
16
|
+
vectors?: VectorBlock;
|
|
17
|
+
now?: number;
|
|
18
|
+
/** include what has been superseded; off by default, on for an audit */
|
|
19
|
+
stale?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare function recall(input: RecallInput): Recollection;
|
|
22
|
+
/**
|
|
23
|
+
* Decay orders, it does not admit. Relevance alone clears `minScore`, and
|
|
24
|
+
* recency only breaks the tie afterwards — folding decay in before the
|
|
25
|
+
* threshold would make every memory unreachable once it was old enough,
|
|
26
|
+
* however exactly it matched, which is the opposite of outliving a run.
|
|
27
|
+
*/
|
|
28
|
+
export declare function rank(input: RecallInput): ScoredNode[];
|
|
29
|
+
/**
|
|
30
|
+
* Breadth-first from every seed at once, so a node two hops from one seed loses
|
|
31
|
+
* to a node one hop from another. Direction is ignored on the walk: a memory is
|
|
32
|
+
* as often reached from the artifact back to the request as the other way.
|
|
33
|
+
*/
|
|
34
|
+
export declare function stitch(graph: MemoryGraph, seeds: readonly ScoredNode[], query: MemoryQuery, sees: readonly string[], stale?: boolean): Recollection;
|
|
35
|
+
//# sourceMappingURL=recall.d.ts.map
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { PREFERENCE_KIND, SPINE_RELATIONS, visible, } from "./types.js";
|
|
2
|
+
import { overlap, recencyDecay, tokenize } from "./vectors.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// From a question to a subgraph
|
|
5
|
+
//
|
|
6
|
+
// A ranked list of node ids is not an answer. A remembered script is worth
|
|
7
|
+
// having only alongside the request that asked for it and the endpoint it
|
|
8
|
+
// calls, and two hits are one memory only if the graph joins them. So ranking
|
|
9
|
+
// picks seeds and traversal decides what has to come with them.
|
|
10
|
+
//
|
|
11
|
+
// The budget is spent in tiers. Seeds first, then the spine — what a task
|
|
12
|
+
// produced, what an artifact calls, what replaced it — and only then the
|
|
13
|
+
// context that merely informed the work. A tight budget therefore degrades by
|
|
14
|
+
// dropping background rather than by truncating the answer itself.
|
|
15
|
+
//
|
|
16
|
+
// Superseded nodes are dropped, not deleted. A corrected script hides the one
|
|
17
|
+
// it replaced from every recall, while the history stays on disk for anyone
|
|
18
|
+
// asking how the correction came about.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
export const DEFAULT_SEEDS = 5;
|
|
21
|
+
export const DEFAULT_MAX_HOPS = 2;
|
|
22
|
+
/** Past roughly this many nodes a rendered graph reads as soup, not structure. */
|
|
23
|
+
export const DEFAULT_MAX_NODES = 25;
|
|
24
|
+
/** Below this, a "match" is noise that costs context and buys nothing. */
|
|
25
|
+
export const DEFAULT_MIN_SCORE = 0.15;
|
|
26
|
+
const SEED = 0;
|
|
27
|
+
const LINK = 1;
|
|
28
|
+
const FILL = 2;
|
|
29
|
+
export function recall(input) {
|
|
30
|
+
const seeds = rank(input);
|
|
31
|
+
return stitch(input.graph, seeds, input.query, input.sees, input.stale ?? false);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Decay orders, it does not admit. Relevance alone clears `minScore`, and
|
|
35
|
+
* recency only breaks the tie afterwards — folding decay in before the
|
|
36
|
+
* threshold would make every memory unreachable once it was old enough,
|
|
37
|
+
* however exactly it matched, which is the opposite of outliving a run.
|
|
38
|
+
*/
|
|
39
|
+
export function rank(input) {
|
|
40
|
+
const { graph, query, sees } = input;
|
|
41
|
+
const now = input.now ?? Date.now();
|
|
42
|
+
const limit = query.limit ?? DEFAULT_SEEDS;
|
|
43
|
+
const minScore = query.minScore ?? DEFAULT_MIN_SCORE;
|
|
44
|
+
const stale = input.stale ? new Set() : graph.superseded(sees);
|
|
45
|
+
const admissible = (n) => eligible(n, query, sees) && !stale.has(n.id);
|
|
46
|
+
let scored;
|
|
47
|
+
if (input.vector && input.vectors) {
|
|
48
|
+
const hits = input.vectors.topK(input.vector, limit * 4, (id) => {
|
|
49
|
+
const node = graph.get(id);
|
|
50
|
+
return !!node && admissible(node);
|
|
51
|
+
});
|
|
52
|
+
scored = hits.flatMap((h) => {
|
|
53
|
+
const node = graph.get(h.id);
|
|
54
|
+
return node ? [{ node, score: h.score }] : [];
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
const terms = tokenize(query.text ?? '');
|
|
59
|
+
scored = graph.nodes(sees).flatMap((node) => {
|
|
60
|
+
if (!admissible(node)) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const score = terms.length ? overlap(terms, tokenize(node.text)) : 1;
|
|
64
|
+
return score > 0 ? [{ node, score }] : [];
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
// `score` stays the similarity the caller can reason about; the decayed
|
|
68
|
+
// value exists only to order, and is not reported.
|
|
69
|
+
return scored
|
|
70
|
+
.filter((s) => s.score >= minScore)
|
|
71
|
+
.map((s) => ({ ...s, rung: s.score * recencyDecay(s.node.lastUsedAt, now) }))
|
|
72
|
+
.sort((a, b) => b.rung - a.rung)
|
|
73
|
+
.slice(0, limit)
|
|
74
|
+
.map((s) => ({ node: s.node, score: s.score, seed: true }));
|
|
75
|
+
}
|
|
76
|
+
function eligible(node, query, sees) {
|
|
77
|
+
if (!visible(node, sees)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (query.kinds?.length && !query.kinds.includes(node.kind)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
// A preference is in the system prompt already. Recalling one would state
|
|
84
|
+
// the same standing instruction twice in two framings; asking for the kind
|
|
85
|
+
// by name still finds it, which is how an agent checks for a duplicate
|
|
86
|
+
// before committing another.
|
|
87
|
+
if (!query.kinds?.length && node.kind === PREFERENCE_KIND) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (query.newerThan && node.createdAt < query.newerThan) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Breadth-first from every seed at once, so a node two hops from one seed loses
|
|
97
|
+
* to a node one hop from another. Direction is ignored on the walk: a memory is
|
|
98
|
+
* as often reached from the artifact back to the request as the other way.
|
|
99
|
+
*/
|
|
100
|
+
export function stitch(graph, seeds, query, sees, stale = false) {
|
|
101
|
+
const maxHops = query.maxHops ?? DEFAULT_MAX_HOPS;
|
|
102
|
+
const maxNodes = query.maxNodes ?? DEFAULT_MAX_NODES;
|
|
103
|
+
const superseded = stale ? new Set() : graph.superseded(sees);
|
|
104
|
+
const tier = new Map();
|
|
105
|
+
const scores = new Map();
|
|
106
|
+
// What admitted each node, which is the branch the renderer hangs it from.
|
|
107
|
+
// A seed never gets one: its tier is 0 and no edge can better that.
|
|
108
|
+
const via = new Map();
|
|
109
|
+
for (const seed of seeds) {
|
|
110
|
+
tier.set(seed.node.id, SEED);
|
|
111
|
+
scores.set(seed.node.id, seed.score);
|
|
112
|
+
}
|
|
113
|
+
let frontier = seeds.map((s) => s.node.id);
|
|
114
|
+
for (let hop = 0; hop < maxHops && frontier.length; hop++) {
|
|
115
|
+
const next = [];
|
|
116
|
+
for (const id of frontier) {
|
|
117
|
+
for (const edge of graph.neighbors(id, sees)) {
|
|
118
|
+
const other = edge.source === id ? edge.target : edge.source;
|
|
119
|
+
if (superseded.has(other)) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const rung = SPINE_RELATIONS.has(edge.relation) ? LINK : FILL;
|
|
123
|
+
const known = tier.get(other);
|
|
124
|
+
if (known !== undefined && known <= rung) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (known === undefined) {
|
|
128
|
+
next.push(other);
|
|
129
|
+
}
|
|
130
|
+
tier.set(other, rung);
|
|
131
|
+
via.set(other, { from: id, relation: edge.relation, outbound: edge.source === id });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
frontier = next;
|
|
135
|
+
}
|
|
136
|
+
// Seeds, then the spine, then context — and inside a tier, by seed score.
|
|
137
|
+
const ranked = [...tier.entries()]
|
|
138
|
+
.flatMap(([id, rung]) => {
|
|
139
|
+
const node = graph.get(id);
|
|
140
|
+
return node ? [{ node, rung, score: scores.get(id) ?? 0 }] : [];
|
|
141
|
+
})
|
|
142
|
+
.sort((a, b) => a.rung - b.rung || b.score - a.score);
|
|
143
|
+
const kept = ranked.slice(0, maxNodes);
|
|
144
|
+
const ids = new Set(kept.map((k) => k.node.id));
|
|
145
|
+
const edges = graph.between(ids, sees);
|
|
146
|
+
return {
|
|
147
|
+
nodes: kept.map((k) => {
|
|
148
|
+
const branch = via.get(k.node.id);
|
|
149
|
+
return {
|
|
150
|
+
node: k.node,
|
|
151
|
+
score: k.score,
|
|
152
|
+
seed: k.rung === SEED,
|
|
153
|
+
...(branch ? { via: branch } : {}),
|
|
154
|
+
};
|
|
155
|
+
}),
|
|
156
|
+
edges,
|
|
157
|
+
seeds: seeds.map((s) => s.node.id),
|
|
158
|
+
truncated: ranked.length > kept.length,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=recall.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Recollection } from './types.ts';
|
|
2
|
+
export declare const RECOLLECTION_TAG = "memory-recollection";
|
|
3
|
+
export interface RenderOptions {
|
|
4
|
+
/** wrap in the <memory-recollection> element; off for CLI output */
|
|
5
|
+
tagged?: boolean;
|
|
6
|
+
clip?: number;
|
|
7
|
+
now?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function renderRecollection(rec: Recollection, opts?: RenderOptions): string;
|
|
10
|
+
//# sourceMappingURL=render.d.ts.map
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { SPINE_RELATIONS, } from "./types.js";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// What the model actually reads
|
|
4
|
+
//
|
|
5
|
+
// An outline, because the block has exactly one audience: `memory_search` and
|
|
6
|
+
// auto-recall both hand it straight to a model, and nothing human or machine
|
|
7
|
+
// consumes it — the HTML export draws its own diagram from the graph.
|
|
8
|
+
//
|
|
9
|
+
// It replaced a mermaid graph over a legend, which carried the same subgraph
|
|
10
|
+
// twice: topology above, text below, joined only by the id. Answering "what
|
|
11
|
+
// does this script call" meant carrying a 26-character id from one half into
|
|
12
|
+
// the other, and the halves were sorted differently, so the answer sat as far
|
|
13
|
+
// from the question as the sort could put it. An outline states each id once,
|
|
14
|
+
// with its text under it and its neighbours around it.
|
|
15
|
+
//
|
|
16
|
+
// The tree is mostly the walk: `stitch` records the edge that admitted each
|
|
17
|
+
// node, so nesting is how recall got there. Seeds are the exception — nothing
|
|
18
|
+
// admitted them — and left as roots they turned a well-matched subgraph into a
|
|
19
|
+
// flat list, so each adopts its best-connected higher-ranked neighbour instead.
|
|
20
|
+
// Whatever edges that leaves over become back-references, always drawn under
|
|
21
|
+
// the endpoint that appeared later, so a `+` line never points forward.
|
|
22
|
+
//
|
|
23
|
+
// No SUPERSEDES arrow can appear on the ordinary path: recall drops the node
|
|
24
|
+
// one points at, so the edge loses an endpoint before it reaches here. It shows
|
|
25
|
+
// up only under `stale`, which is the audit view, and `memory/instructions.ts`
|
|
26
|
+
// therefore does not teach the model to look for one.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
export const RECOLLECTION_TAG = 'memory-recollection';
|
|
29
|
+
/** Enough to judge relevance; the model calls `memory_load` for the rest. */
|
|
30
|
+
const TEXT_CLIP = 160;
|
|
31
|
+
/** Two columns per level of nesting, so the tree is the indentation. */
|
|
32
|
+
const STEP = 2;
|
|
33
|
+
/** Where text sits under its header; a deeper walk pushes it right, never over it. */
|
|
34
|
+
const TEXT_COLUMN = 6;
|
|
35
|
+
export function renderRecollection(rec, opts = {}) {
|
|
36
|
+
if (!rec.nodes.length) {
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
const lines = [];
|
|
40
|
+
for (const [i, entry] of layout(rec).entries()) {
|
|
41
|
+
if (i && entry.depth === 0) {
|
|
42
|
+
lines.push('');
|
|
43
|
+
}
|
|
44
|
+
lines.push(...block(entry, opts));
|
|
45
|
+
}
|
|
46
|
+
if (rec.truncated) {
|
|
47
|
+
lines.push('', '(more memory matched than fits; narrow the query or raise max_nodes)');
|
|
48
|
+
}
|
|
49
|
+
const text = lines.join('\n');
|
|
50
|
+
return opts.tagged === false ? text : `<${RECOLLECTION_TAG}>\n${text}\n</${RECOLLECTION_TAG}>`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Depth-first along the branches the walk took, roots in seed order. A node
|
|
54
|
+
* whose parent the budget cut is promoted to a root rather than dropped: it
|
|
55
|
+
* survived the ranking, so it belongs in the answer.
|
|
56
|
+
*/
|
|
57
|
+
function layout(rec) {
|
|
58
|
+
const at = new Map(rec.nodes.map((s, i) => [s.node.id, i]));
|
|
59
|
+
const parents = branches(rec, at);
|
|
60
|
+
const children = new Map();
|
|
61
|
+
const roots = [];
|
|
62
|
+
for (const scored of rec.nodes) {
|
|
63
|
+
const parent = parents.get(scored.node.id)?.from;
|
|
64
|
+
if (parent !== undefined) {
|
|
65
|
+
children.set(parent, [...(children.get(parent) ?? []), scored]);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
roots.push(scored);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const rank = new Map(rec.seeds.map((id, i) => [id, i]));
|
|
72
|
+
const place = (id) => rank.get(id) ?? Number.MAX_SAFE_INTEGER;
|
|
73
|
+
roots.sort((a, b) => place(a.node.id) - place(b.node.id));
|
|
74
|
+
const order = [];
|
|
75
|
+
const walk = (scored, depth) => {
|
|
76
|
+
order.push({ scored, depth, via: parents.get(scored.node.id), refs: [] });
|
|
77
|
+
for (const child of children.get(scored.node.id) ?? []) {
|
|
78
|
+
walk(child, depth + 1);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
for (const root of roots) {
|
|
82
|
+
walk(root, 0);
|
|
83
|
+
}
|
|
84
|
+
// Everything the nesting has not already said, hung off whichever endpoint
|
|
85
|
+
// came out later — so resolving one only ever means looking back up.
|
|
86
|
+
const nested = new Set([...parents].map(([id, via]) => {
|
|
87
|
+
const [source, target] = via.outbound ? [via.from, id] : [id, via.from];
|
|
88
|
+
return edgeKey(source, target, via.relation);
|
|
89
|
+
}));
|
|
90
|
+
const position = new Map(order.map((entry, i) => [entry.scored.node.id, i]));
|
|
91
|
+
for (const edge of rec.edges) {
|
|
92
|
+
if (nested.has(edgeKey(edge.source, edge.target, edge.relation))) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const source = position.get(edge.source);
|
|
96
|
+
const target = position.get(edge.target);
|
|
97
|
+
if (source !== undefined && target !== undefined) {
|
|
98
|
+
order[Math.max(source, target)]?.refs.push(edge);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return order;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Which branch each node hangs from. `stitch` records one for anything it had
|
|
105
|
+
* to walk to, but never for a seed — nothing reached a seed, it was the reason
|
|
106
|
+
* for the walk. Two seeds joined by an edge would then both sit at the left
|
|
107
|
+
* margin with the link between them demoted to a `+`, so the better a query
|
|
108
|
+
* matched the flatter it drew, which is backwards.
|
|
109
|
+
*
|
|
110
|
+
* So a node with no branch of its own adopts one, preferring the spine and then
|
|
111
|
+
* the best-ranked neighbour. Only a neighbour that ranked *higher* may be
|
|
112
|
+
* adopted, which is what makes this a forest: every parent is strictly earlier
|
|
113
|
+
* in one total order, so no chain of them can close into a cycle.
|
|
114
|
+
*/
|
|
115
|
+
function branches(rec, at) {
|
|
116
|
+
const parents = new Map();
|
|
117
|
+
const touching = new Map();
|
|
118
|
+
for (const scored of rec.nodes) {
|
|
119
|
+
const via = scored.via;
|
|
120
|
+
const from = via ? at.get(via.from) : undefined;
|
|
121
|
+
// Strictly earlier, on the same total order the adoption below uses:
|
|
122
|
+
// one rule for both is what keeps `walk` from ever meeting a cycle.
|
|
123
|
+
if (via && from !== undefined && from < at.get(scored.node.id)) {
|
|
124
|
+
parents.set(scored.node.id, via);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const edge of rec.edges) {
|
|
128
|
+
if (!at.has(edge.source) || !at.has(edge.target)) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
touching.set(edge.source, [...(touching.get(edge.source) ?? []), edge]);
|
|
132
|
+
touching.set(edge.target, [...(touching.get(edge.target) ?? []), edge]);
|
|
133
|
+
}
|
|
134
|
+
for (const scored of rec.nodes) {
|
|
135
|
+
const id = scored.node.id;
|
|
136
|
+
if (parents.has(id)) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const mine = at.get(id);
|
|
140
|
+
let best;
|
|
141
|
+
for (const edge of touching.get(id) ?? []) {
|
|
142
|
+
const from = edge.source === id ? edge.target : edge.source;
|
|
143
|
+
const there = at.get(from);
|
|
144
|
+
if (there >= mine) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const spine = SPINE_RELATIONS.has(edge.relation);
|
|
148
|
+
if (best && !(spine && !best.spine) && !(spine === best.spine && there < best.at)) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
best = {
|
|
152
|
+
spine,
|
|
153
|
+
at: there,
|
|
154
|
+
via: { from, relation: edge.relation, outbound: edge.source === from },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (best) {
|
|
158
|
+
parents.set(id, best.via);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return parents;
|
|
162
|
+
}
|
|
163
|
+
function edgeKey(source, target, relation) {
|
|
164
|
+
return `${source}|${relation}|${target}`;
|
|
165
|
+
}
|
|
166
|
+
function block(entry, opts) {
|
|
167
|
+
const { node } = entry.scored;
|
|
168
|
+
const now = opts.now ?? Date.now();
|
|
169
|
+
const indent = ' '.repeat(Math.max(TEXT_COLUMN, (entry.depth + 2) * STEP));
|
|
170
|
+
const lines = [`${' '.repeat(entry.depth * STEP)}${header(entry)}`];
|
|
171
|
+
if (node.file) {
|
|
172
|
+
const facts = [node.file.path, bytes(node.file.bytes), used(node, now)];
|
|
173
|
+
lines.push(`${indent}${facts.join(' · ')}`);
|
|
174
|
+
}
|
|
175
|
+
const summary = clipped(node.text, opts.clip ?? TEXT_CLIP);
|
|
176
|
+
if (summary) {
|
|
177
|
+
lines.push(`${indent}${summary}`);
|
|
178
|
+
}
|
|
179
|
+
for (const ref of entry.refs) {
|
|
180
|
+
const outbound = ref.source === node.id;
|
|
181
|
+
const other = outbound ? ref.target : ref.source;
|
|
182
|
+
lines.push(`${indent}+ ${step(outbound, ref.relation)} ${other}`);
|
|
183
|
+
}
|
|
184
|
+
return lines;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* A root leads with its score, a child with the edge that reached it. A child
|
|
188
|
+
* that the ranker also matched leads with both: the arrow says how the outline
|
|
189
|
+
* got here, the score says it would have been worth reading regardless.
|
|
190
|
+
*/
|
|
191
|
+
function header(entry) {
|
|
192
|
+
const { node, seed, score } = entry.scored;
|
|
193
|
+
const via = entry.via;
|
|
194
|
+
const tail = `${label(node.kind)} ${node.id}`;
|
|
195
|
+
if (!via || entry.depth === 0) {
|
|
196
|
+
return `${seed ? score.toFixed(2) : ' --'} ${tail}`;
|
|
197
|
+
}
|
|
198
|
+
const reached = step(via.outbound, via.relation);
|
|
199
|
+
return seed ? `${reached} ${score.toFixed(2)} ${tail}` : `${reached} ${tail}`;
|
|
200
|
+
}
|
|
201
|
+
/** `→` when the line above is the edge's source, `←` when it is the target. */
|
|
202
|
+
function step(outbound, relation) {
|
|
203
|
+
return `${outbound ? '\u2192' : '\u2190'}${relation.toLowerCase()}`;
|
|
204
|
+
}
|
|
205
|
+
/** Kinds are a closed vocabulary, but a project may add its own — so still guard. */
|
|
206
|
+
function label(kind) {
|
|
207
|
+
return kind.replace(/[^A-Za-z0-9_ -]/g, '') || 'node';
|
|
208
|
+
}
|
|
209
|
+
function clipped(text, clip) {
|
|
210
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
211
|
+
return flat.length > clip ? `${flat.slice(0, clip - 1)}…` : flat;
|
|
212
|
+
}
|
|
213
|
+
function bytes(n) {
|
|
214
|
+
return n < 1024
|
|
215
|
+
? `${n} B`
|
|
216
|
+
: n < 1024 * 1024
|
|
217
|
+
? `${(n / 1024).toFixed(1)} KB`
|
|
218
|
+
: `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
219
|
+
}
|
|
220
|
+
function used(node, now) {
|
|
221
|
+
if (!node.useCount) {
|
|
222
|
+
return 'unused';
|
|
223
|
+
}
|
|
224
|
+
const days = Math.floor((now - Date.parse(node.lastUsedAt)) / 86_400_000);
|
|
225
|
+
const ago = days <= 0 ? 'today' : days === 1 ? 'yesterday' : `${days}d ago`;
|
|
226
|
+
return `used ${node.useCount}\u00d7, ${ago}`;
|
|
227
|
+
}
|
|
228
|
+
//# sourceMappingURL=render.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { MemoryStore } from './store.ts';
|
|
2
|
+
import type { MemoryEdge, MemoryFile } from './types.ts';
|
|
3
|
+
/** Beyond this a file is named and measured, not shown. */
|
|
4
|
+
export declare const MAX_CONTENT_BYTES: number;
|
|
5
|
+
/** Why a remembered file is not shown. */
|
|
6
|
+
export type Omitted = 'too-big' | 'binary' | 'missing' | 'unreadable';
|
|
7
|
+
export interface ReportNode {
|
|
8
|
+
id: string;
|
|
9
|
+
kind: string;
|
|
10
|
+
text: string;
|
|
11
|
+
audience: string[];
|
|
12
|
+
createdAt: string;
|
|
13
|
+
updatedAt: string;
|
|
14
|
+
lastUsedAt: string;
|
|
15
|
+
useCount: number;
|
|
16
|
+
revision: number;
|
|
17
|
+
metadata?: Record<string, unknown>;
|
|
18
|
+
file?: MemoryFile;
|
|
19
|
+
/** the file's text, when it is text and small enough to show */
|
|
20
|
+
content?: string;
|
|
21
|
+
/** an image file, as a data URL */
|
|
22
|
+
image?: string;
|
|
23
|
+
omitted?: Omitted;
|
|
24
|
+
/** superseded by a newer node; still here, because why it changed matters */
|
|
25
|
+
stale: boolean;
|
|
26
|
+
/** edges touching this node, in either direction */
|
|
27
|
+
degree: number;
|
|
28
|
+
}
|
|
29
|
+
export interface MemoryReport {
|
|
30
|
+
title: string;
|
|
31
|
+
dir: string;
|
|
32
|
+
generatedAt: string;
|
|
33
|
+
embedding?: {
|
|
34
|
+
model: string;
|
|
35
|
+
dimensions: number;
|
|
36
|
+
};
|
|
37
|
+
vectors?: {
|
|
38
|
+
rows: number;
|
|
39
|
+
dims: number;
|
|
40
|
+
bytes: number;
|
|
41
|
+
};
|
|
42
|
+
nodes: ReportNode[];
|
|
43
|
+
edges: MemoryEdge[];
|
|
44
|
+
/** every kind present, for the filter */
|
|
45
|
+
kinds: string[];
|
|
46
|
+
/** every audience present, for the filter */
|
|
47
|
+
audiences: string[];
|
|
48
|
+
}
|
|
49
|
+
export interface MemoryReportOptions {
|
|
50
|
+
title?: string;
|
|
51
|
+
/** ceiling on inlined file content; 0 to inline nothing */
|
|
52
|
+
maxContentBytes?: number;
|
|
53
|
+
/** override the clock, for tests */
|
|
54
|
+
now?: () => string;
|
|
55
|
+
}
|
|
56
|
+
/** Part of a memory, for a caller that already knows which part it wants. */
|
|
57
|
+
export interface MemorySlice {
|
|
58
|
+
nodes: ReportNode[];
|
|
59
|
+
/** only the edges with both ends inside the slice */
|
|
60
|
+
edges: MemoryEdge[];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The nodes named, built exactly as the whole graph is — same inlining, same
|
|
64
|
+
* excuses, same degree, which is counted over every edge rather than over the
|
|
65
|
+
* slice: a node's degree is a fact about the memory, not about the cut.
|
|
66
|
+
*/
|
|
67
|
+
export declare function buildMemorySlice(store: MemoryStore, ids: readonly string[], opts?: Pick<MemoryReportOptions, 'maxContentBytes'>): Promise<MemorySlice>;
|
|
68
|
+
export declare function buildMemoryReport(store: MemoryStore, opts?: MemoryReportOptions): Promise<MemoryReport>;
|
|
69
|
+
//# sourceMappingURL=report.d.ts.map
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { hostPath } from "./files.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// The memory, flattened for reading
|
|
5
|
+
//
|
|
6
|
+
// This is the debugging view, and it is deliberately the *whole* graph: no
|
|
7
|
+
// mask, no ranking, no recall. Everything a mask would hide is exactly what
|
|
8
|
+
// you are looking for when a mask is the thing that is wrong, so audiences
|
|
9
|
+
// arrive as data to filter by rather than as a filter already applied.
|
|
10
|
+
//
|
|
11
|
+
// The one thing it does not do is grow without bound. A remembered file can be
|
|
12
|
+
// two megabytes and there can be a lot of them, so content is inlined up to a
|
|
13
|
+
// ceiling and replaced by a reason past it — a page that fails to open tells
|
|
14
|
+
// you nothing about the memory it was supposed to show.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
/** Beyond this a file is named and measured, not shown. */
|
|
17
|
+
export const MAX_CONTENT_BYTES = 256 * 1024;
|
|
18
|
+
/** Rendered inline as a data URL; anything else is offered as a path. */
|
|
19
|
+
const IMAGE_FORMATS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg']);
|
|
20
|
+
const MIME = {
|
|
21
|
+
png: 'image/png',
|
|
22
|
+
jpg: 'image/jpeg',
|
|
23
|
+
jpeg: 'image/jpeg',
|
|
24
|
+
gif: 'image/gif',
|
|
25
|
+
webp: 'image/webp',
|
|
26
|
+
svg: 'image/svg+xml',
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The nodes named, built exactly as the whole graph is — same inlining, same
|
|
30
|
+
* excuses, same degree, which is counted over every edge rather than over the
|
|
31
|
+
* slice: a node's degree is a fact about the memory, not about the cut.
|
|
32
|
+
*/
|
|
33
|
+
export async function buildMemorySlice(store, ids, opts = {}) {
|
|
34
|
+
const graph = store.graph;
|
|
35
|
+
const all = graph.edges();
|
|
36
|
+
const wanted = new Set(ids);
|
|
37
|
+
return {
|
|
38
|
+
nodes: await inline(store, graph.nodes().filter((n) => wanted.has(n.id)), degrees(all), graph.superseded(), opts.maxContentBytes ?? MAX_CONTENT_BYTES),
|
|
39
|
+
edges: all.filter((e) => wanted.has(e.source) && wanted.has(e.target)),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export async function buildMemoryReport(store, opts = {}) {
|
|
43
|
+
const graph = store.graph;
|
|
44
|
+
const nodes = graph.nodes();
|
|
45
|
+
const edges = graph.edges();
|
|
46
|
+
const limit = opts.maxContentBytes ?? MAX_CONTENT_BYTES;
|
|
47
|
+
const built = await inline(store, nodes, degrees(edges), graph.superseded(), limit);
|
|
48
|
+
const vectors = store.vectors;
|
|
49
|
+
return {
|
|
50
|
+
title: opts.title ?? 'Memory',
|
|
51
|
+
dir: store.dir,
|
|
52
|
+
generatedAt: (opts.now ?? (() => new Date().toISOString()))(),
|
|
53
|
+
embedding: store.embedding,
|
|
54
|
+
vectors: vectors
|
|
55
|
+
? { rows: vectors.rows, dims: vectors.dims, bytes: vectors.rows * vectors.dims * 4 }
|
|
56
|
+
: undefined,
|
|
57
|
+
nodes: built,
|
|
58
|
+
edges,
|
|
59
|
+
kinds: [...new Set(nodes.map((n) => n.kind))].sort(),
|
|
60
|
+
audiences: [...new Set(nodes.flatMap((n) => n.audience))].sort(),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function degrees(edges) {
|
|
64
|
+
const degree = new Map();
|
|
65
|
+
for (const e of edges) {
|
|
66
|
+
degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
|
|
67
|
+
degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
|
|
68
|
+
}
|
|
69
|
+
return degree;
|
|
70
|
+
}
|
|
71
|
+
/** Files are read in parallel: one memory can hold hundreds, independently. */
|
|
72
|
+
async function inline(store, nodes, degree, stale, limit) {
|
|
73
|
+
return Promise.all(nodes.map(async (n) => ({
|
|
74
|
+
...plain(n),
|
|
75
|
+
stale: stale.has(n.id),
|
|
76
|
+
degree: degree.get(n.id) ?? 0,
|
|
77
|
+
...(n.file ? await body(store.dir, n.file, limit) : {}),
|
|
78
|
+
})));
|
|
79
|
+
}
|
|
80
|
+
/** The node's own fields, without the graph attributes graphology adds. */
|
|
81
|
+
function plain(n) {
|
|
82
|
+
return {
|
|
83
|
+
id: n.id,
|
|
84
|
+
kind: n.kind,
|
|
85
|
+
text: n.text,
|
|
86
|
+
audience: [...n.audience],
|
|
87
|
+
createdAt: n.createdAt,
|
|
88
|
+
updatedAt: n.updatedAt,
|
|
89
|
+
lastUsedAt: n.lastUsedAt,
|
|
90
|
+
useCount: n.useCount,
|
|
91
|
+
revision: n.revision,
|
|
92
|
+
metadata: n.metadata,
|
|
93
|
+
file: n.file,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A remembered file, as much of it as is worth carrying. The recorded size is
|
|
98
|
+
* not trusted for the decision — the bytes on disk are what the page would
|
|
99
|
+
* have to hold — and a file that has gone missing says so rather than making
|
|
100
|
+
* the node look contentless.
|
|
101
|
+
*/
|
|
102
|
+
async function body(dir, file, limit) {
|
|
103
|
+
const path = hostPath(dir, file);
|
|
104
|
+
let size;
|
|
105
|
+
try {
|
|
106
|
+
size = (await stat(path)).size;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return { omitted: 'missing' };
|
|
110
|
+
}
|
|
111
|
+
if (size > limit) {
|
|
112
|
+
return { omitted: 'too-big' };
|
|
113
|
+
}
|
|
114
|
+
let buf;
|
|
115
|
+
try {
|
|
116
|
+
buf = await readFile(path);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return { omitted: 'unreadable' };
|
|
120
|
+
}
|
|
121
|
+
if (IMAGE_FORMATS.has(file.format)) {
|
|
122
|
+
return { image: `data:${MIME[file.format]};base64,${buf.toString('base64')}` };
|
|
123
|
+
}
|
|
124
|
+
// A NUL byte in the first few KiB is the cheap, boring test for "this is
|
|
125
|
+
// not text", and it is right about every format anyone remembers on purpose.
|
|
126
|
+
if (buf.subarray(0, 8192).includes(0)) {
|
|
127
|
+
return { omitted: 'binary' };
|
|
128
|
+
}
|
|
129
|
+
return { content: buf.toString('utf8') };
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=report.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { MemoryGraph } from './graph.ts';
|
|
2
|
+
import { VectorBlock } from './vectors.ts';
|
|
3
|
+
export declare const MEMORY_VERSION = 1;
|
|
4
|
+
export declare const GRAPH_FILE = "graph.json";
|
|
5
|
+
export declare const VECTORS_FILE = "vectors.f32";
|
|
6
|
+
export declare const VECTOR_IDS_FILE = "vectors.json";
|
|
7
|
+
export declare const MANIFEST_FILE = "manifest.json";
|
|
8
|
+
export declare const FILES_DIR = "files";
|
|
9
|
+
export declare const LOCK_FILE = ".lock";
|
|
10
|
+
export interface MemoryEmbedding {
|
|
11
|
+
model: string;
|
|
12
|
+
dimensions: number;
|
|
13
|
+
}
|
|
14
|
+
export interface MemoryManifest {
|
|
15
|
+
version: number;
|
|
16
|
+
/** absent when the project configured no embedder and ranks lexically */
|
|
17
|
+
embedding?: MemoryEmbedding;
|
|
18
|
+
nodes: number;
|
|
19
|
+
edges: number;
|
|
20
|
+
updatedAt: string;
|
|
21
|
+
}
|
|
22
|
+
export interface OpenOptions {
|
|
23
|
+
/** the embedder the caller intends to use; a mismatch with the manifest is fatal */
|
|
24
|
+
embedding?: MemoryEmbedding;
|
|
25
|
+
/** take the directory lock; off for read-only inspection (`zen memory ls`) */
|
|
26
|
+
lock?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export declare class MemoryStore {
|
|
29
|
+
#private;
|
|
30
|
+
readonly dir: string;
|
|
31
|
+
readonly graph: MemoryGraph;
|
|
32
|
+
private constructor();
|
|
33
|
+
static open(dir: string, opts?: OpenOptions): Promise<MemoryStore>;
|
|
34
|
+
get embedding(): MemoryEmbedding | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Records the vectoriser actually in use. A width is only knowable from a
|
|
37
|
+
* real response, so a memory embedded for the first time learns it here
|
|
38
|
+
* rather than making the caller declare a number it would have to keep in
|
|
39
|
+
* step with the model.
|
|
40
|
+
*/
|
|
41
|
+
adopt(embedding: MemoryEmbedding): void;
|
|
42
|
+
/** Absent when no embedder is configured; recall then falls back to term overlap. */
|
|
43
|
+
get vectors(): VectorBlock | undefined;
|
|
44
|
+
filePath(name: string): string;
|
|
45
|
+
/** Manifest last: a torn commit reads as no memory, never as a partial one. */
|
|
46
|
+
commit(): Promise<void>;
|
|
47
|
+
release(): void;
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=store.d.ts.map
|