@openchambery/web 1.19.15 → 1.19.16

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.
Files changed (47) hide show
  1. package/dist/assets/{AssistantView-xdL1EvpR.js → AssistantView-B79FR_5m.js} +1 -1
  2. package/dist/assets/{DiagramView-DPgkYU-_.js → DiagramView-Bg5pX5aJ.js} +1 -1
  3. package/dist/assets/{MarkdownRenderer-CP7UVZdr.js → MarkdownRenderer-BrOPSCOy.js} +2 -2
  4. package/dist/assets/{MarkdownRendererImpl-BB1Q1RWk.js → MarkdownRendererImpl-BWTMbqp6.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-BjQrUIcq.js → MarkstreamRendererImpl-Bwxkdtts.js} +1 -1
  6. package/dist/assets/{MobileDetailNavigation-Cyzs6cuR.js → MobileDetailNavigation-37jfPItf.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-A-Z6jMUn.js → MobileShareBridge-D2RJnSvI.js} +1 -1
  8. package/dist/assets/{MobileSurface-DjDa20gx.js → MobileSurface-B_jx2mRT.js} +1 -1
  9. package/dist/assets/{MultiRunWindow-Cg5x-kJV.js → MultiRunWindow-CUYEega4.js} +1 -1
  10. package/dist/assets/{SettingsView-CYrTHDdp.js → SettingsView-B5BWCL5K.js} +1 -1
  11. package/dist/assets/{SettingsWindow-CF-0Zgxr.js → SettingsWindow--F_wFMX-.js} +1 -1
  12. package/dist/assets/{TerminalView-CShou18D.js → TerminalView-BqnCNcW3.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-BvmxlNOa.js → ToolOutputDialog-Cxpellmm.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-iqSTUpPj.js → appThemeRegistry-DaoSMJG1.js} +1 -1
  15. package/dist/assets/{fileMentionTouchSelection-CddGH2aH.js → fileMentionTouchSelection-COw1MDRz.js} +1 -1
  16. package/dist/assets/{gitApi-BwuaHHRQ.js → gitApi-uzDmhDPP.js} +1 -1
  17. package/dist/assets/{insertionBoundaries-og6NBgdn.js → insertionBoundaries-BYOyFrjB.js} +1 -1
  18. package/dist/assets/{main-VD-QBM47.js → main-Br8P10pP.js} +2 -2
  19. package/dist/assets/{main-31g3JiQx.js → main-DaaedB65.js} +4 -4
  20. package/dist/assets/{miniChat-DwCMTamY.js → miniChat-aQQ4d-bD.js} +2 -2
  21. package/dist/assets/{mobile-C7q1yowe.js → mobile-XeTOB53s.js} +2 -2
  22. package/dist/assets/{multirun-DcN5T0lN.js → multirun-BKVlVu2V.js} +1 -1
  23. package/dist/assets/{projectMeta-moNJYCK4.js → projectMeta-DX3l1Xzy.js} +1 -1
  24. package/dist/assets/{renderElectronMiniChatApp-DVkgKA6A.js → renderElectronMiniChatApp-CmYP_apQ.js} +2 -2
  25. package/dist/assets/{renderMobileApp-BgEXZhCO.js → renderMobileApp-ATUEcuRh.js} +4 -4
  26. package/dist/assets/{runtimeConfig-B49cxtgI.js → runtimeConfig-Cug6bqDv.js} +1 -1
  27. package/dist/assets/{runtimeEndpointReset-9K_Dj1qR.js → runtimeEndpointReset-CRc6se81.js} +1 -1
  28. package/dist/assets/{sessionLookup-m3h8c4xR.js → sessionLookup-D3ISlV8R.js} +1 -1
  29. package/dist/assets/{useAppFontEffects-DRFi4TCE.js → useAppFontEffects-Cn7pYCz1.js} +5 -5
  30. package/dist/assets/{useDesktopWindowControlsLayout-BdNR3zMN.js → useDesktopWindowControlsLayout-DXAFSQuV.js} +1 -1
  31. package/dist/assets/{useEffectiveDirectory-COoPja_2.js → useEffectiveDirectory-DT_EmmM0.js} +1 -1
  32. package/dist/assets/{useMobileNavigationStore-Cn0-ozUB.js → useMobileNavigationStore-gXfZxA1A.js} +1 -1
  33. package/dist/assets/{useSessionAutoCleanup-XwgyzsEd.js → useSessionAutoCleanup-CsVhR6FK.js} +1 -1
  34. package/dist/assets/{useWorkerHighlightedLines-GgcqEoKY.js → useWorkerHighlightedLines-C1rC3WQe.js} +1 -1
  35. package/dist/index.html +2 -2
  36. package/dist/mini-chat.html +2 -2
  37. package/dist/mobile.html +2 -2
  38. package/package.json +1 -1
  39. package/server/lib/assistants/DOCUMENTATION.md +11 -1
  40. package/server/lib/assistants/contact-store.js +18 -4
  41. package/server/lib/assistants/contact-tools.js +44 -0
  42. package/server/lib/assistants/contact-tools.test.js +2 -0
  43. package/server/lib/assistants/harness.js +2 -2
  44. package/server/lib/assistants/memory.http.test.js +204 -0
  45. package/server/lib/assistants/memory.js +124 -0
  46. package/server/lib/assistants/memory.test.js +159 -0
  47. package/server/lib/assistants/service.js +9 -0
