@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 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
- export declare function scoreNotes(notes: MemoryNote[], task: string): Array<{
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: notes are
6
- * scored against the current task (ASCII words + CJK bigrams, no deps) and
7
- * only the top matches plus the newest few enter the system prompt.
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
- return notes
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
- // tiny recency bias so equal-relevance ties favor recent notes
48
- return { note, score: overlap + idx / Math.max(notes.length, 1) * 0.01 };
49
- })
50
- .sort((a, b) => b.score - a.score);
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
- const ranked = scoreNotes(notes, task);
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
- await appendFile(join(dir, 'memory.md'), `\n- [${stamp}] ${note.replace(/\n+/g, ' ').trim()}\n`, 'utf8');
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.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.3.0"
18
+ "@hmharness/kernel": "0.4.0"
19
19
  },
20
20
  "files": [
21
21
  "dist"