discoclaw 0.5.1 → 0.5.2

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.
@@ -33,7 +33,10 @@ Bot: We've been working through your Express → Fastify migration.
33
33
 
34
34
  Structured store of user facts. Each item has a kind (fact, preference, project,
35
35
  constraint, person, tool, workflow), deduplication by content hash, and a 200-item
36
- cap per user. Injected into every prompt.
36
+ cap per user. Items track `hitCount` (incremented each time the item is selected
37
+ for prompt injection) and `lastHitAt` (timestamp of most recent selection).
38
+ Injected into every prompt using a blended score of recency and usage frequency
39
+ rather than raw `updatedAt` alone.
37
40
 
38
41
  **What the user sees:**
39
42
  - The bot knows your preferences, projects, and key facts across all conversations.
@@ -53,7 +56,7 @@ Bot: Given your preference for Rust in systems work, I'd lean that way —
53
56
 
54
57
  #### Consolidation
55
58
 
56
- When the active item count for a user crosses a threshold (`DISCOCLAW_DURABLE_CONSOLIDATION_THRESHOLD`, default `100`), consolidation can be triggered to prune and merge the list. A single `fast`-tier model call receives all active items and is asked to return a revised list — removing exact duplicates, merging near-duplicates, dropping clearly stale items, and preserving everything that is still plausibly useful. The model must not invent new facts or change the meaning of existing ones.
59
+ When the active item count for a user crosses a threshold (`DISCOCLAW_DURABLE_CONSOLIDATION_THRESHOLD`, default `100`), consolidation can be triggered to prune and merge the list. A single `fast`-tier model call receives all active items and is asked to return a revised list — removing exact duplicates, merging near-duplicates, dropping clearly stale items, and preserving everything that is still plausibly useful. The model must not invent new facts or change the meaning of existing ones. Items with low `hitCount` and stale `lastHitAt` are natural eviction candidates — the blended score surfaces which items the AI actually uses versus those that were added once and never referenced again.
57
60
 
58
61
  The revised list is applied atomically: items absent from the model's output are deprecated via `deprecateItems()`; new or rewritten items are written via `addItem()`. Items present verbatim in the output are left untouched (no unnecessary writes).
59
62
 
@@ -183,7 +186,7 @@ no separator). The three memory builders run in `Promise.all` so they add no lat
183
186
 
184
187
  | Layer | Default budget | Default state | How it stays within budget |
185
188
  |-------|---------------|---------------|---------------------------|
186
- | Durable memory | 2000 chars | on | Sorts active items by recency, adds one at a time, stops when next line would exceed budget. Older facts silently excluded. |
189
+ | Durable memory | 2000 chars | on | Ranks active items by blended score (recency + hit frequency), adds one at a time, stops when next line would exceed budget. Low-scoring items silently excluded. |
187
190
  | Rolling summary | 2000 chars | on | The `fast`-tier model is prompted with `"Keep the summary under {maxChars} characters"`. Replaces itself each update rather than growing. |
188
191
  | Message history | 3000 chars | on | Fetches up to 10 messages, walks backward from newest. Bot messages truncated to fit; user messages that don't fit cause a hard stop. |
189
192
  | Short-term memory | 1000 chars | **on** | Filters by max age (default 6h), sorts newest-first, accumulates lines until budget hit. |
@@ -202,7 +205,7 @@ might add ~500 chars total. Sections with no data produce zero overhead.
202
205
 
203
206
  ### Where the budgets are enforced
204
207
 
205
- - **Durable**: `selectItemsForInjection()` in `durable-memory.ts:152`
208
+ - **Durable**: `selectItemsForInjection()` in `durable-memory.ts:152` — scores items using `hitCount`, `lastHitAt`, and `updatedAt`; increments hit counters on selected items
206
209
  - **Short-term**: `selectEntriesForInjection()` in `shortterm-memory.ts:113`
207
210
  - **Summary**: `fast`-tier prompt constraint in `summarizer.ts:63`
208
211
  - **History**: `fetchMessageHistory()` in `message-history.ts:38`
@@ -1,4 +1,4 @@
1
- import { loadDurableMemory, saveDurableMemory, addItem, deprecateItems, selectItemsForInjection, formatDurableSection, } from './durable-memory.js';
1
+ import { loadDurableMemory, saveDurableMemory, addItem, deprecateItems, selectItemsForInjection, formatDurableSection, CURRENT_VERSION, } from './durable-memory.js';
2
2
  import { durableWriteQueue } from './durable-write-queue.js';