@@ -0,0 +1,124 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { getContactAssistantContextBoundary, getContactContextBoundary, getContactGeneration } from './contact-store.js';
3
+
4
+ const SCAN_LIMIT = 200;
5
+ const fail = (code) => { throw Object.assign(new Error(code), { code }); };
6
+ const paramsObject = (value, keys) => {
7
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some((key) => !keys.includes(key))) fail('validation_error');
8
+ };
9
+ const integer = (value, fallback, min, max) => {
10
+ if (value === undefined) return fallback;
11
+ if (!Number.isSafeInteger(value) || value < min || value > max) fail('validation_error');
12
+ return value;
13
+ };
14
+ const time = (value) => {
15
+ if (value === undefined) return null;
16
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T.+(?:Z|[+-]\d{2}:\d{2})$/u.test(value)) fail('validation_error');
17
+ const result = Date.parse(value);
18
+ if (!Number.isFinite(result)) fail('validation_error');
19
+ return result;
20
+ };
21
+
22
+ // Project only authored text, ordered exactly as the contact transcript.
23
+ // Attachment bodies, URLs, peer DMs, diagnostics and tool payloads never leave this reader.
24
+ const textRows = (count) => `
25
+ WITH text_parts AS (
26
+ SELECT message_id, json_extract(part_json, '$.text') AS text
27
+ FROM assistant_contact_part
28
+ WHERE message_id IN (${Array(count).fill('?').join(',')})
29
+ AND json_extract(part_json, '$.type') = 'text'
30
+ AND substr(json_extract(part_json, '$.text'), 1, 10) <> 'oc.settle.'
31
+ ORDER BY message_id, ordinal, part_id
32
+ ), texts AS (
33
+ SELECT message_id, group_concat(text, '') AS text FROM text_parts GROUP BY message_id
34
+ )`;
35
+
36
+ /** One contact turn's read-only view, fenced again against live memory state on every call. */
37
+ export function createContactMemoryReader(db, assistantID, { beforeOrdinal = null, assertAvailable = () => {} } = {}) {
38
+ const boundary = getContactContextBoundary(db, assistantID);
39
+ const assistantBoundary = getContactAssistantContextBoundary(db, assistantID);
40
+ const generation = getContactGeneration(db, assistantID);
41
+ const ceiling = Number(db.prepare('SELECT COALESCE(MAX(ordinal), 0) AS value FROM assistant_contact_message WHERE assistant_id=?').get(assistantID).value);
42
+ const eligible = (row) => row.role !== 'peer' && ['user', 'assistant'].includes(row.role)
43
+ && row.status === 'complete' && !row.from_assistant_id
44
+ && (row.role !== 'assistant' || row.ordinal > assistantBoundary)
45
+ && (row.role !== 'user' || beforeOrdinal === null || row.ordinal < beforeOrdinal);
46
+ const check = () => {
47
+ assertAvailable();
48
+ if (getContactGeneration(db, assistantID) !== generation || getContactContextBoundary(db, assistantID) !== boundary
49
+ || getContactAssistantContextBoundary(db, assistantID) !== assistantBoundary) fail('memory_scope_changed');
50
+ };
51
+ return {
52
+ search(params = {}) {
53
+ check();
54
+ paramsObject(params, ['query', 'from', 'to', 'limit', 'cursor']);
55
+ const query = params.query === undefined ? '' : params.query;
56
+ if (typeof query !== 'string' || query.length > 256) fail('validation_error');
57
+ const from = time(params.from);
58
+ const to = time(params.to);
59
+ if (from !== null && to !== null && from > to) fail('validation_error');
60
+ const limit = integer(params.limit, 10, 1, 20);
61
+ const scope = createHash('sha256').update(JSON.stringify([assistantID, boundary, assistantBoundary, generation, ceiling, beforeOrdinal, query, from, to])).digest('hex');
62
+ let cursor = null;
63
+ if (params.cursor !== undefined) {
64
+ if (typeof params.cursor !== 'string' || params.cursor.length > 2048 || !/^[A-Za-z0-9_-]+$/u.test(params.cursor)) fail('invalid_memory_cursor');
65
+ try { cursor = JSON.parse(Buffer.from(params.cursor, 'base64url').toString('utf8')); } catch { fail('invalid_memory_cursor'); }
66
+ if (cursor?.v !== 1 || cursor.scope !== scope || !Number.isSafeInteger(cursor.ordinal)
67
+ || cursor.ordinal <= boundary || cursor.ordinal > ceiling || typeof cursor.messageID !== 'string'
68
+ || !cursor.messageID || cursor.messageID.length > 512) fail('invalid_memory_cursor');
69
+ }
70
+ const rows = db.prepare(`SELECT message_id, role, created_at, ordinal, status, from_assistant_id
71
+ FROM assistant_contact_message WHERE assistant_id=? AND ordinal>? AND ordinal<=?
72
+ ${cursor ? 'AND (ordinal, message_id) < (?, ?)' : ''}
73
+ ORDER BY ordinal DESC, message_id DESC LIMIT ?`).all(
74
+ assistantID, boundary, ceiling, ...(cursor ? [cursor.ordinal, cursor.messageID] : []), SCAN_LIMIT + 1,
75
+ );
76
+ const candidates = rows.slice(0, SCAN_LIMIT);
77
+ const ids = candidates.filter((row) => eligible(row)
78
+ && (from === null || row.created_at >= from) && (to === null || row.created_at <= to)).map((row) => row.message_id);
79
+ const snippets = ids.length === 0 ? [] : db.prepare(`${textRows(ids.length)}
80
+ SELECT message_id, substr(text, max(instr(lower(text), lower(?)) - 80, 1), 400) AS snippet,
81
+ max(instr(lower(text), lower(?)) - 81, 0) AS snippet_offset, length(text) AS total_chars
82
+ FROM texts WHERE length(trim(text)) > 0 AND (? = '' OR instr(lower(text), lower(?)) > 0)
83
+ `).all(...ids, query, query, query, query);
84
+ const byID = new Map(snippets.map((row) => [row.message_id, row]));
85
+ const matches = [];
86
+ let scanned = 0;
87
+ for (const row of candidates) {
88
+ scanned += 1;
89
+ const snippet = byID.get(row.message_id);
90
+ if (snippet) matches.push({
91
+ messageID: row.message_id, role: row.role, createdAt: row.created_at,
92
+ snippet: snippet.snippet, offset: snippet.snippet_offset, totalChars: snippet.total_chars,
93
+ });
94
+ if (matches.length === limit) break;
95
+ }
96
+ const last = candidates[scanned - 1];
97
+ const hasMore = scanned < rows.length;
98
+ const nextCursor = hasMore && last ? Buffer.from(JSON.stringify({
99
+ v: 1, scope, ordinal: last.ordinal, messageID: last.message_id,
100
+ })).toString('base64url') : null;
101
+ return { matches, nextCursor, complete: nextCursor === null, scanned };
102
+ },
103
+ read(params = {}) {
104
+ check();
105
+ paramsObject(params, ['messageID', 'offset', 'maxChars']);
106
+ if (typeof params.messageID !== 'string' || !params.messageID || params.messageID.length > 512) fail('validation_error');
107
+ const offset = integer(params.offset, 0, 0, Number.MAX_SAFE_INTEGER);
108
+ const maxChars = integer(params.maxChars, 4000, 1, 8000);
109
+ const row = db.prepare(`SELECT message_id, role, created_at, ordinal, status, from_assistant_id
110
+ FROM assistant_contact_message WHERE assistant_id=? AND message_id=? AND ordinal>? AND ordinal<=?`).get(
111
+ assistantID, params.messageID, boundary, ceiling,
112
+ );
113
+ if (!row || !eligible(row)) fail('memory_not_found');
114
+ const content = db.prepare(`${textRows(1)} SELECT substr(text, ?, ?) AS text, length(text) AS total_chars FROM texts`).get(row.message_id, offset + 1, maxChars);
115
+ if (!content || !content.total_chars) fail('memory_not_found');
116
+ if (offset > content.total_chars) fail('validation_error');
117
+ const nextOffset = offset + maxChars < content.total_chars ? offset + maxChars : null;
118
+ return {
119
+ messageID: row.message_id, role: row.role, createdAt: row.created_at,
120
+ text: content.text, offset, totalChars: content.total_chars, nextOffset, complete: nextOffset === null,
121
+ };
122
+ },
123
+ };
124
+ }
@@ -0,0 +1,159 @@
1
+ import { createRequire } from 'node:module';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { createContactMemoryReader } from './memory.js';
4
+ import { bumpContactGeneration, clearContactMemory, contactHistoryForLlm, deleteContactMessages, ensureContactSchema, insertContactMessage } from './contact-store.js';
5
+
6
+ const Database = createRequire(import.meta.url)('better-sqlite3');
7
+ const databases = [];
8
+ afterEach(() => { while (databases.length) databases.pop().close(); });
9
+ const fixture = () => {
10
+ const db = new Database(':memory:');
11
+ databases.push(db);
12
+ ensureContactSchema(db);
13
+ let ordinal = 0;
14
+ const insert = (messageID, text, { assistantID = 'a', role = 'user', turnID = messageID, ...extra } = {}) => {
15
+ insertContactMessage(db, {
16
+ messageID, assistantID, role, turnID, ordinal: ++ordinal, createdAt: 1_700_000_000_000 + ordinal,
17
+ parts: [{ type: 'text', text }], ...extra,
18
+ });
19
+ return ordinal;
20
+ };
21
+ return { db, insert };
22
+ };
23
+
24
+ describe('contact memory boundaries', () => {
25
+ it('forgets a preceding turn late reply while preserving queued users after clear-memory admission', () => {
26
+ const { db, insert } = fixture();
27
+ insert('prior', 'Old decision');
28
+ const clearing = insert('clearing', 'Clear memory');
29
+ insert('queued', 'New topic');
30
+ insert('late-prior', 'Old decision details', { role: 'assistant', turnID: 'prior' });
31
+ clearContactMemory(db, 'a', { upToOrdinal: clearing });
32
+ insert('reset-confirm', 'Ready for a new topic', { role: 'assistant', turnID: 'clearing' });
33
+ const reader = createContactMemoryReader(db, 'a');
34
+ expect(reader.search().matches.map((m) => m.messageID)).toEqual(['reset-confirm', 'queued']);
35
+ expect(() => reader.read({ messageID: 'late-prior' })).toThrow('memory_not_found');
36
+ expect(contactHistoryForLlm(db, 'a').map((m) => m.content)).toEqual(['New topic', 'Ready for a new topic']);
37
+ });
38
+
39
+ it('scopes exact reads, search and cursors to one assistant and rejects target overrides', () => {
40
+ const { db, insert } = fixture();
41
+ insert('a1', 'needle own'); insert('a2', 'needle own later');
42
+ insert('b1', 'needle other', { assistantID: 'b' });
43
+ const a = createContactMemoryReader(db, 'a');
44
+ const b = createContactMemoryReader(db, 'b');
45
+ expect(a.search({ query: 'needle' }).matches.map((m) => m.messageID)).toEqual(['a2', 'a1']);
46
+ expect(() => a.read({ messageID: 'b1' })).toThrow('memory_not_found');
47
+ expect(() => a.search({ assistantID: 'b' })).toThrow('validation_error');
48
+ expect(() => b.search({ query: 'needle', cursor: a.search({ query: 'needle', limit: 1 }).nextCursor })).toThrow('invalid_memory_cursor');
49
+ });
50
+
51
+ it('bounds scans and exposes empty partial pages until an older match is reached', () => {
52
+ const { db, insert } = fixture();
53
+ insert('old', 'rare decision');
54
+ for (let i = 0; i < 410; i++) insert(`filler-${i}`, 'ordinary');
55
+ const reader = createContactMemoryReader(db, 'a');
56
+ let page = reader.search({ query: 'rare' });
57
+ expect(page).toMatchObject({ matches: [], complete: false, scanned: 200 });
58
+ page = reader.search({ query: 'rare', cursor: page.nextCursor });
59
+ expect(page).toMatchObject({ matches: [], complete: false, scanned: 200 });
60
+ page = reader.search({ query: 'rare', cursor: page.nextCursor });
61
+ expect(page.matches.map((m) => m.messageID)).toEqual(['old']);
62
+ expect(page.complete).toBe(true);
63
+ expect(page.scanned).toBe(11);
64
+ });
65
+
66
+ it('paginates matching rows without gaps and returns literal, dated snippets', () => {
67
+ const { db, insert } = fixture();
68
+ for (let i = 0; i < 7; i++) insert(`match-${i}`, `100%_literal ${i}`);
69
+ const reader = createContactMemoryReader(db, 'a');
70
+ const ids = [];
71
+ let cursor;
72
+ do {
73
+ const page = reader.search({ query: '%_', limit: 2, ...(cursor ? { cursor } : {}) });
74
+ ids.push(...page.matches.map((m) => m.messageID));
75
+ cursor = page.nextCursor;
76
+ } while (cursor);
77
+ expect(ids).toEqual(['match-6', 'match-5', 'match-4', 'match-3', 'match-2', 'match-1', 'match-0']);
78
+ const page = reader.search({ from: new Date(1_700_000_000_003).toISOString(), to: new Date(1_700_000_000_004).toISOString() });
79
+ expect(page.matches.map((m) => m.messageID)).toEqual(['match-3', 'match-2']);
80
+ });
81
+
82
+ it('reads long Unicode messages by character offsets, including a match beyond the first chunk', () => {
83
+ const { db, insert } = fixture();
84
+ const text = '你好🙂'.repeat(2000) + 'rare ending';
85
+ insert('long', text);
86
+ const reader = createContactMemoryReader(db, 'a');
87
+ const found = reader.search({ query: 'rare' }).matches[0];
88
+ expect(found.snippet).toContain('rare ending');
89
+ expect(found.offset).toBeGreaterThan(4000);
90
+ let offset = 0;
91
+ let recovered = '';
92
+ do {
93
+ const page = reader.read({ messageID: 'long', offset, maxChars: 997 });
94
+ recovered += page.text;
95
+ offset = page.nextOffset;
96
+ } while (offset !== null);
97
+ expect(recovered).toBe(text);
98
+ expect(() => reader.read({ messageID: 'long', offset: 99999 })).toThrow('validation_error');
99
+ });
100
+
101
+ it('invalidates captured readers and cursors on clear and wipe, retaining visible history only for clear', () => {
102
+ const { db, insert } = fixture();
103
+ insert('old-1', 'old'); insert('old-2', 'old');
104
+ const reader = createContactMemoryReader(db, 'a');
105
+ const cursor = reader.search({ limit: 1 }).nextCursor;
106
+ clearContactMemory(db, 'a');
107
+ expect(() => reader.search({ cursor })).toThrow('memory_scope_changed');
108
+ expect(() => reader.read({ messageID: 'old-1' })).toThrow('memory_scope_changed');
109
+ const fresh = createContactMemoryReader(db, 'a');
110
+ expect(fresh.search().matches).toEqual([]);
111
+ expect(() => fresh.read({ messageID: 'old-1' })).toThrow('memory_not_found');
112
+ expect(() => fresh.search({ cursor })).toThrow('invalid_memory_cursor');
113
+ deleteContactMessages(db, 'a'); bumpContactGeneration(db, 'a');
114
+ expect(() => fresh.search()).toThrow('memory_scope_changed');
115
+ expect(createContactMemoryReader(db, 'a').search().matches).toEqual([]);
116
+ });
117
+
118
+ it('excludes peers, errors, internal markers, attachment bodies and later queued admissions', () => {
119
+ const { db, insert } = fixture();
120
+ insert('visible', 'visible');
121
+ const current = insert('current', 'current question');
122
+ insert('queued', 'queued secret');
123
+ insert('error', 'error secret', { role: 'assistant', status: 'error' });
124
+ insert('peer', 'peer secret', { role: 'peer', fromAssistantID: 'b' });
125
+ insert('internal', 'oc.settle.complete', { role: 'assistant' });
126
+ insert('file', '', { parts: [{ type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,c2VjcmV0', filename: 'private.txt' }] });
127
+ const reader = createContactMemoryReader(db, 'a', { beforeOrdinal: current });
128
+ insert('future', 'future secret');
129
+ expect(reader.search().matches.map((m) => m.messageID)).toEqual(['visible']);
130
+ for (const id of ['current', 'queued', 'error', 'peer', 'internal', 'file', 'future']) expect(() => reader.read({ messageID: id })).toThrow('memory_not_found');
131
+ });
132
+
133
+ it('rejects malformed arguments and changed-filter cursors while database failures remain errors', () => {
134
+ const { db, insert } = fixture();
135
+ insert('one', 'needle'); insert('two', 'needle');
136
+ const reader = createContactMemoryReader(db, 'a');
137
+ for (const params of [{ limit: 21 }, { query: 1 }, { from: 'yesterday' }, { cursor: '{}' }, { cursor: 'bnVsbA' }]) expect(() => reader.search(params)).toThrow();
138
+ const cursor = reader.search({ query: 'needle', limit: 1 }).nextCursor;
139
+ expect(() => reader.search({ query: 'different', cursor })).toThrow('invalid_memory_cursor');
140
+ db.exec('DROP TABLE assistant_contact_part');
141
+ expect(() => reader.search()).toThrow();
142
+ expect(() => reader.read({ messageID: 'one' })).toThrow();
143
+ });
144
+
145
+ it('migrates existing boundaries and rolls both watermarks back on failed transactions', () => {
146
+ const { db, insert } = fixture();
147
+ insert('old', 'old text');
148
+ db.exec('ALTER TABLE assistant_contact_context_boundary DROP COLUMN assistant_after_ordinal');
149
+ db.prepare('INSERT INTO assistant_contact_context_boundary VALUES (?,?,?)').run('a', 1, 1);
150
+ ensureContactSchema(db);
151
+ insert('new', 'new text');
152
+ const reader = createContactMemoryReader(db, 'a');
153
+ expect(reader.search().matches.map((m) => m.messageID)).toEqual(['new']);
154
+ db.exec('BEGIN');
155
+ clearContactMemory(db, 'a');
156
+ db.exec('ROLLBACK');
157
+ expect(reader.search().matches.map((m) => m.messageID)).toEqual(['new']);
158
+ });
159
+ });
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { createOpencodeClient } from '@opencode-ai/sdk/v2';
6
6
  import { validAssistantDeliveryParts } from '../assistant-delivery-parts.js';
7
7
  import { reduceBackfillState } from './history-state.js';
8
+ import { createContactMemoryReader } from './memory.js';
8
9
  import { getWorktrees as defaultListWorktrees } from '../git/service.js';
9
10
  import { contactCardIdentity, parseContactCard, parseContactPart } from './cards.js';
10
11
  import {
@@ -2240,7 +2241,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2240
2241
  history,
2241
2242
  { budgetBytes: CONTACT_ATTACHMENT_TURN_BUDGET_BYTES },
2242
2243
  );
2244
+ const memory = createContactMemoryReader(db, assistantID, { assertAvailable: () => editable(assistantID) });
2243
2245
  const tools = createContactTools({
2246
+ searchMemory: memory.search,
2247
+ readMemory: memory.read,
2244
2248
  assignWork: (params) => assignWork(live, params),
2245
2249
  watchSession: (params) => watchSessionWork(live, params),
2246
2250
  stopSession: (params) => stopSessionWork(live, params),
@@ -2570,7 +2574,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2570
2574
  (Array.isArray(executionParts) ? executionParts : []).filter((part) => part?.type === 'file'),
2571
2575
  );
2572
2576
  const turnAttachmentScope = attachmentScopeKey(turnFileParts);
2577
+ const memory = createContactMemoryReader(db, row.assistant_id, {
2578
+ beforeOrdinal, assertAvailable: () => editable(row.assistant_id),
2579
+ });
2573
2580
  const tools = createContactTools({
2581
+ searchMemory: memory.search,
2582
+ readMemory: memory.read,
2574
2583
  assignWork: (params) => assignWork(row, params),
2575
2584
  watchSession: (params) => watchSessionWork(row, params),
2576
2585
  stopSession: (params) => stopSessionWork(row, params),