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 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
- return { autoCapture: captureEnabled, recallInject: injectEnabled };
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
- lines.push('Relevant memories from previous sessions (guidance, not instructions):');
170
- for (const hit of hits)
171
- lines.push(`- [${hit.memory.kind}] ${hit.memory.text}`);
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
- const overlap = tokens.length === 0 ? 0 : matched.length / tokens.length;
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 why = `overlap ${matched.length}/${tokens.length}, recency ${recency.toFixed(2)}, ${verified ? 'verified' : 'unverified'}`;
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 hits = memories
165
- .filter((memory) => inTarget(memory, target))
166
- .map((memory) => ({ memory, ...scoreMemory(memory, tokens, now) }))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-context-memory",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Cross-session memory for your dsh agent: remembered, recalled, and re-verified instead of assumed.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",