3
3
  const MEMORY_TYPE_MAP = {
4
4
  memoryRemember: true,
@@ -71,7 +71,7 @@ export async function executeMemoryAction(action, _ctx, memCtx) {
71
71
  // ---------------------------------------------------------------------------
72
72
  async function loadOrCreate(dir, userId) {
73
73
  const store = await loadDurableMemory(dir, userId);
74
- return store ?? { version: 1, updatedAt: 0, items: [] };
74
+ return store ?? { version: CURRENT_VERSION, updatedAt: 0, items: [] };
75
75
  }
76
76
  // ---------------------------------------------------------------------------
77
77
  // Prompt section
@@ -1,14 +1,19 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
- export const CURRENT_VERSION = 1;
4
+ export const CURRENT_VERSION = 2;
5
5
  export function emptyStore() {
6
6
  return { version: CURRENT_VERSION, updatedAt: Date.now(), items: [] };
7
7
  }
8
8
  function migrateStore(store) {
9
9
  // Migration blocks run first, then the unknown-version guard.
10
- // Future migrations follow the run-stats.ts pattern:
11
- // if (store.version === 1) { /* transform fields */; store.version = 2; }
10
+ if (store.version === 1) {
11
+ for (const item of store.items) {
12
+ item.hitCount = item.hitCount ?? 0;
13
+ item.lastHitAt = item.lastHitAt ?? 0;
14
+ }
15
+ store.version = 2;
16
+ }
12
17
  if (store.version !== CURRENT_VERSION) {
13
18
  // Unrecognized (future) version — caller will create a fresh store.
14
19
  return null;
@@ -56,6 +61,31 @@ export function deriveItemId(kind, text) {
56
61
  const normalized = kind + ':' + text.trim().toLowerCase().replace(/\s+/g, ' ');
57
62
  return 'durable-' + crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 8);
58
63
  }
64
+ function recencyWeight(timestamp, now) {
65
+ const daysSince = Math.max(0, now - timestamp) / (1000 * 60 * 60 * 24);
66
+ return 1 / (1 + daysSince / 30);
67
+ }
68
+ export function scoreItem(item, now) {
69
+ if (item.hitCount === 0)
70
+ return 0;
71
+ return item.hitCount * recencyWeight(item.lastHitAt, now);
72
+ }
73
+ export function blendedInjectionScore(item, now) {
74
+ const hitBoost = 0.5;
75
+ return recencyWeight(item.updatedAt, now) + hitBoost * item.hitCount * recencyWeight(item.lastHitAt, now);
76
+ }
77
+ export function recordHits(store, itemIds) {
78
+ const idSet = new Set(itemIds);
79
+ const now = Date.now();
80
+ for (const item of store.items) {
81
+ if (idSet.has(item.id)) {
82
+ item.hitCount += 1;
83
+ item.lastHitAt = now;
84
+ }
85
+ }
86
+ if (idSet.size > 0)
87
+ store.updatedAt = now;
88
+ }
59
89
  export function addItem(store, text, source, maxItems, kind = 'fact') {
60
90
  const now = Date.now();
61
91
  const id = deriveItemId(kind, text);
@@ -77,25 +107,27 @@ export function addItem(store, text, source, maxItems, kind = 'fact') {
77
107
  source,
78
108
  createdAt: now,
79
109
  updatedAt: now,
110
+ hitCount: 0,
111
+ lastHitAt: 0,
80
112
  };
81
113
  store.items.push(item);
82
114
  store.updatedAt = now;
83
115
  // Enforce maxItems cap.
84
116
  while (store.items.length > maxItems) {
85
- // Drop oldest deprecated first.
117
+ // Drop lowest-scored deprecated first.
86
118
  const deprecatedIdx = store.items
87
119
  .map((it, i) => ({ it, i }))
88
120
  .filter(({ it }) => it.status === 'deprecated')
89
- .sort((a, b) => a.it.updatedAt - b.it.updatedAt)[0];
121
+ .sort((a, b) => scoreItem(a.it, now) - scoreItem(b.it, now))[0];
90
122
  if (deprecatedIdx) {
91
123
  store.items.splice(deprecatedIdx.i, 1);
92
124
  }
93
125
  else {
94
- // Drop oldest active.
126
+ // Drop lowest-scored active.
95
127
  const activeIdx = store.items
96
128
  .map((it, i) => ({ it, i }))
97
129
  .filter(({ it }) => it.status === 'active')
98
- .sort((a, b) => a.it.updatedAt - b.it.updatedAt)[0];
130
+ .sort((a, b) => scoreItem(a.it, now) - scoreItem(b.it, now))[0];
99
131
  if (activeIdx) {
100
132
  store.items.splice(activeIdx.i, 1);
101
133
  }
@@ -126,9 +158,10 @@ export function deprecateItems(store, substring) {
126
158
  return { store, deprecatedCount };
127
159
  }
128
160
  export function selectItemsForInjection(store, maxChars) {
161
+ const now = Date.now();
129
162
  const active = store.items
130
163
  .filter((item) => item.status === 'active')
131
- .sort((a, b) => b.updatedAt - a.updatedAt);
164
+ .sort((a, b) => blendedInjectionScore(b, now) - blendedInjectionScore(a, now));
132
165
  const selected = [];
133
166
  let chars = 0;
134
167
  for (const item of active) {
@@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest';
2
2
  import fs from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { loadDurableMemory, saveDurableMemory, deriveItemId, addItem, deprecateItems, selectItemsForInjection, formatDurableSection, CURRENT_VERSION, } from './durable-memory.js';
5
+ import { loadDurableMemory, saveDurableMemory, deriveItemId, addItem, deprecateItems, selectItemsForInjection, formatDurableSection, scoreItem, blendedInjectionScore, recordHits, CURRENT_VERSION, } from './durable-memory.js';
6
6
  async function makeTmpDir() {
7
7
  return fs.mkdtemp(path.join(os.tmpdir(), 'durable-memory-test-'));
8
8
  }
9
9
  function emptyStore() {
10
- return { version: 1, updatedAt: 0, items: [] };
10
+ return { version: 2, updatedAt: 0, items: [] };
11
11
  }
12
12
  function makeItem(overrides = {}) {
13
13
  return {
@@ -19,6 +19,8 @@ function makeItem(overrides = {}) {
19
19
  source: { type: 'manual' },
20
20
  createdAt: 1000,
21
21
  updatedAt: 1000,
22
+ hitCount: 0,
23
+ lastHitAt: 0,
22
24
  ...overrides,
23
25
  };
24
26
  }
@@ -30,7 +32,7 @@ describe('loadDurableMemory', () => {
30
32
  });
31
33
  it('parses valid store', async () => {
32
34
  const dir = await makeTmpDir();
33
- const store = { version: 1, updatedAt: 1000, items: [] };
35
+ const store = { version: 2, updatedAt: 1000, items: [] };
34
36
  await fs.writeFile(path.join(dir, '12345.json'), JSON.stringify(store), 'utf8');
35
37
  const result = await loadDurableMemory(dir, '12345');
36
38
  expect(result).toEqual(store);
@@ -45,12 +47,31 @@ describe('loadDurableMemory', () => {
45
47
  const dir = await makeTmpDir();
46
48
  await expect(loadDurableMemory(dir, '../evil')).rejects.toThrow(/Invalid userId/);
47
49
  });
48
- it('returns store unchanged for version-1 store (migration no-op)', async () => {
50
+ it('migrates v1 store to v2 with hitCount and lastHitAt backfilled', async () => {
49
51
  const dir = await makeTmpDir();
50
- const store = { version: 1, updatedAt: 1000, items: [] };
51
- await fs.writeFile(path.join(dir, 'user1.json'), JSON.stringify(store), 'utf8');
52
+ const v1Store = {
53
+ version: 1,
54
+ updatedAt: 1000,
55
+ items: [
56
+ {
57
+ id: 'durable-abc12345',
58
+ kind: 'fact',
59
+ text: 'test item',
60
+ tags: [],
61
+ status: 'active',
62
+ source: { type: 'manual' },
63
+ createdAt: 500,
64
+ updatedAt: 1000,
65
+ },
66
+ ],
67
+ };
68
+ await fs.writeFile(path.join(dir, 'user1.json'), JSON.stringify(v1Store), 'utf8');
52
69
  const result = await loadDurableMemory(dir, 'user1');
53
- expect(result).toEqual(store);
70
+ expect(result).not.toBeNull();
71
+ expect(result.version).toBe(2);
72
+ expect(result.items).toHaveLength(1);
73
+ expect(result.items[0].hitCount).toBe(0);
74
+ expect(result.items[0].lastHitAt).toBe(0);
54
75
  });
55
76
  it('returns empty store for unsupported version', async () => {
56
77
  const dir = await makeTmpDir();
@@ -279,3 +300,81 @@ describe('formatDurableSection', () => {
279
300
  expect(result).toMatch(/src: manual, updated/);
280
301
  });
281
302
  });
303
+ describe('scoreItem', () => {
304
+ it('returns 0 when hitCount is 0', () => {
305
+ const item = makeItem({ hitCount: 0, lastHitAt: 1000 });
306
+ expect(scoreItem(item, Date.now())).toBe(0);
307
+ });
308
+ it('scores higher with recent lastHitAt than distant lastHitAt', () => {
309
+ const now = Date.now();
310
+ const recent = makeItem({ hitCount: 5, lastHitAt: now - 1000 });
311
+ const distant = makeItem({ hitCount: 5, lastHitAt: now - 90 * 24 * 60 * 60 * 1000 });
312
+ expect(scoreItem(recent, now)).toBeGreaterThan(scoreItem(distant, now));
313
+ });
314
+ it('scores higher with more hits', () => {
315
+ const now = Date.now();
316
+ const manyHits = makeItem({ hitCount: 10, lastHitAt: now - 1000 });
317
+ const fewHits = makeItem({ hitCount: 2, lastHitAt: now - 1000 });
318
+ expect(scoreItem(manyHits, now)).toBeGreaterThan(scoreItem(fewHits, now));
319
+ });
320
+ });
321
+ describe('blendedInjectionScore', () => {
322
+ it('recently-updated + frequently-hit item scores above old + never-hit item', () => {
323
+ const now = Date.now();
324
+ const hotItem = makeItem({
325
+ updatedAt: now - 1000,
326
+ hitCount: 10,
327
+ lastHitAt: now - 1000,
328
+ });
329
+ const coldItem = makeItem({
330
+ updatedAt: now - 60 * 24 * 60 * 60 * 1000,
331
+ hitCount: 0,
332
+ lastHitAt: 0,
333
+ });
334
+ expect(blendedInjectionScore(hotItem, now)).toBeGreaterThan(blendedInjectionScore(coldItem, now));
335
+ });
336
+ });
337
+ describe('addItem — eviction with hit tracking', () => {
338
+ it('evicts never-hit item before frequently-hit item of same age', () => {
339
+ const store = emptyStore();
340
+ const now = Date.now();
341
+ store.items.push(makeItem({ id: 'never-hit', status: 'active', text: 'never hit', updatedAt: now - 1000, hitCount: 0, lastHitAt: 0 }), makeItem({ id: 'freq-hit', status: 'active', text: 'frequently hit', updatedAt: now - 1000, hitCount: 10, lastHitAt: now - 500 }));
342
+ addItem(store, 'new item', { type: 'manual' }, 2);
343
+ expect(store.items).toHaveLength(2);
344
+ expect(store.items.find((it) => it.id === 'never-hit')).toBeUndefined();
345
+ expect(store.items.find((it) => it.id === 'freq-hit')).toBeDefined();
346
+ });
347
+ it('evicts deprecated never-hit item before active never-hit item', () => {
348
+ const store = emptyStore();
349
+ const now = Date.now();
350
+ store.items.push(makeItem({ id: 'dep-no-hit', status: 'deprecated', text: 'deprecated no hit', updatedAt: now - 1000, hitCount: 0, lastHitAt: 0 }), makeItem({ id: 'active-no-hit', status: 'active', text: 'active no hit', updatedAt: now - 1000, hitCount: 0, lastHitAt: 0 }));
351
+ addItem(store, 'new item', { type: 'manual' }, 2);
352
+ expect(store.items).toHaveLength(2);
353
+ expect(store.items.find((it) => it.id === 'dep-no-hit')).toBeUndefined();
354
+ expect(store.items.find((it) => it.id === 'active-no-hit')).toBeDefined();
355
+ });
356
+ });
357
+ describe('selectItemsForInjection — blended scoring', () => {
358
+ it('orders by blended score, not just updatedAt', () => {
359
+ const now = Date.now();
360
+ const store = emptyStore();
361
+ store.items.push(makeItem({ id: 'old-hot', text: 'old but popular', status: 'active', updatedAt: now - 30 * 24 * 60 * 60 * 1000, hitCount: 20, lastHitAt: now - 1000 }), makeItem({ id: 'new-cold', text: 'new but ignored', status: 'active', updatedAt: now - 1000, hitCount: 0, lastHitAt: 0 }));
362
+ const items = selectItemsForInjection(store, 10000);
363
+ expect(items).toHaveLength(2);
364
+ expect(items[0].id).toBe('old-hot');
365
+ expect(items[1].id).toBe('new-cold');
366
+ });
367
+ });
368
+ describe('recordHits', () => {
369
+ it('bumps hitCount and lastHitAt on matching IDs, leaves others untouched', () => {
370
+ const store = emptyStore();
371
+ store.items.push(makeItem({ id: 'hit-me', hitCount: 3, lastHitAt: 500 }), makeItem({ id: 'leave-me', hitCount: 1, lastHitAt: 400 }));
372
+ const before = store.items[1].hitCount;
373
+ const beforeLastHit = store.items[1].lastHitAt;
374
+ recordHits(store, ['hit-me']);
375
+ expect(store.items[0].hitCount).toBe(4);
376
+ expect(store.items[0].lastHitAt).toBeGreaterThan(500);
377
+ expect(store.items[1].hitCount).toBe(before);
378
+ expect(store.items[1].lastHitAt).toBe(beforeLastHit);
379
+ });
380
+ });
@@ -1,7 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { buildPromptPreamble as buildRootPolicyPreamble } from '../root-policy.js';
4
- import { formatDurableSection, loadDurableMemory, selectItemsForInjection } from './durable-memory.js';
4
+ import { formatDurableSection, loadDurableMemory, saveDurableMemory, selectItemsForInjection, recordHits } from './durable-memory.js';
5
+ import { durableWriteQueue } from './durable-write-queue.js';
5
6
  import { buildShortTermMemorySection } from './shortterm-memory.js';
6
7
  import { loadWorkspacePermissions, resolveTools } from '../workspace-permissions.js';
7
8
  import { isOnboardingComplete } from '../workspace-bootstrap.js';
@@ -121,6 +122,19 @@ export async function buildDurableMemorySection(opts) {
121
122
  const items = selectItemsForInjection(store, opts.durableInjectMaxChars);
122
123
  if (items.length === 0)
123
124
  return '';
125
+ // Record hits on injected items so frequently-used items accumulate
126
+ // a Hebbian signal for scoring and eviction. Fire-and-forget — the
127
+ // background write doesn't block prompt assembly.
128
+ const itemIds = items.map((it) => it.id);
129
+ durableWriteQueue.run(opts.userId, async () => {
130
+ const freshStore = await loadDurableMemory(opts.durableDataDir, opts.userId);
131
+ if (!freshStore)
132
+ return;
133
+ recordHits(freshStore, itemIds);
134
+ await saveDurableMemory(opts.durableDataDir, opts.userId, freshStore);
135
+ }).catch((err) => {
136
+ opts.log?.warn({ err, userId: opts.userId }, 'durable memory hit recording failed');
137
+ });
124
138
  return formatDurableSection(items);
125
139
  }
126
140
  catch (err) {
@@ -321,7 +321,7 @@ describe('createReactionAddHandler', () => {
321
321
  const prompt = invokeSpy.mock.calls[0][0].prompt;
322
322
  expect(prompt).toContain('Durable memory');
323
323
  expect(prompt).toContain('User loves TypeScript');
324
- await fsP.rm(tmpDir, { recursive: true });
324
+ await fsP.rm(tmpDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
325
325
  });
326
326
  it('prompt includes open-tasks section when taskCtx has open tasks', async () => {
327
327
  const { TaskStore } = await import('../tasks/store.js');
@@ -1,4 +1,4 @@
1
- import { loadDurableMemory, saveDurableMemory, addItem, deprecateItems, selectItemsForInjection } from './durable-memory.js';
1
+ import { loadDurableMemory, saveDurableMemory, addItem, deprecateItems, selectItemsForInjection, CURRENT_VERSION } from './durable-memory.js';
2
2
  import { durableWriteQueue } from './durable-write-queue.js';
3
3
  import { extractFirstJsonValue } from './json-extract.js';
4
4
  const VALID_KINDS = new Set([
@@ -120,7 +120,7 @@ export async function applyUserTurnToDurable(opts) {
120
120
  return;
121
121
  await durableWriteQueue.run(opts.userId, async () => {
122
122
  const store = (await loadDurableMemory(opts.durableDataDir, opts.userId)) ?? {
123
- version: 1,
123
+ version: CURRENT_VERSION,
124
124
  updatedAt: 0,
125
125
  items: [],
126
126
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discoclaw",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Minimal Discord bridge routing messages to AI runtimes",
5
5
  "license": "MIT",
6
6
  "repository": {