@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.
- package/dist/assets/{AssistantView-xdL1EvpR.js → AssistantView-B79FR_5m.js} +1 -1
- package/dist/assets/{DiagramView-DPgkYU-_.js → DiagramView-Bg5pX5aJ.js} +1 -1
- package/dist/assets/{MarkdownRenderer-CP7UVZdr.js → MarkdownRenderer-BrOPSCOy.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-BB1Q1RWk.js → MarkdownRendererImpl-BWTMbqp6.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-BjQrUIcq.js → MarkstreamRendererImpl-Bwxkdtts.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-Cyzs6cuR.js → MobileDetailNavigation-37jfPItf.js} +1 -1
- package/dist/assets/{MobileShareBridge-A-Z6jMUn.js → MobileShareBridge-D2RJnSvI.js} +1 -1
- package/dist/assets/{MobileSurface-DjDa20gx.js → MobileSurface-B_jx2mRT.js} +1 -1
- package/dist/assets/{MultiRunWindow-Cg5x-kJV.js → MultiRunWindow-CUYEega4.js} +1 -1
- package/dist/assets/{SettingsView-CYrTHDdp.js → SettingsView-B5BWCL5K.js} +1 -1
- package/dist/assets/{SettingsWindow-CF-0Zgxr.js → SettingsWindow--F_wFMX-.js} +1 -1
- package/dist/assets/{TerminalView-CShou18D.js → TerminalView-BqnCNcW3.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BvmxlNOa.js → ToolOutputDialog-Cxpellmm.js} +1 -1
- package/dist/assets/{appThemeRegistry-iqSTUpPj.js → appThemeRegistry-DaoSMJG1.js} +1 -1
- package/dist/assets/{fileMentionTouchSelection-CddGH2aH.js → fileMentionTouchSelection-COw1MDRz.js} +1 -1
- package/dist/assets/{gitApi-BwuaHHRQ.js → gitApi-uzDmhDPP.js} +1 -1
- package/dist/assets/{insertionBoundaries-og6NBgdn.js → insertionBoundaries-BYOyFrjB.js} +1 -1
- package/dist/assets/{main-VD-QBM47.js → main-Br8P10pP.js} +2 -2
- package/dist/assets/{main-31g3JiQx.js → main-DaaedB65.js} +4 -4
- package/dist/assets/{miniChat-DwCMTamY.js → miniChat-aQQ4d-bD.js} +2 -2
- package/dist/assets/{mobile-C7q1yowe.js → mobile-XeTOB53s.js} +2 -2
- package/dist/assets/{multirun-DcN5T0lN.js → multirun-BKVlVu2V.js} +1 -1
- package/dist/assets/{projectMeta-moNJYCK4.js → projectMeta-DX3l1Xzy.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-DVkgKA6A.js → renderElectronMiniChatApp-CmYP_apQ.js} +2 -2
- package/dist/assets/{renderMobileApp-BgEXZhCO.js → renderMobileApp-ATUEcuRh.js} +4 -4
- package/dist/assets/{runtimeConfig-B49cxtgI.js → runtimeConfig-Cug6bqDv.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-9K_Dj1qR.js → runtimeEndpointReset-CRc6se81.js} +1 -1
- package/dist/assets/{sessionLookup-m3h8c4xR.js → sessionLookup-D3ISlV8R.js} +1 -1
- package/dist/assets/{useAppFontEffects-DRFi4TCE.js → useAppFontEffects-Cn7pYCz1.js} +5 -5
- package/dist/assets/{useDesktopWindowControlsLayout-BdNR3zMN.js → useDesktopWindowControlsLayout-DXAFSQuV.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-COoPja_2.js → useEffectiveDirectory-DT_EmmM0.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-Cn0-ozUB.js → useMobileNavigationStore-gXfZxA1A.js} +1 -1
- package/dist/assets/{useSessionAutoCleanup-XwgyzsEd.js → useSessionAutoCleanup-CsVhR6FK.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-GgcqEoKY.js → useWorkerHighlightedLines-C1rC3WQe.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/mini-chat.html +2 -2
- package/dist/mobile.html +2 -2
- package/package.json +1 -1
- package/server/lib/assistants/DOCUMENTATION.md +11 -1
- package/server/lib/assistants/contact-store.js +18 -4
- package/server/lib/assistants/contact-tools.js +44 -0
- package/server/lib/assistants/contact-tools.test.js +2 -0
- package/server/lib/assistants/harness.js +2 -2
- package/server/lib/assistants/memory.http.test.js +204 -0
- package/server/lib/assistants/memory.js +124 -0
- package/server/lib/assistants/memory.test.js +159 -0
- 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),
|