@hmharness/evolution 0.3.0 → 0.4.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/dist/memory.d.ts +13 -2
- package/dist/memory.js +128 -15
- package/package.json +2 -2
package/dist/memory.d.ts
CHANGED
|
@@ -3,10 +3,18 @@ export interface MemoryNote {
|
|
|
3
3
|
text: string;
|
|
4
4
|
}
|
|
5
5
|
export declare function readNotes(home: string): Promise<MemoryNote[]>;
|
|
6
|
-
|
|
6
|
+
/** Resolve the workspace a cwd belongs to (longest path prefix wins).
|
|
7
|
+
* Returns null outside every workspace - those notes stay global. */
|
|
8
|
+
export declare function workspaceForCwd(home: string, cwd: string): Promise<string | null>;
|
|
9
|
+
export declare function scoreNotes(notes: MemoryNote[], task: string, workspace?: string): Array<{
|
|
7
10
|
note: MemoryNote;
|
|
8
11
|
score: number;
|
|
9
12
|
}>;
|
|
13
|
+
export interface EmbeddingProvider {
|
|
14
|
+
baseUrl: string;
|
|
15
|
+
apiKey: string;
|
|
16
|
+
model: string;
|
|
17
|
+
}
|
|
10
18
|
/**
|
|
11
19
|
* Build the prompt block: top-k task-relevant notes plus the newest few
|
|
12
20
|
* (deduplicated), bounded in chars. Empty string when memory is empty.
|
|
@@ -15,7 +23,10 @@ export declare function retrieveMemory(home: string, task: string, opts?: {
|
|
|
15
23
|
topK?: number;
|
|
16
24
|
newest?: number;
|
|
17
25
|
maxChars?: number;
|
|
26
|
+
workspace?: string;
|
|
27
|
+
embedding?: EmbeddingProvider;
|
|
28
|
+
fetchImpl?: typeof fetch;
|
|
18
29
|
}): Promise<string>;
|
|
19
30
|
/** Legacy full load (tail-bounded) - kept for callers that want everything. */
|
|
20
31
|
export declare function loadMemory(home: string): Promise<string>;
|
|
21
|
-
export declare function appendMemory(home: string, note: string): Promise<void>;
|
|
32
|
+
export declare function appendMemory(home: string, note: string, workspace?: string): Promise<void>;
|
package/dist/memory.js
CHANGED
|
@@ -2,11 +2,22 @@
|
|
|
2
2
|
* @hmharness/evolution - memory
|
|
3
3
|
* Cross-session persistent memory. Notes are append-only lines in
|
|
4
4
|
* memory/memory.md (ACE lesson: append beats rewrite - rewriting is where
|
|
5
|
-
* hard-won context gets lost). Injection is retrieval-based
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* hard-won context gets lost). Injection is retrieval-based and layered:
|
|
6
|
+
*
|
|
7
|
+
* 1. lexical scoring (ASCII words + CJK bigrams, no deps) - the baseline
|
|
8
|
+
* 2. workspace scoping - notes tagged [ws:<name>] are boosted 2.5x when
|
|
9
|
+
* the current task runs inside that workspace and dampened to 0.3x in
|
|
10
|
+
* others. Isolation WITHOUT walls: project-local facts rank first at
|
|
11
|
+
* home, but global lessons stay reachable everywhere. Untagged notes
|
|
12
|
+
* (the entire pre-existing memory) behave exactly as before.
|
|
13
|
+
* 3. optional embedding hybrid - when an embedding provider is passed in,
|
|
14
|
+
* notes are vectorised once (cache: memory/embeddings.json, keyed by
|
|
15
|
+
* content hash) and ranked by cosine similarity blended with the
|
|
16
|
+
* lexical score. Any failure falls back to pure lexical - embeddings
|
|
17
|
+
* are an upgrade, never a dependency.
|
|
8
18
|
*/
|
|
9
|
-
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
19
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
10
21
|
import { join } from 'node:path';
|
|
11
22
|
export async function readNotes(home) {
|
|
12
23
|
let raw;
|
|
@@ -24,6 +35,35 @@ export async function readNotes(home) {
|
|
|
24
35
|
}
|
|
25
36
|
return notes;
|
|
26
37
|
}
|
|
38
|
+
/** Resolve the workspace a cwd belongs to (longest path prefix wins).
|
|
39
|
+
* Returns null outside every workspace - those notes stay global. */
|
|
40
|
+
export async function workspaceForCwd(home, cwd) {
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
const j = JSON.parse(await readFile(join(home, 'workspaces.json'), 'utf8'));
|
|
44
|
+
entries = Array.isArray(j) ? j : (j.workspaces ?? []);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const norm = (p) => p.replace(/[\\/]+/g, '\\').toLowerCase().replace(/\\$/, '');
|
|
50
|
+
const target = norm(cwd);
|
|
51
|
+
let best = null;
|
|
52
|
+
for (const w of entries) {
|
|
53
|
+
if (!w?.path || !w?.name)
|
|
54
|
+
continue;
|
|
55
|
+
const p = norm(w.path);
|
|
56
|
+
if ((target === p || target.startsWith(p + '\\')) && (!best || p.length > best.len)) {
|
|
57
|
+
best = { name: w.name, len: p.length };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return best?.name ?? null;
|
|
61
|
+
}
|
|
62
|
+
const WS_TAG = /\s*\[ws:([^\]]+)\]\s*$/;
|
|
63
|
+
function noteWorkspace(text) {
|
|
64
|
+
const m = text.match(WS_TAG);
|
|
65
|
+
return m ? m[1] : null;
|
|
66
|
+
}
|
|
27
67
|
/** Tokenize for scoring: ASCII words as-is, CJK runs as bigrams. */
|
|
28
68
|
function tokens(text) {
|
|
29
69
|
const out = new Set();
|
|
@@ -35,19 +75,85 @@ function tokens(text) {
|
|
|
35
75
|
}
|
|
36
76
|
return out;
|
|
37
77
|
}
|
|
38
|
-
export function scoreNotes(notes, task) {
|
|
78
|
+
export function scoreNotes(notes, task, workspace) {
|
|
39
79
|
const taskTokens = tokens(task);
|
|
40
|
-
|
|
41
|
-
.map((note, idx) => {
|
|
80
|
+
const ranked = notes.map((note, idx) => {
|
|
42
81
|
const n = tokens(note.text);
|
|
43
82
|
let overlap = 0;
|
|
44
83
|
for (const t of n)
|
|
45
84
|
if (taskTokens.has(t))
|
|
46
85
|
overlap++;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
86
|
+
let score = overlap + idx / Math.max(notes.length, 1) * 0.01;
|
|
87
|
+
// workspace scoping: boost at home, dampen abroad, globals untouched
|
|
88
|
+
const ws = noteWorkspace(note.text);
|
|
89
|
+
if (workspace && ws === workspace)
|
|
90
|
+
score *= 2.5;
|
|
91
|
+
else if (workspace && ws && ws !== workspace)
|
|
92
|
+
score *= 0.3;
|
|
93
|
+
return { note, score };
|
|
94
|
+
});
|
|
95
|
+
return ranked.sort((a, b) => b.score - a.score);
|
|
96
|
+
}
|
|
97
|
+
function cosine(a, b) {
|
|
98
|
+
let dot = 0, na = 0, nb = 0;
|
|
99
|
+
for (let i = 0; i < a.length; i++) {
|
|
100
|
+
dot += a[i] * b[i];
|
|
101
|
+
na += a[i] * a[i];
|
|
102
|
+
nb += b[i] * b[i];
|
|
103
|
+
}
|
|
104
|
+
return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
|
|
105
|
+
}
|
|
106
|
+
async function embed(inputs, p, fetchImpl) {
|
|
107
|
+
const doFetch = fetchImpl ?? fetch;
|
|
108
|
+
try {
|
|
109
|
+
const res = await doFetch(p.baseUrl.replace(/\/+$/, '') + '/embeddings', {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${p.apiKey}` },
|
|
112
|
+
body: JSON.stringify({ model: p.model, input: inputs }),
|
|
113
|
+
signal: AbortSignal.timeout(20_000),
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok)
|
|
116
|
+
return null;
|
|
117
|
+
const j = await res.json();
|
|
118
|
+
const out = (j.data ?? []).map((d) => d.embedding ?? []);
|
|
119
|
+
return out.length === inputs.length ? out : null;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function hybridRank(notes, task, workspace, home, embedding, fetchImpl) {
|
|
126
|
+
const lexical = scoreNotes(notes, task, workspace);
|
|
127
|
+
const maxLex = Math.max(...lexical.map((r) => r.score), 1e-9);
|
|
128
|
+
const cacheFile = join(home, 'memory', 'embeddings.json');
|
|
129
|
+
let cache = {};
|
|
130
|
+
try {
|
|
131
|
+
cache = JSON.parse(await readFile(cacheFile, 'utf8'));
|
|
132
|
+
}
|
|
133
|
+
catch { /* empty */ }
|
|
134
|
+
const hash = (s) => createHash('sha256').update(s).digest('hex').slice(0, 24);
|
|
135
|
+
const missing = [...new Set(notes.filter((n) => !cache[hash(n.text)]).map((n) => n.text))];
|
|
136
|
+
if (missing.length > 0) {
|
|
137
|
+
const vecs = await embed(missing, embedding, fetchImpl);
|
|
138
|
+
if (!vecs)
|
|
139
|
+
return null; // embedding endpoint down -> pure lexical
|
|
140
|
+
missing.forEach((text, i) => { cache[hash(text)] = vecs[i]; });
|
|
141
|
+
try {
|
|
142
|
+
await mkdir(join(home, 'memory'), { recursive: true });
|
|
143
|
+
await writeFile(cacheFile, JSON.stringify(cache), 'utf8');
|
|
144
|
+
}
|
|
145
|
+
catch { /* best effort */ }
|
|
146
|
+
}
|
|
147
|
+
const queryVec = await embed([task], embedding, fetchImpl);
|
|
148
|
+
if (!queryVec)
|
|
149
|
+
return null;
|
|
150
|
+
const q = queryVec[0];
|
|
151
|
+
const blended = lexical.map((r) => {
|
|
152
|
+
const v = cache[hash(r.note.text)];
|
|
153
|
+
const cos = v ? cosine(v, q) : 0;
|
|
154
|
+
return { note: r.note, score: 0.5 * (r.score / maxLex) + 0.5 * Math.max(cos, 0) };
|
|
155
|
+
});
|
|
156
|
+
return blended.sort((a, b) => b.score - a.score);
|
|
51
157
|
}
|
|
52
158
|
/**
|
|
53
159
|
* Build the prompt block: top-k task-relevant notes plus the newest few
|
|
@@ -58,14 +164,20 @@ export async function retrieveMemory(home, task, opts = {}) {
|
|
|
58
164
|
const notes = await readNotes(home);
|
|
59
165
|
if (notes.length === 0)
|
|
60
166
|
return '';
|
|
61
|
-
|
|
167
|
+
let ranked;
|
|
168
|
+
if (opts.embedding) {
|
|
169
|
+
ranked = (await hybridRank(notes, task, opts.workspace, home, opts.embedding, opts.fetchImpl)) ?? scoreNotes(notes, task, opts.workspace);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
ranked = scoreNotes(notes, task, opts.workspace);
|
|
173
|
+
}
|
|
62
174
|
const picked = [];
|
|
63
175
|
const seen = new Set();
|
|
64
176
|
for (const { note } of ranked.slice(0, topK)) {
|
|
65
177
|
picked.push(note);
|
|
66
178
|
seen.add(note.text);
|
|
67
179
|
}
|
|
68
|
-
for (const note of notes.slice(-newest)) {
|
|
180
|
+
for (const note of (newest > 0 ? notes.slice(-newest) : [])) {
|
|
69
181
|
if (!seen.has(note.text))
|
|
70
182
|
picked.push(note);
|
|
71
183
|
seen.add(note.text);
|
|
@@ -90,9 +202,10 @@ export async function loadMemory(home) {
|
|
|
90
202
|
return '';
|
|
91
203
|
}
|
|
92
204
|
}
|
|
93
|
-
export async function appendMemory(home, note) {
|
|
205
|
+
export async function appendMemory(home, note, workspace) {
|
|
94
206
|
const dir = join(home, 'memory');
|
|
95
207
|
await mkdir(dir, { recursive: true });
|
|
96
208
|
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
|
|
97
|
-
|
|
209
|
+
const tag = workspace ? ` [ws:${workspace}]` : '';
|
|
210
|
+
await appendFile(join(dir, 'memory.md'), `\n- [${stamp}] ${note.replace(/\n+/g, ' ').trim()}${tag}\n`, 'utf8');
|
|
98
211
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hmharness/evolution",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "hmharness evolution subsystem: persistent memory, insight capture, skill library, and the bench that gives evolution its fitness signal. First-class kernel citizen, not a plugin.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"build": "tsc -p tsconfig.build.json"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@hmharness/kernel": "0.
|
|
18
|
+
"@hmharness/kernel": "0.4.0"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"dist"
|