peon-mem 1.0.0
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/LICENSE +21 -0
- package/README.md +301 -0
- package/bin/peon-mem.mjs +273 -0
- package/dist/brain.d.ts +72 -0
- package/dist/brain.js +224 -0
- package/dist/compression.d.ts +9 -0
- package/dist/compression.js +37 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +99 -0
- package/dist/daemon-cli.d.ts +2 -0
- package/dist/daemon-cli.js +54 -0
- package/dist/daemon.d.ts +23 -0
- package/dist/daemon.js +1078 -0
- package/dist/embedding-store.d.ts +43 -0
- package/dist/embedding-store.js +169 -0
- package/dist/embeddings.d.ts +93 -0
- package/dist/embeddings.js +345 -0
- package/dist/entities.d.ts +61 -0
- package/dist/entities.js +191 -0
- package/dist/entity-extraction.d.ts +33 -0
- package/dist/entity-extraction.js +75 -0
- package/dist/eval-metrics.d.ts +27 -0
- package/dist/eval-metrics.js +50 -0
- package/dist/evaluation.d.ts +58 -0
- package/dist/evaluation.js +244 -0
- package/dist/global-extraction.d.ts +15 -0
- package/dist/global-extraction.js +61 -0
- package/dist/global-memory.d.ts +43 -0
- package/dist/global-memory.js +306 -0
- package/dist/global-promotion.d.ts +25 -0
- package/dist/global-promotion.js +29 -0
- package/dist/hyde.d.ts +31 -0
- package/dist/hyde.js +46 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +246 -0
- package/dist/injection.d.ts +38 -0
- package/dist/injection.js +133 -0
- package/dist/logger.d.ts +17 -0
- package/dist/logger.js +63 -0
- package/dist/memory-mutations.d.ts +24 -0
- package/dist/memory-mutations.js +57 -0
- package/dist/memory-store.d.ts +194 -0
- package/dist/memory-store.js +1205 -0
- package/dist/monitor.d.ts +13 -0
- package/dist/monitor.js +977 -0
- package/dist/overview.d.ts +73 -0
- package/dist/overview.js +104 -0
- package/dist/processor.d.ts +90 -0
- package/dist/processor.js +450 -0
- package/dist/quality.d.ts +86 -0
- package/dist/quality.js +338 -0
- package/dist/recuration.d.ts +13 -0
- package/dist/recuration.js +65 -0
- package/dist/reranker.d.ts +34 -0
- package/dist/reranker.js +89 -0
- package/dist/retrieval.d.ts +106 -0
- package/dist/retrieval.js +392 -0
- package/dist/session-index.d.ts +34 -0
- package/dist/session-index.js +87 -0
- package/dist/temporal.d.ts +20 -0
- package/dist/temporal.js +62 -0
- package/dist/token-ab-monitor.d.ts +1 -0
- package/dist/token-ab-monitor.js +7 -0
- package/dist/tools.d.ts +232 -0
- package/dist/tools.js +546 -0
- package/dist/types.d.ts +169 -0
- package/dist/types.js +1 -0
- package/docs/assets/neural-universe.png +0 -0
- package/package.json +57 -0
- package/scripts/claude-peon-hook.mjs +522 -0
- package/scripts/codex-peon-hook.mjs +4 -0
- package/scripts/eval-retrieval-labeled.mjs +135 -0
- package/scripts/eval-retrieval.mjs +96 -0
- package/scripts/evaluate-peon.mjs +47 -0
- package/scripts/install-peon-stl.mjs +82 -0
- package/scripts/install-peon.mjs +318 -0
- package/scripts/lib/eval-ledger.mjs +104 -0
- package/scripts/lib/stl-classify.mjs +44 -0
- package/scripts/longmemeval-eval.mjs +144 -0
- package/scripts/peon-report.mjs +155 -0
- package/scripts/peon-stl.mjs +506 -0
- package/scripts/token-ab-monitor.html +235 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic entity resolution for the knowledge graph.
|
|
3
|
+
*
|
|
4
|
+
* The old `inferEntities` (file-extension regex + backtick spans) produced a broken graph:
|
|
5
|
+
* - PHANTOM nodes: the space in a path like ".../Project_x 2/peon-mcp/src/daemon.ts" let the
|
|
6
|
+
* regex start mid-path, yielding "2/peon-mcp/src/daemon.ts".
|
|
7
|
+
* - ALIAS FRAGMENTATION: one file appeared under up to 7 surface forms (daemon.ts,
|
|
8
|
+
* src/daemon.ts, peon-mcp/src/daemon.ts, /Users/.../daemon.ts, worktree paths, ...),
|
|
9
|
+
* each a distinct node — so spreading activation never connected them.
|
|
10
|
+
*
|
|
11
|
+
* This module canonicalizes a raw entity string to a stable key + classifies it into a
|
|
12
|
+
* NAMESPACE (code vs domain) so retrieval can keep file/symbol co-occurrence from drowning
|
|
13
|
+
* domain concepts. Fully deterministic — no model, no network.
|
|
14
|
+
*/
|
|
15
|
+
export type EntityNamespace = "code" | "domain";
|
|
16
|
+
export type EntityKind = "file" | "symbol" | "concept";
|
|
17
|
+
export interface CanonicalEntity {
|
|
18
|
+
/** Stable dedup key (canonical form). */
|
|
19
|
+
key: string;
|
|
20
|
+
/** Display name (canonical form, original-ish casing). */
|
|
21
|
+
name: string;
|
|
22
|
+
kind: EntityKind;
|
|
23
|
+
namespace: EntityNamespace;
|
|
24
|
+
}
|
|
25
|
+
/** Canonicalize one raw entity string. Returns null for junk (empty, too long, pure noise). */
|
|
26
|
+
export declare function canonicalizeEntity(raw: string): CanonicalEntity | null;
|
|
27
|
+
/**
|
|
28
|
+
* Extract DOMAIN entities from prose: products/acronyms with internal capitals or digits
|
|
29
|
+
* (MaskSQL, NL2SQL, BIRD, DTS-SQL, GPT-5) and short proper-noun sequences (Shantanu Sharma).
|
|
30
|
+
* Conservative — single sentence-initial Capitalized words are NOT entities (avoids noise) —
|
|
31
|
+
* so the 83% of beliefs that are prose finally get graph-linkable concepts.
|
|
32
|
+
*/
|
|
33
|
+
export declare function extractDomainEntities(content: string): CanonicalEntity[];
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the entities mentioned in a piece of content (file paths + backtick spans + domain
|
|
36
|
+
* proper nouns/products), deduped to canonical entities. Replaces the old regex-only `inferEntities`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveEntities(content: string, extra?: readonly string[]): CanonicalEntity[];
|
|
39
|
+
/** Back-compat: canonical entity KEYS for the string[] `entities` field on records. */
|
|
40
|
+
export declare function inferCanonicalEntities(content: string, extra?: readonly string[]): string[];
|
|
41
|
+
export interface RegistryEntity {
|
|
42
|
+
key: string;
|
|
43
|
+
name: string;
|
|
44
|
+
kind: EntityKind;
|
|
45
|
+
namespace: EntityNamespace;
|
|
46
|
+
/** Surface forms that fold into this canonical entity. */
|
|
47
|
+
aliases: string[];
|
|
48
|
+
/** Mention count across the brain (graph salience / hub indicator). */
|
|
49
|
+
salience: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build the canonical entity registry from every entity occurrence across all records.
|
|
53
|
+
* Performs cross-form ALIAS MERGE: a bare basename ("daemon.ts") folds into the unique
|
|
54
|
+
* path key that shares it ("src/daemon.ts") — finishing the de-fragmentation 1a starts.
|
|
55
|
+
* Ambiguous basenames (two distinct paths share it) are left alone. Returns the registry
|
|
56
|
+
* plus a `canonical(rawKey) -> finalKey` map for rewriting record entities and graph nodes.
|
|
57
|
+
*/
|
|
58
|
+
export declare function buildEntityRegistry(entityKeys: readonly string[]): {
|
|
59
|
+
entities: RegistryEntity[];
|
|
60
|
+
canonical: Map<string, string>;
|
|
61
|
+
};
|
package/dist/entities.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic entity resolution for the knowledge graph.
|
|
3
|
+
*
|
|
4
|
+
* The old `inferEntities` (file-extension regex + backtick spans) produced a broken graph:
|
|
5
|
+
* - PHANTOM nodes: the space in a path like ".../Project_x 2/peon-mcp/src/daemon.ts" let the
|
|
6
|
+
* regex start mid-path, yielding "2/peon-mcp/src/daemon.ts".
|
|
7
|
+
* - ALIAS FRAGMENTATION: one file appeared under up to 7 surface forms (daemon.ts,
|
|
8
|
+
* src/daemon.ts, peon-mcp/src/daemon.ts, /Users/.../daemon.ts, worktree paths, ...),
|
|
9
|
+
* each a distinct node — so spreading activation never connected them.
|
|
10
|
+
*
|
|
11
|
+
* This module canonicalizes a raw entity string to a stable key + classifies it into a
|
|
12
|
+
* NAMESPACE (code vs domain) so retrieval can keep file/symbol co-occurrence from drowning
|
|
13
|
+
* domain concepts. Fully deterministic — no model, no network.
|
|
14
|
+
*/
|
|
15
|
+
// Source-root tokens: a path is canonicalized to the suffix starting at the first of these,
|
|
16
|
+
// so "/Users/.../peon-mcp/src/daemon.ts", "peon-mcp/src/daemon.ts" and "src/daemon.ts" all
|
|
17
|
+
// collapse to "src/daemon.ts".
|
|
18
|
+
const SRC_ROOTS = new Set(["src", "lib", "scripts", "test", "tests", "app", "apps", "packages", "dist", "bin"]);
|
|
19
|
+
const FILE_EXT_RE = /\.(ts|tsx|js|jsx|mjs|cjs|json|md|mdx|html|css|scss|py|ipynb|pdf|txt|yml|yaml|toml|sh|sql|rs|go|java|rb|c|cpp|h)$/i;
|
|
20
|
+
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*(?:[.#][A-Za-z_$][\w$]*)*$/;
|
|
21
|
+
/** Canonicalize one raw entity string. Returns null for junk (empty, too long, pure noise). */
|
|
22
|
+
export function canonicalizeEntity(raw) {
|
|
23
|
+
const s = (raw ?? "").trim().replace(/^[`'"]+|[`'"]+$/g, "").trim();
|
|
24
|
+
if (s.length < 2 || s.length > 200)
|
|
25
|
+
return null;
|
|
26
|
+
const pathLike = s.includes("/") || s.includes("\\") || FILE_EXT_RE.test(s);
|
|
27
|
+
if (pathLike) {
|
|
28
|
+
// Normalize separators, drop a leading "./" and a leading truncation marker ("..."/"…src/…").
|
|
29
|
+
// NOTE: we deliberately do NOT blind-strip a leading numeric segment — the SRC_ROOT slice and
|
|
30
|
+
// parent/basename fallback below already collapse the "2/" phantom from "Project_x 2" WITHOUT
|
|
31
|
+
// eating real numeric directories like "2024/notes.md".
|
|
32
|
+
const p = s.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^(?:\.{3}|…)\/?/, "");
|
|
33
|
+
const segs = p.split("/").filter((seg) => seg && seg !== "." && seg !== "..");
|
|
34
|
+
if (segs.length === 0)
|
|
35
|
+
return null;
|
|
36
|
+
const rootIdx = segs.findIndex((seg) => SRC_ROOTS.has(seg));
|
|
37
|
+
// With a known source root, key from there (so all prefixes of src/daemon.ts collapse).
|
|
38
|
+
// Without one, keep parent/basename — NOT bare basename — so two different DESIGN.md /
|
|
39
|
+
// SKILL.md in different dirs stay DISTINCT entities (avoid merging unrelated files).
|
|
40
|
+
const key = rootIdx >= 0
|
|
41
|
+
? segs.slice(rootIdx).join("/")
|
|
42
|
+
: segs.length >= 2 ? segs.slice(-2).join("/") : segs[segs.length - 1];
|
|
43
|
+
if (!key)
|
|
44
|
+
return null;
|
|
45
|
+
return { key, name: key, kind: "file", namespace: "code" };
|
|
46
|
+
}
|
|
47
|
+
if (IDENTIFIER_RE.test(s)) {
|
|
48
|
+
// A product/acronym (starts-uppercase or all-caps, with internal caps or a digit — MaskSQL,
|
|
49
|
+
// BIRD, NL2SQL) is a DOMAIN concept, keyed lowercase so the backtick `MaskSQL` and the prose
|
|
50
|
+
// "MaskSQL" (via extractDomainEntities) collapse to ONE node. A lowercase-initial camelCase
|
|
51
|
+
// identifier (rankMemoryRecords) is a code symbol.
|
|
52
|
+
const internalCaps = /[A-Z]{2,}/.test(s) || /[a-z][A-Z]/.test(s) || /\d/.test(s);
|
|
53
|
+
const productLike = internalCaps && (/^[A-Z]/.test(s) || s === s.toUpperCase());
|
|
54
|
+
if (productLike)
|
|
55
|
+
return { key: s.toLowerCase(), name: s, kind: "concept", namespace: "domain" };
|
|
56
|
+
if (/[A-Z_]/.test(s.slice(1)))
|
|
57
|
+
return { key: s, name: s, kind: "symbol", namespace: "code" };
|
|
58
|
+
// lowercase single token (e.g. "vllm", "ollama") — treat as a domain concept
|
|
59
|
+
return { key: s.toLowerCase(), name: s, kind: "concept", namespace: "domain" };
|
|
60
|
+
}
|
|
61
|
+
// Multi-word phrase / proper noun → domain concept.
|
|
62
|
+
return { key: s.toLowerCase(), name: s, kind: "concept", namespace: "domain" };
|
|
63
|
+
}
|
|
64
|
+
// Common capitalized words that START sentences / clauses — not domain entities.
|
|
65
|
+
const PROPER_NOUN_STOP = new Set([
|
|
66
|
+
"the", "this", "that", "these", "those", "a", "an", "it", "we", "i", "you", "he", "she", "they",
|
|
67
|
+
"if", "when", "then", "for", "and", "but", "or", "so", "to", "in", "on", "of", "at", "by", "as",
|
|
68
|
+
"use", "used", "using", "add", "added", "fix", "fixed", "make", "made", "set", "run", "build",
|
|
69
|
+
"now", "also", "after", "before", "while", "since", "because", "however", "note", "todo",
|
|
70
|
+
"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"
|
|
71
|
+
]);
|
|
72
|
+
// Acronyms/keywords that are too generic to be useful domain entities.
|
|
73
|
+
const ACRONYM_STOP = new Set(["OK", "ID", "TODO", "FIXME", "JSON", "HTTP", "HTTPS", "URL", "API", "CLI", "UI", "MD", "PDF", "CSV", "YAML", "AM", "PM", "EST", "IST", "UTC"]);
|
|
74
|
+
/**
|
|
75
|
+
* Extract DOMAIN entities from prose: products/acronyms with internal capitals or digits
|
|
76
|
+
* (MaskSQL, NL2SQL, BIRD, DTS-SQL, GPT-5) and short proper-noun sequences (Shantanu Sharma).
|
|
77
|
+
* Conservative — single sentence-initial Capitalized words are NOT entities (avoids noise) —
|
|
78
|
+
* so the 83% of beliefs that are prose finally get graph-linkable concepts.
|
|
79
|
+
*/
|
|
80
|
+
export function extractDomainEntities(content) {
|
|
81
|
+
const out = [];
|
|
82
|
+
const add = (name) => {
|
|
83
|
+
const key = name.toLowerCase();
|
|
84
|
+
out.push({ key, name, kind: "concept", namespace: "domain" });
|
|
85
|
+
};
|
|
86
|
+
// products/acronyms: a token with an internal run of ≥2 capitals or a digit (MaskSQL, BIRD, NL2SQL, GPT-5, DTS-SQL)
|
|
87
|
+
for (const m of content.matchAll(/\b[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*\b/g)) {
|
|
88
|
+
const tok = m[0];
|
|
89
|
+
if (tok.length < 3 || tok.length > 40)
|
|
90
|
+
continue;
|
|
91
|
+
if (ACRONYM_STOP.has(tok.toUpperCase()))
|
|
92
|
+
continue;
|
|
93
|
+
const hasInternalCaps = /[A-Z]{2,}/.test(tok) || /[a-z][A-Z]/.test(tok) || /[A-Za-z]\d|\d[A-Za-z]/.test(tok);
|
|
94
|
+
if (hasInternalCaps)
|
|
95
|
+
add(tok);
|
|
96
|
+
}
|
|
97
|
+
// proper-noun sequences: 2-3 Capitalized words (Shantanu Sharma, Master Project)
|
|
98
|
+
for (const m of content.matchAll(/\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,2})\b/g)) {
|
|
99
|
+
const phrase = m[1];
|
|
100
|
+
if (PROPER_NOUN_STOP.has(phrase.split(/\s+/)[0].toLowerCase()))
|
|
101
|
+
continue; // drop "The Death ...", "When Foo ..."
|
|
102
|
+
add(phrase);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the entities mentioned in a piece of content (file paths + backtick spans + domain
|
|
108
|
+
* proper nouns/products), deduped to canonical entities. Replaces the old regex-only `inferEntities`.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveEntities(content, extra = []) {
|
|
111
|
+
const raw = [...extra];
|
|
112
|
+
// file-like spans (anchored on a real extension so we don't grab prose)
|
|
113
|
+
for (const m of content.matchAll(/[\w./\\-]*\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|mdx|html|css|scss|py|ipynb|pdf|txt|yml|yaml|toml|sh|sql)\b/gi)) {
|
|
114
|
+
raw.push(m[0]);
|
|
115
|
+
}
|
|
116
|
+
// backtick-quoted spans
|
|
117
|
+
for (const m of content.matchAll(/`([^`]+)`/g))
|
|
118
|
+
raw.push(m[1]);
|
|
119
|
+
const byKey = new Map();
|
|
120
|
+
for (const r of raw) {
|
|
121
|
+
const c = canonicalizeEntity(r);
|
|
122
|
+
if (c && !byKey.has(c.key))
|
|
123
|
+
byKey.set(c.key, c);
|
|
124
|
+
}
|
|
125
|
+
// domain entities from prose (the bulk of beliefs that have no files/backticks) — already
|
|
126
|
+
// classified domain, added after so file/symbol classification of any overlap wins.
|
|
127
|
+
for (const c of extractDomainEntities(content)) {
|
|
128
|
+
if (!byKey.has(c.key))
|
|
129
|
+
byKey.set(c.key, c);
|
|
130
|
+
}
|
|
131
|
+
// Separate quotas so a long file list can't crowd domain concepts out of the cap entirely
|
|
132
|
+
// (domain entities are the associative-recall signal; they were appended last and got sliced).
|
|
133
|
+
const all = [...byKey.values()];
|
|
134
|
+
const code = all.filter((c) => c.namespace === "code").slice(0, 10);
|
|
135
|
+
const domain = all.filter((c) => c.namespace === "domain").slice(0, 8);
|
|
136
|
+
return [...code, ...domain];
|
|
137
|
+
}
|
|
138
|
+
/** Back-compat: canonical entity KEYS for the string[] `entities` field on records. */
|
|
139
|
+
export function inferCanonicalEntities(content, extra = []) {
|
|
140
|
+
return resolveEntities(content, extra).map((e) => e.key);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Build the canonical entity registry from every entity occurrence across all records.
|
|
144
|
+
* Performs cross-form ALIAS MERGE: a bare basename ("daemon.ts") folds into the unique
|
|
145
|
+
* path key that shares it ("src/daemon.ts") — finishing the de-fragmentation 1a starts.
|
|
146
|
+
* Ambiguous basenames (two distinct paths share it) are left alone. Returns the registry
|
|
147
|
+
* plus a `canonical(rawKey) -> finalKey` map for rewriting record entities and graph nodes.
|
|
148
|
+
*/
|
|
149
|
+
export function buildEntityRegistry(entityKeys) {
|
|
150
|
+
const canonOf = new Map(); // rawKey -> its CanonicalEntity
|
|
151
|
+
for (const raw of entityKeys) {
|
|
152
|
+
if (canonOf.has(raw))
|
|
153
|
+
continue;
|
|
154
|
+
const c = canonicalizeEntity(raw);
|
|
155
|
+
if (c)
|
|
156
|
+
canonOf.set(raw, c);
|
|
157
|
+
}
|
|
158
|
+
// basename -> set of distinct path keys that end in it
|
|
159
|
+
const basenameToPaths = new Map();
|
|
160
|
+
for (const c of canonOf.values()) {
|
|
161
|
+
if (!c.key.includes("/"))
|
|
162
|
+
continue;
|
|
163
|
+
const base = c.key.split("/").pop();
|
|
164
|
+
(basenameToPaths.get(base) ?? basenameToPaths.set(base, new Set()).get(base)).add(c.key);
|
|
165
|
+
}
|
|
166
|
+
const aliasTarget = (c) => {
|
|
167
|
+
if (c.kind === "file" && !c.key.includes("/")) {
|
|
168
|
+
const matches = basenameToPaths.get(c.key);
|
|
169
|
+
if (matches && matches.size === 1)
|
|
170
|
+
return [...matches][0]; // unique → fold in
|
|
171
|
+
}
|
|
172
|
+
return c.key;
|
|
173
|
+
};
|
|
174
|
+
const canonical = new Map();
|
|
175
|
+
for (const [raw, c] of canonOf)
|
|
176
|
+
canonical.set(raw, aliasTarget(c));
|
|
177
|
+
const merged = new Map();
|
|
178
|
+
for (const raw of entityKeys) {
|
|
179
|
+
const c = canonOf.get(raw);
|
|
180
|
+
if (!c)
|
|
181
|
+
continue;
|
|
182
|
+
const finalKey = canonical.get(raw);
|
|
183
|
+
const entry = merged.get(finalKey) ?? { key: finalKey, name: finalKey, kind: c.kind, namespace: c.namespace, aliases: [], salience: 0 };
|
|
184
|
+
entry.salience += 1; // one mention occurrence
|
|
185
|
+
for (const form of [raw, c.key])
|
|
186
|
+
if (form !== finalKey && !entry.aliases.includes(form))
|
|
187
|
+
entry.aliases.push(form);
|
|
188
|
+
merged.set(finalKey, entry);
|
|
189
|
+
}
|
|
190
|
+
return { entities: [...merged.values()].sort((a, b) => b.salience - a.salience), canonical };
|
|
191
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { PeonConfig } from "./config.js";
|
|
2
|
+
import type { FetchLike } from "./reranker.js";
|
|
3
|
+
/**
|
|
4
|
+
* Model-grade DOMAIN entity extraction. The deterministic resolver (entities.ts) reliably catches
|
|
5
|
+
* files/symbols and obvious products/proper-nouns, but it misses the domain knowledge that matters
|
|
6
|
+
* most for associative recall — people, papers, methods, datasets, organizations stated in prose
|
|
7
|
+
* ("the professor's MaskSQL idea", "AskData on BIRD"). The consolidation panel's one firm
|
|
8
|
+
* conclusion was that entity extraction is THE place model quality earns its keep (unlike
|
|
9
|
+
* consolidation fidelity, where it's marginal). So this runs ONE batched LLM pass over the belief
|
|
10
|
+
* snippets and returns the named entities per snippet, to be MERGED with the deterministic set.
|
|
11
|
+
*
|
|
12
|
+
* Strictly optional + fail-safe: no API key, AI disabled, empty input, or any LLM/parse failure
|
|
13
|
+
* returns an empty map and the caller keeps the deterministic entities. Injectable fetch for tests.
|
|
14
|
+
*/
|
|
15
|
+
export interface ExtractItem {
|
|
16
|
+
/** Stable key the caller maps results back by (e.g. normalized content). */
|
|
17
|
+
key: string;
|
|
18
|
+
content: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ExtractOptions {
|
|
21
|
+
config: PeonConfig;
|
|
22
|
+
model?: string;
|
|
23
|
+
fetchImpl?: FetchLike;
|
|
24
|
+
/** Max snippets per call + per-snippet char cap (keeps the prompt and cost bounded). */
|
|
25
|
+
maxItems?: number;
|
|
26
|
+
snippetChars?: number;
|
|
27
|
+
}
|
|
28
|
+
export declare function extractDomainEntitiesViaModel(items: ExtractItem[], options: ExtractOptions): Promise<Map<string, string[]>>;
|
|
29
|
+
/** Tolerant parse of `[{n, entities:[...]}, ...]` from a model response. */
|
|
30
|
+
export declare function parseEntityArray(content: string): Array<{
|
|
31
|
+
n: number;
|
|
32
|
+
entities: string[];
|
|
33
|
+
}>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const DEFAULT_MAX_ITEMS = 40;
|
|
2
|
+
const DEFAULT_SNIPPET_CHARS = 240;
|
|
3
|
+
export async function extractDomainEntitiesViaModel(items, options) {
|
|
4
|
+
const out = new Map();
|
|
5
|
+
const { config } = options;
|
|
6
|
+
if (items.length === 0 || config.aiMode === "off" || !config.openRouterApiKey)
|
|
7
|
+
return out;
|
|
8
|
+
const doFetch = options.fetchImpl ?? globalThis.fetch;
|
|
9
|
+
if (!doFetch)
|
|
10
|
+
return out;
|
|
11
|
+
const maxItems = Math.max(1, Math.trunc(options.maxItems ?? DEFAULT_MAX_ITEMS));
|
|
12
|
+
const snippetChars = Math.max(40, Math.trunc(options.snippetChars ?? DEFAULT_SNIPPET_CHARS));
|
|
13
|
+
const batch = items.slice(0, maxItems);
|
|
14
|
+
const numbered = batch.map((it, i) => `${i + 1}. ${truncate(it.content, snippetChars)}`).join("\n");
|
|
15
|
+
const system = "You extract NAMED DOMAIN entities from memory snippets for a knowledge graph. For each numbered " +
|
|
16
|
+
"snippet, list the specific named entities it mentions: people, papers/methods/models, datasets, " +
|
|
17
|
+
"projects, organizations, and distinctive technical concepts. Use each entity's canonical surface " +
|
|
18
|
+
"form. EXCLUDE generic words, file paths, and code identifiers. Return ONLY a JSON array of objects " +
|
|
19
|
+
'{"n": <snippet number>, "entities": ["..."]}, empty array when a snippet names none. No prose, no fences.';
|
|
20
|
+
const user = `Snippets:\n${numbered}\n\nJSON array:`;
|
|
21
|
+
try {
|
|
22
|
+
const response = await doFetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { Authorization: `Bearer ${config.openRouterApiKey}`, "Content-Type": "application/json" },
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
model: options.model ?? config.processingModel,
|
|
27
|
+
messages: [
|
|
28
|
+
{ role: "system", content: system },
|
|
29
|
+
{ role: "user", content: user }
|
|
30
|
+
],
|
|
31
|
+
temperature: 0
|
|
32
|
+
})
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok)
|
|
35
|
+
return out;
|
|
36
|
+
const json = (await response.json());
|
|
37
|
+
const parsed = parseEntityArray(json.choices?.[0]?.message?.content ?? "");
|
|
38
|
+
for (const row of parsed) {
|
|
39
|
+
const idx = row.n - 1;
|
|
40
|
+
if (idx >= 0 && idx < batch.length && row.entities.length > 0) {
|
|
41
|
+
out.set(batch[idx].key, row.entities);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return out; // any failure → deterministic-only
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Tolerant parse of `[{n, entities:[...]}, ...]` from a model response. */
|
|
51
|
+
export function parseEntityArray(content) {
|
|
52
|
+
const match = content.match(/\[[\s\S]*\]/);
|
|
53
|
+
if (!match)
|
|
54
|
+
return [];
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(match[0]);
|
|
57
|
+
if (!Array.isArray(parsed))
|
|
58
|
+
return [];
|
|
59
|
+
return parsed
|
|
60
|
+
.map((row) => ({
|
|
61
|
+
n: typeof row?.n === "number" ? Math.trunc(row.n) : Number.parseInt(String(row?.n), 10),
|
|
62
|
+
entities: Array.isArray(row?.entities)
|
|
63
|
+
? row.entities.filter((e) => typeof e === "string" && e.trim().length > 1).map((e) => e.trim()).slice(0, 12)
|
|
64
|
+
: []
|
|
65
|
+
}))
|
|
66
|
+
.filter((row) => Number.isInteger(row.n));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function truncate(text, max) {
|
|
73
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
74
|
+
return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
|
|
75
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard ranked-retrieval metrics for the query-driven eval harness. Pure + deterministic so
|
|
3
|
+
* they're unit-testable; the harness (scripts/eval-retrieval-labeled.mjs) feeds them a ranked list
|
|
4
|
+
* of belief ids and a relevance-judged set, and reports graph-off vs graph-on.
|
|
5
|
+
*
|
|
6
|
+
* All take `retrieved` (ranked ids, best first) and `relevant` (the judged-relevant id set).
|
|
7
|
+
*/
|
|
8
|
+
/** Fraction of the relevant items found in the top-k. 1 when there are no relevant items (nothing to miss). */
|
|
9
|
+
export declare function recallAtK(retrieved: readonly string[], relevant: ReadonlySet<string>, k: number): number;
|
|
10
|
+
/** Reciprocal rank of the FIRST relevant hit (1/rank), 0 if none retrieved. Mean over queries = MRR. */
|
|
11
|
+
export declare function reciprocalRank(retrieved: readonly string[], relevant: ReadonlySet<string>): number;
|
|
12
|
+
/** Normalized DCG at k with binary relevance: DCG/IDCG. 1 when there are no relevant items. */
|
|
13
|
+
export declare function ndcgAtK(retrieved: readonly string[], relevant: ReadonlySet<string>, k: number): number;
|
|
14
|
+
export interface QueryScore {
|
|
15
|
+
recall: number;
|
|
16
|
+
rr: number;
|
|
17
|
+
ndcg: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function scoreQuery(retrieved: readonly string[], relevant: ReadonlySet<string>, k: number): QueryScore;
|
|
20
|
+
export interface AggregateScore {
|
|
21
|
+
queries: number;
|
|
22
|
+
recallAtK: number;
|
|
23
|
+
mrr: number;
|
|
24
|
+
ndcgAtK: number;
|
|
25
|
+
}
|
|
26
|
+
/** Mean of per-query scores → Recall@K, MRR, nDCG@K over the whole labeled set. */
|
|
27
|
+
export declare function aggregate(scores: readonly QueryScore[]): AggregateScore;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard ranked-retrieval metrics for the query-driven eval harness. Pure + deterministic so
|
|
3
|
+
* they're unit-testable; the harness (scripts/eval-retrieval-labeled.mjs) feeds them a ranked list
|
|
4
|
+
* of belief ids and a relevance-judged set, and reports graph-off vs graph-on.
|
|
5
|
+
*
|
|
6
|
+
* All take `retrieved` (ranked ids, best first) and `relevant` (the judged-relevant id set).
|
|
7
|
+
*/
|
|
8
|
+
/** Fraction of the relevant items found in the top-k. 1 when there are no relevant items (nothing to miss). */
|
|
9
|
+
export function recallAtK(retrieved, relevant, k) {
|
|
10
|
+
if (relevant.size === 0)
|
|
11
|
+
return 1;
|
|
12
|
+
let hits = 0;
|
|
13
|
+
for (const id of retrieved.slice(0, Math.max(0, k)))
|
|
14
|
+
if (relevant.has(id))
|
|
15
|
+
hits += 1;
|
|
16
|
+
return hits / relevant.size;
|
|
17
|
+
}
|
|
18
|
+
/** Reciprocal rank of the FIRST relevant hit (1/rank), 0 if none retrieved. Mean over queries = MRR. */
|
|
19
|
+
export function reciprocalRank(retrieved, relevant) {
|
|
20
|
+
for (let i = 0; i < retrieved.length; i += 1)
|
|
21
|
+
if (relevant.has(retrieved[i]))
|
|
22
|
+
return 1 / (i + 1);
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
/** Normalized DCG at k with binary relevance: DCG/IDCG. 1 when there are no relevant items. */
|
|
26
|
+
export function ndcgAtK(retrieved, relevant, k) {
|
|
27
|
+
if (relevant.size === 0)
|
|
28
|
+
return 1;
|
|
29
|
+
const top = retrieved.slice(0, Math.max(0, k));
|
|
30
|
+
let dcg = 0;
|
|
31
|
+
for (let i = 0; i < top.length; i += 1)
|
|
32
|
+
if (relevant.has(top[i]))
|
|
33
|
+
dcg += 1 / Math.log2(i + 2);
|
|
34
|
+
const ideal = Math.min(k, relevant.size);
|
|
35
|
+
let idcg = 0;
|
|
36
|
+
for (let i = 0; i < ideal; i += 1)
|
|
37
|
+
idcg += 1 / Math.log2(i + 2);
|
|
38
|
+
return idcg > 0 ? dcg / idcg : 0;
|
|
39
|
+
}
|
|
40
|
+
export function scoreQuery(retrieved, relevant, k) {
|
|
41
|
+
return { recall: recallAtK(retrieved, relevant, k), rr: reciprocalRank(retrieved, relevant), ndcg: ndcgAtK(retrieved, relevant, k) };
|
|
42
|
+
}
|
|
43
|
+
/** Mean of per-query scores → Recall@K, MRR, nDCG@K over the whole labeled set. */
|
|
44
|
+
export function aggregate(scores) {
|
|
45
|
+
const n = scores.length;
|
|
46
|
+
if (n === 0)
|
|
47
|
+
return { queries: 0, recallAtK: 0, mrr: 0, ndcgAtK: 0 };
|
|
48
|
+
const sum = scores.reduce((a, s) => ({ recall: a.recall + s.recall, rr: a.rr + s.rr, ndcg: a.ndcg + s.ndcg }), { recall: 0, rr: 0, ndcg: 0 });
|
|
49
|
+
return { queries: n, recallAtK: sum.recall / n, mrr: sum.rr / n, ndcgAtK: sum.ndcg / n };
|
|
50
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type EvaluationTextInput = string | string[];
|
|
2
|
+
export interface ExpectedMemoryInput {
|
|
3
|
+
id?: string;
|
|
4
|
+
content: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ProcessingJobInput {
|
|
7
|
+
status?: "processed" | "skipped" | "failed" | string;
|
|
8
|
+
model?: string;
|
|
9
|
+
reason?: string;
|
|
10
|
+
estimatedTokens?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface EvaluationInput {
|
|
13
|
+
expectedMemories: Array<string | ExpectedMemoryInput>;
|
|
14
|
+
retrievedText?: EvaluationTextInput;
|
|
15
|
+
injectedText?: EvaluationTextInput;
|
|
16
|
+
processingJobs?: ProcessingJobInput[];
|
|
17
|
+
}
|
|
18
|
+
export interface EvaluationReport {
|
|
19
|
+
expectedCount: number;
|
|
20
|
+
observedItemCount: number;
|
|
21
|
+
matchedExpectedCount: number;
|
|
22
|
+
matchedObservedItemCount: number;
|
|
23
|
+
recall: number;
|
|
24
|
+
coverage: number;
|
|
25
|
+
missingExpectedItems: Array<{
|
|
26
|
+
id: string;
|
|
27
|
+
content: string;
|
|
28
|
+
}>;
|
|
29
|
+
unexpectedNoisyItems: Array<{
|
|
30
|
+
source: "retrieved" | "injected";
|
|
31
|
+
content: string;
|
|
32
|
+
}>;
|
|
33
|
+
matches: Array<{
|
|
34
|
+
expectedId: string;
|
|
35
|
+
expectedContent: string;
|
|
36
|
+
observedSource: "retrieved" | "injected";
|
|
37
|
+
observedContent: string;
|
|
38
|
+
score: number;
|
|
39
|
+
}>;
|
|
40
|
+
costSummary: {
|
|
41
|
+
jobCount: number;
|
|
42
|
+
processedJobs: number;
|
|
43
|
+
skippedJobs: number;
|
|
44
|
+
failedJobs: number;
|
|
45
|
+
totalEstimatedTokens: number;
|
|
46
|
+
byModel: Record<string, {
|
|
47
|
+
jobCount: number;
|
|
48
|
+
estimatedTokens: number;
|
|
49
|
+
}>;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export interface EvaluatePeonProjectInput {
|
|
53
|
+
projectPath: string;
|
|
54
|
+
memoryDirName?: string;
|
|
55
|
+
expectedMemories?: Array<string | ExpectedMemoryInput>;
|
|
56
|
+
}
|
|
57
|
+
export declare function computeEvaluationReport(input: EvaluationInput): EvaluationReport;
|
|
58
|
+
export declare function evaluatePeonProject(input: EvaluatePeonProjectInput): Promise<EvaluationReport>;
|