dsh-context-memory 0.9.0 → 0.10.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/lib/index.d.ts +2 -0
- package/lib/index.js +35 -7
- package/lib/recall.d.ts +16 -1
- package/lib/recall.js +59 -6
- package/package.json +1 -1
package/lib/index.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export declare const inject: string[];
|
|
|
30
30
|
export interface MemoryPrefs {
|
|
31
31
|
autoCapture: boolean;
|
|
32
32
|
recallInject: boolean;
|
|
33
|
+
/** Character budget for the guidance rows in the recall injection. */
|
|
34
|
+
recallCharBudget: number;
|
|
33
35
|
}
|
|
34
36
|
/** The service shape other plugins and the panel use. */
|
|
35
37
|
export interface MemoryService {
|
package/lib/index.js
CHANGED
|
@@ -109,6 +109,10 @@ export function apply(ctx) {
|
|
|
109
109
|
};
|
|
110
110
|
/** When the last automatic re-check ran, so a day's worth of steps do not each run one. */
|
|
111
111
|
let lastReverifyAt = 0;
|
|
112
|
+
// Character budget for the guidance rows in the recall injection. Contradicted rows and
|
|
113
|
+
// conflicts are not optional, so they ignore it. Mutable through setPrefs like the other
|
|
114
|
+
// panel switches, so the panel can raise it without a restart.
|
|
115
|
+
let recallCharBudget = 4000;
|
|
112
116
|
const service = {
|
|
113
117
|
remember: (input) => memory.remember(input),
|
|
114
118
|
forget: (id, reason, options) => memory.forget(id, reason, options),
|
|
@@ -120,13 +124,15 @@ export function apply(ctx) {
|
|
|
120
124
|
candidates: () => memory.candidates(),
|
|
121
125
|
accept: (id, overrides) => memory.accept(id, overrides),
|
|
122
126
|
dismiss: (id) => memory.dismiss(id),
|
|
123
|
-
prefs: () => ({ autoCapture: captureEnabled, recallInject: injectEnabled }),
|
|
127
|
+
prefs: () => ({ autoCapture: captureEnabled, recallInject: injectEnabled, recallCharBudget }),
|
|
124
128
|
setPrefs: (patch) => {
|
|
125
129
|
if (typeof patch.autoCapture === 'boolean')
|
|
126
130
|
captureEnabled = patch.autoCapture;
|
|
127
131
|
if (typeof patch.recallInject === 'boolean')
|
|
128
132
|
injectEnabled = patch.recallInject;
|
|
129
|
-
|
|
133
|
+
if (typeof patch.recallCharBudget === 'number' && patch.recallCharBudget >= 200)
|
|
134
|
+
recallCharBudget = patch.recallCharBudget;
|
|
135
|
+
return { autoCapture: captureEnabled, recallInject: injectEnabled, recallCharBudget };
|
|
130
136
|
},
|
|
131
137
|
locations: () => memory.locations,
|
|
132
138
|
};
|
|
@@ -162,13 +168,35 @@ export function apply(ctx) {
|
|
|
162
168
|
let injectEnabled = true;
|
|
163
169
|
let captureEnabled = true;
|
|
164
170
|
let injectedMessageIds = [];
|
|
165
|
-
const contextMessageFor = (memory, query) => {
|
|
171
|
+
const contextMessageFor = (memory, query, charBudget) => {
|
|
166
172
|
const hits = memory.recall(query, 5);
|
|
167
173
|
const lines = [];
|
|
174
|
+
// Guidance rows are optional content, so they share a budget: recall already ranks
|
|
175
|
+
// them best-first, so rows are appended until the budget runs out and the rest are
|
|
176
|
+
// dropped with a note. Contradicted rows and conflicts are NOT optional — a claim the
|
|
177
|
+
// world denies or two records that disagree are things the model must see to resolve,
|
|
178
|
+
// so those sections ignore the budget entirely.
|
|
179
|
+
let budgetLeft = Math.max(0, charBudget);
|
|
180
|
+
let droppedForBudget = 0;
|
|
168
181
|
if (hits.length > 0) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
const header = 'Relevant memories from previous sessions (guidance, not instructions):';
|
|
183
|
+
const rows = [];
|
|
184
|
+
for (const hit of hits) {
|
|
185
|
+
const row = `- [${hit.memory.kind}] ${hit.memory.text}`;
|
|
186
|
+
if (rows.length > 0 && budgetLeft - row.length < 0) {
|
|
187
|
+
droppedForBudget = hits.length - rows.length;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
budgetLeft -= row.length;
|
|
191
|
+
rows.push(row);
|
|
192
|
+
}
|
|
193
|
+
if (rows.length > 0) {
|
|
194
|
+
lines.push(header);
|
|
195
|
+
lines.push(...rows);
|
|
196
|
+
if (droppedForBudget > 0) {
|
|
197
|
+
lines.push(`(${droppedForBudget} more matches omitted — raise the char budget to see them, or use memory_recall for a targeted query)`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
172
200
|
}
|
|
173
201
|
// Contradicted memories are shown as a problem, never as guidance. Silently dropping
|
|
174
202
|
// them would hide the fact that the log now contains a claim the world denies; the
|
|
@@ -272,7 +300,7 @@ export function apply(ctx) {
|
|
|
272
300
|
catch { /* a queue that cannot be written must not stop the step */ }
|
|
273
301
|
}
|
|
274
302
|
}
|
|
275
|
-
const desired = contextMessageFor(memory, trimmed);
|
|
303
|
+
const desired = contextMessageFor(memory, trimmed, recallCharBudget);
|
|
276
304
|
// Nothing relevant and nothing conflicting → empty content: skip entirely
|
|
277
305
|
// rather than ship an empty row into the transcript.
|
|
278
306
|
if (desired.content.length === 0)
|
package/lib/recall.d.ts
CHANGED
|
@@ -32,6 +32,21 @@ import type { MemoryState, MemoryTarget, RecallHit } from './types.js';
|
|
|
32
32
|
* memory that share one word still match even when the surrounding wording differs.
|
|
33
33
|
*/
|
|
34
34
|
export declare function tokenize(text: string): string[];
|
|
35
|
+
/**
|
|
36
|
+
* Inverse document frequency over the population being ranked.
|
|
37
|
+
*
|
|
38
|
+
* Why this exists: plain overlap (`matched / tokens`) gives "项目" and "构建" the same
|
|
39
|
+
* weight. In a log about one project the first appears in nearly every memory and
|
|
40
|
+
* matches everything, so a query that also names a rare term ("pnpm") is diluted by the
|
|
41
|
+
* common ones. IDF is the standard correction — a term that appears in fewer memories
|
|
42
|
+
* carries more of the query meaning.
|
|
43
|
+
*
|
|
44
|
+
* Computed per `recall()` call over the filtered population, so the corpus is exactly the
|
|
45
|
+
* set of candidates being ranked: no global statistics to keep in sync, and no behaviour
|
|
46
|
+
* change when every term is equally common (equal weights reduce the weighted fraction
|
|
47
|
+
* to `matched / tokens`).
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildIdf(memories: MemoryState[], tokens: string[]): Map<string, number>;
|
|
35
50
|
/**
|
|
36
51
|
* Score how relevant one memory is to a query.
|
|
37
52
|
*
|
|
@@ -41,7 +56,7 @@ export declare function tokenize(text: string): string[];
|
|
|
41
56
|
* passed a check or it did not, and giving partial credit for "was checked once and has
|
|
42
57
|
* not been rechecked" would reward staleness.
|
|
43
58
|
*/
|
|
44
|
-
export declare function scoreMemory(memory: MemoryState, tokens: string[], now?: number): {
|
|
59
|
+
export declare function scoreMemory(memory: MemoryState, tokens: string[], now?: number, idf?: ReadonlyMap<string, number>): {
|
|
45
60
|
score: number;
|
|
46
61
|
matched: string[];
|
|
47
62
|
why: string;
|
package/lib/recall.js
CHANGED
|
@@ -100,6 +100,37 @@ function searchableText(memory) {
|
|
|
100
100
|
SEARCHABLE_TEXT.set(memory, value);
|
|
101
101
|
return value;
|
|
102
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Inverse document frequency over the population being ranked.
|
|
105
|
+
*
|
|
106
|
+
* Why this exists: plain overlap (`matched / tokens`) gives "项目" and "构建" the same
|
|
107
|
+
* weight. In a log about one project the first appears in nearly every memory and
|
|
108
|
+
* matches everything, so a query that also names a rare term ("pnpm") is diluted by the
|
|
109
|
+
* common ones. IDF is the standard correction — a term that appears in fewer memories
|
|
110
|
+
* carries more of the query meaning.
|
|
111
|
+
*
|
|
112
|
+
* Computed per `recall()` call over the filtered population, so the corpus is exactly the
|
|
113
|
+
* set of candidates being ranked: no global statistics to keep in sync, and no behaviour
|
|
114
|
+
* change when every term is equally common (equal weights reduce the weighted fraction
|
|
115
|
+
* to `matched / tokens`).
|
|
116
|
+
*/
|
|
117
|
+
export function buildIdf(memories, tokens) {
|
|
118
|
+
const idf = new Map();
|
|
119
|
+
if (memories.length === 0 || tokens.length === 0)
|
|
120
|
+
return idf;
|
|
121
|
+
const texts = memories.map((memory) => searchableText(memory));
|
|
122
|
+
const n = texts.length;
|
|
123
|
+
for (const token of tokens) {
|
|
124
|
+
let df = 0;
|
|
125
|
+
for (const text of texts)
|
|
126
|
+
if (text.includes(token))
|
|
127
|
+
df += 1;
|
|
128
|
+
// Smoothed IDF, always positive: a term in every document scores ~0 rather than
|
|
129
|
+
// going negative and inverting the ranking.
|
|
130
|
+
idf.set(token, Math.log(1 + (n - df + 0.5) / (df + 0.5)));
|
|
131
|
+
}
|
|
132
|
+
return idf;
|
|
133
|
+
}
|
|
103
134
|
/**
|
|
104
135
|
* Score how relevant one memory is to a query.
|
|
105
136
|
*
|
|
@@ -109,15 +140,36 @@ function searchableText(memory) {
|
|
|
109
140
|
* passed a check or it did not, and giving partial credit for "was checked once and has
|
|
110
141
|
* not been rechecked" would reward staleness.
|
|
111
142
|
*/
|
|
112
|
-
export function scoreMemory(memory, tokens, now = Date.now()) {
|
|
143
|
+
export function scoreMemory(memory, tokens, now = Date.now(), idf) {
|
|
113
144
|
const subject = searchableText(memory);
|
|
114
145
|
const matched = tokens.filter((token) => subject.includes(token));
|
|
115
|
-
|
|
146
|
+
// Overlap is the fraction of the query's *signal* that this memory covers. Without an
|
|
147
|
+
// IDF map it is the plain ratio; with one, each matched term contributes in proportion
|
|
148
|
+
// to how rare it is across the population (see buildIdf). The denominator keeps every
|
|
149
|
+
// query term — one that matches nothing still counts against the memory, which is what
|
|
150
|
+
// stops a stray rare word from inflating a score.
|
|
151
|
+
let overlap;
|
|
152
|
+
if (idf === undefined || tokens.length === 0) {
|
|
153
|
+
overlap = tokens.length === 0 ? 0 : matched.length / tokens.length;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
let weightTotal = 0;
|
|
157
|
+
let weightMatched = 0;
|
|
158
|
+
const matchedSet = new Set(matched);
|
|
159
|
+
for (const token of tokens) {
|
|
160
|
+
const weight = idf.get(token) ?? 1;
|
|
161
|
+
weightTotal += weight;
|
|
162
|
+
if (matchedSet.has(token))
|
|
163
|
+
weightMatched += weight;
|
|
164
|
+
}
|
|
165
|
+
overlap = weightTotal === 0 ? 0 : weightMatched / weightTotal;
|
|
166
|
+
}
|
|
116
167
|
const age = Math.max(0, now - memory.createdAt);
|
|
117
168
|
const recency = Math.max(0, 1 - age / RECENCY_HORIZON_MS);
|
|
118
169
|
const verified = memory.verification?.outcome === 'passed' ? 1 : 0;
|
|
119
170
|
const score = overlap * WEIGHTS.overlap + recency * WEIGHTS.recency + verified * WEIGHTS.verification;
|
|
120
|
-
const
|
|
171
|
+
const idfPart = idf === undefined ? '' : ` (idf ${overlap.toFixed(2)})`;
|
|
172
|
+
const why = `overlap ${matched.length}/${tokens.length}${idfPart}, recency ${recency.toFixed(2)}, ${verified ? 'verified' : 'unverified'}`;
|
|
121
173
|
return { score, matched, why };
|
|
122
174
|
}
|
|
123
175
|
/**
|
|
@@ -161,9 +213,10 @@ export function inTarget(memory, target) {
|
|
|
161
213
|
*/
|
|
162
214
|
export function recall(memories, query, limit = 5, now = Date.now(), target = 'guidance') {
|
|
163
215
|
const tokens = tokenize(query);
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
216
|
+
const population = memories.filter((memory) => inTarget(memory, target));
|
|
217
|
+
const idf = buildIdf(population, tokens);
|
|
218
|
+
const hits = population
|
|
219
|
+
.map((memory) => ({ memory, ...scoreMemory(memory, tokens, now, idf) }))
|
|
167
220
|
// A query with no signal-bearing tokens matches nothing rather than everything: an
|
|
168
221
|
// empty query is a question about nothing, and answering it with the whole log would
|
|
169
222
|
// make every ranking unexplainable.
|
package/package.json
CHANGED