opencode-lcm 0.11.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/CHANGELOG.md +83 -0
- package/LICENSE +21 -0
- package/README.md +207 -0
- package/dist/archive-transform.d.ts +45 -0
- package/dist/archive-transform.js +81 -0
- package/dist/constants.d.ts +12 -0
- package/dist/constants.js +16 -0
- package/dist/doctor.d.ts +22 -0
- package/dist/doctor.js +44 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +306 -0
- package/dist/logging.d.ts +14 -0
- package/dist/logging.js +28 -0
- package/dist/options.d.ts +3 -0
- package/dist/options.js +217 -0
- package/dist/preview-providers.d.ts +20 -0
- package/dist/preview-providers.js +246 -0
- package/dist/privacy.d.ts +16 -0
- package/dist/privacy.js +92 -0
- package/dist/search-ranking.d.ts +12 -0
- package/dist/search-ranking.js +98 -0
- package/dist/sql-utils.d.ts +31 -0
- package/dist/sql-utils.js +80 -0
- package/dist/store-artifacts.d.ts +50 -0
- package/dist/store-artifacts.js +374 -0
- package/dist/store-retention.d.ts +39 -0
- package/dist/store-retention.js +90 -0
- package/dist/store-search.d.ts +37 -0
- package/dist/store-search.js +298 -0
- package/dist/store-snapshot.d.ts +133 -0
- package/dist/store-snapshot.js +325 -0
- package/dist/store-types.d.ts +14 -0
- package/dist/store-types.js +5 -0
- package/dist/store.d.ts +316 -0
- package/dist/store.js +3673 -0
- package/dist/types.d.ts +117 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +35 -0
- package/dist/utils.js +414 -0
- package/dist/workspace-path.d.ts +1 -0
- package/dist/workspace-path.js +15 -0
- package/dist/worktree-key.d.ts +1 -0
- package/dist/worktree-key.js +6 -0
- package/package.json +61 -0
- package/src/archive-transform.ts +147 -0
- package/src/bun-sqlite.d.ts +18 -0
- package/src/constants.ts +20 -0
- package/src/doctor.ts +83 -0
- package/src/index.ts +330 -0
- package/src/logging.ts +41 -0
- package/src/options.ts +297 -0
- package/src/preview-providers.ts +298 -0
- package/src/privacy.ts +122 -0
- package/src/search-ranking.ts +145 -0
- package/src/sql-utils.ts +107 -0
- package/src/store-artifacts.ts +666 -0
- package/src/store-retention.ts +152 -0
- package/src/store-search.ts +440 -0
- package/src/store-snapshot.ts +582 -0
- package/src/store-types.ts +16 -0
- package/src/store.ts +4926 -0
- package/src/types.ts +132 -0
- package/src/utils.ts +444 -0
- package/src/workspace-path.ts +20 -0
- package/src/worktree-key.ts +5 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { runBinaryPreviewProviders } from './preview-providers.js';
|
|
3
|
+
import { isExcludedTool, matchesExcludedPath, PRIVACY_EXCLUDED_FILE_CONTENT, PRIVACY_EXCLUDED_FILE_REFERENCE, PRIVACY_EXCLUDED_TOOL_OUTPUT, PRIVACY_REDACTED_PATH_TEXT, redactStructuredValue, redactText, } from './privacy.js';
|
|
4
|
+
import { classifyFileCategory, formatMetadataValue, hashContent, inferFileExtension, inferUrlScheme, parseJson, sanitizeAutomaticRetrievalSourceText, truncate, } from './utils.js';
|
|
5
|
+
function artifactPlaceholder(artifactID, label, preview, charCount) {
|
|
6
|
+
const body = preview ? ` Preview: ${preview}` : '';
|
|
7
|
+
return `[Externalized ${label} as ${artifactID} (${charCount} chars). Use lcm_artifact for full content.]${body}`;
|
|
8
|
+
}
|
|
9
|
+
function fileCategoryHint(category) {
|
|
10
|
+
switch (category) {
|
|
11
|
+
case 'image':
|
|
12
|
+
return 'Visual asset or screenshot; exact pixels still require the source file.';
|
|
13
|
+
case 'pdf':
|
|
14
|
+
return 'Formatted document; exact layout and embedded pages still require the source file.';
|
|
15
|
+
case 'audio':
|
|
16
|
+
return 'Audio asset; waveform and transcription details still require the source file.';
|
|
17
|
+
case 'video':
|
|
18
|
+
return 'Video asset; frames and timing still require the source file.';
|
|
19
|
+
case 'archive':
|
|
20
|
+
return 'Bundled archive; internal file listing still requires unpacking the source file.';
|
|
21
|
+
case 'spreadsheet':
|
|
22
|
+
return 'Spreadsheet-like document; formulas and cell layout may require the source file.';
|
|
23
|
+
case 'presentation':
|
|
24
|
+
return 'Slide deck; visual layout and speaker notes may require the source file.';
|
|
25
|
+
case 'document':
|
|
26
|
+
return 'Rich document; styled content and embedded assets may require the source file.';
|
|
27
|
+
case 'code':
|
|
28
|
+
return 'Code or source-like file reference; load the file body if exact lines matter.';
|
|
29
|
+
case 'structured-data':
|
|
30
|
+
return 'Structured data file reference; exact records may require the full source body.';
|
|
31
|
+
default:
|
|
32
|
+
return 'Binary or opaque artifact reference; inspect the original file for exact contents.';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function createArtifactData(bindings, input) {
|
|
36
|
+
const contentText = redactText(input.contentText, bindings.options.privacy);
|
|
37
|
+
const metadata = redactStructuredValue(input.metadata ?? {}, bindings.options.privacy);
|
|
38
|
+
const previewText = redactText(input.previewText ??
|
|
39
|
+
truncate(contentText.replace(/\s+/g, ' ').trim(), bindings.options.artifactPreviewChars), bindings.options.privacy);
|
|
40
|
+
const contentHash = hashContent(contentText);
|
|
41
|
+
return {
|
|
42
|
+
artifactID: `art_${randomUUID().replace(/-/g, '').slice(0, 12)}`,
|
|
43
|
+
sessionID: input.sessionID,
|
|
44
|
+
messageID: input.messageID,
|
|
45
|
+
partID: input.partID,
|
|
46
|
+
artifactKind: input.artifactKind,
|
|
47
|
+
fieldName: input.fieldName,
|
|
48
|
+
previewText,
|
|
49
|
+
contentText,
|
|
50
|
+
contentHash,
|
|
51
|
+
charCount: contentText.length,
|
|
52
|
+
createdAt: input.createdAt,
|
|
53
|
+
metadata,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function filePrivacyCandidates(file) {
|
|
57
|
+
const sourcePath = file.source && 'path' in file.source ? file.source.path : undefined;
|
|
58
|
+
return [file.filename, file.url, sourcePath];
|
|
59
|
+
}
|
|
60
|
+
function excludeStoredFilePart(file) {
|
|
61
|
+
file.filename = PRIVACY_EXCLUDED_FILE_REFERENCE;
|
|
62
|
+
file.url = 'lcm://privacy-excluded';
|
|
63
|
+
if (file.source && 'path' in file.source)
|
|
64
|
+
file.source.path = PRIVACY_REDACTED_PATH_TEXT;
|
|
65
|
+
if (file.source?.text?.value) {
|
|
66
|
+
file.source.text.value = PRIVACY_EXCLUDED_FILE_CONTENT;
|
|
67
|
+
file.source.text.start = 0;
|
|
68
|
+
file.source.text.end = file.source.text.value.length;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function formatArtifactMetadataLines(metadata) {
|
|
72
|
+
const lines = Object.entries(metadata)
|
|
73
|
+
.map(([key, value]) => {
|
|
74
|
+
const formatted = formatMetadataValue(value);
|
|
75
|
+
return formatted ? `${key}: ${formatted}` : undefined;
|
|
76
|
+
})
|
|
77
|
+
.filter((line) => Boolean(line));
|
|
78
|
+
return lines.length > 0 ? ['Metadata:', ...lines] : [];
|
|
79
|
+
}
|
|
80
|
+
export function buildArtifactSearchContent(artifact) {
|
|
81
|
+
const metadata = Object.entries(artifact.metadata)
|
|
82
|
+
.map(([key, value]) => {
|
|
83
|
+
const formatted = formatMetadataValue(value);
|
|
84
|
+
return formatted ? `${key}: ${formatted}` : undefined;
|
|
85
|
+
})
|
|
86
|
+
.filter((line) => Boolean(line))
|
|
87
|
+
.join('\n');
|
|
88
|
+
return [artifact.previewText, metadata, artifact.contentText].filter(Boolean).join('\n');
|
|
89
|
+
}
|
|
90
|
+
function buildFileArtifactMetadata(file, extras = {}) {
|
|
91
|
+
const sourcePath = file.source && 'path' in file.source ? file.source.path : undefined;
|
|
92
|
+
const extension = inferFileExtension(file.filename ?? sourcePath ?? file.url);
|
|
93
|
+
const category = classifyFileCategory(file.mime, extension);
|
|
94
|
+
return {
|
|
95
|
+
category,
|
|
96
|
+
extension,
|
|
97
|
+
mime: file.mime,
|
|
98
|
+
filename: file.filename,
|
|
99
|
+
url: file.url,
|
|
100
|
+
urlScheme: inferUrlScheme(file.url),
|
|
101
|
+
sourceType: file.source?.type,
|
|
102
|
+
sourcePath,
|
|
103
|
+
hint: fileCategoryHint(category),
|
|
104
|
+
...extras,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
async function buildBinaryPreviewArtifact(bindings, file, fieldName, label, createdAt, extras = {}) {
|
|
108
|
+
const baseMetadata = buildFileArtifactMetadata(file, extras);
|
|
109
|
+
const category = typeof baseMetadata.category === 'string' ? baseMetadata.category : 'binary';
|
|
110
|
+
const extension = typeof baseMetadata.extension === 'string' ? baseMetadata.extension : undefined;
|
|
111
|
+
const name = file.filename ??
|
|
112
|
+
(typeof baseMetadata.sourcePath === 'string' ? baseMetadata.sourcePath : undefined) ??
|
|
113
|
+
file.url ??
|
|
114
|
+
'unknown file';
|
|
115
|
+
const previewDetails = await runBinaryPreviewProviders({
|
|
116
|
+
workspaceDirectory: bindings.workspaceDirectory,
|
|
117
|
+
file,
|
|
118
|
+
category,
|
|
119
|
+
extension,
|
|
120
|
+
mime: file.mime,
|
|
121
|
+
enabledProviders: bindings.options.binaryPreviewProviders,
|
|
122
|
+
bytePeek: bindings.options.previewBytePeek,
|
|
123
|
+
});
|
|
124
|
+
const summary = previewDetails.summaryBits.slice(0, 3).join(', ');
|
|
125
|
+
const contentText = [
|
|
126
|
+
`${label}`,
|
|
127
|
+
`Category: ${category}`,
|
|
128
|
+
`Name: ${name}`,
|
|
129
|
+
...(typeof baseMetadata.sourcePath === 'string' ? [`Path: ${baseMetadata.sourcePath}`] : []),
|
|
130
|
+
...(file.mime ? [`MIME: ${file.mime}`] : []),
|
|
131
|
+
...(extension ? [`Extension: ${extension}`] : []),
|
|
132
|
+
...(typeof baseMetadata.urlScheme === 'string'
|
|
133
|
+
? [`URL scheme: ${baseMetadata.urlScheme}`]
|
|
134
|
+
: []),
|
|
135
|
+
...(file.url ? [`URL: ${file.url}`] : []),
|
|
136
|
+
...(typeof baseMetadata.hint === 'string' ? [`Hint: ${baseMetadata.hint}`] : []),
|
|
137
|
+
...previewDetails.lines,
|
|
138
|
+
].join('\n');
|
|
139
|
+
const previewText = truncate(`${label}: ${name} (${category}${summary ? `, ${summary}` : ''})`, bindings.options.artifactPreviewChars);
|
|
140
|
+
return createArtifactData(bindings, {
|
|
141
|
+
sessionID: file.sessionID,
|
|
142
|
+
messageID: file.messageID,
|
|
143
|
+
partID: file.id,
|
|
144
|
+
artifactKind: 'file',
|
|
145
|
+
fieldName,
|
|
146
|
+
contentText,
|
|
147
|
+
createdAt,
|
|
148
|
+
metadata: { ...baseMetadata, ...previewDetails.metadata },
|
|
149
|
+
previewText,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
async function externalizePart(bindings, part, createdAt) {
|
|
153
|
+
const storedPart = parseJson(JSON.stringify(part));
|
|
154
|
+
const artifacts = [];
|
|
155
|
+
const privacy = bindings.options.privacy;
|
|
156
|
+
const externalize = (artifactKind, fieldName, value, metadata = {}, previewText, sanitize = false) => {
|
|
157
|
+
const contentText = sanitize ? sanitizeAutomaticRetrievalSourceText(value) : value;
|
|
158
|
+
if (contentText.length < bindings.options.largeContentThreshold)
|
|
159
|
+
return contentText;
|
|
160
|
+
const artifact = createArtifactData(bindings, {
|
|
161
|
+
sessionID: storedPart.sessionID,
|
|
162
|
+
messageID: storedPart.messageID,
|
|
163
|
+
partID: storedPart.id,
|
|
164
|
+
artifactKind,
|
|
165
|
+
fieldName,
|
|
166
|
+
contentText,
|
|
167
|
+
createdAt,
|
|
168
|
+
metadata,
|
|
169
|
+
previewText,
|
|
170
|
+
});
|
|
171
|
+
artifacts.push(artifact);
|
|
172
|
+
return artifactPlaceholder(artifact.artifactID, `${artifactKind}/${fieldName}`, artifact.previewText, artifact.charCount);
|
|
173
|
+
};
|
|
174
|
+
switch (storedPart.type) {
|
|
175
|
+
case 'text':
|
|
176
|
+
storedPart.text = externalize('message', 'text', storedPart.text, {}, undefined, true);
|
|
177
|
+
if (artifacts.length > 0) {
|
|
178
|
+
storedPart.metadata = {
|
|
179
|
+
...(storedPart.metadata ?? {}),
|
|
180
|
+
opencodeLcmArtifact: artifacts.map((artifact) => artifact.artifactID),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
case 'reasoning':
|
|
185
|
+
storedPart.text = externalize('reasoning', 'text', storedPart.text, {}, undefined, true);
|
|
186
|
+
if (artifacts.length > 0) {
|
|
187
|
+
storedPart.metadata = {
|
|
188
|
+
...(storedPart.metadata ?? {}),
|
|
189
|
+
opencodeLcmArtifact: artifacts.map((artifact) => artifact.artifactID),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
case 'tool':
|
|
194
|
+
if (isExcludedTool(storedPart.tool, privacy)) {
|
|
195
|
+
storedPart.state.input = { excluded: true };
|
|
196
|
+
if ('metadata' in storedPart.state)
|
|
197
|
+
storedPart.state.metadata = { excluded: true };
|
|
198
|
+
if (storedPart.state.status === 'completed') {
|
|
199
|
+
storedPart.state.output = PRIVACY_EXCLUDED_TOOL_OUTPUT;
|
|
200
|
+
storedPart.state.attachments = [];
|
|
201
|
+
}
|
|
202
|
+
if (storedPart.state.status === 'error') {
|
|
203
|
+
storedPart.state.error = PRIVACY_EXCLUDED_TOOL_OUTPUT;
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
if (storedPart.state.status === 'completed') {
|
|
208
|
+
storedPart.state.output = externalize('tool', 'output', storedPart.state.output, {}, undefined, true);
|
|
209
|
+
if (storedPart.state.attachments) {
|
|
210
|
+
const storedAttachments = [];
|
|
211
|
+
for (const [index, attachment] of storedPart.state.attachments.entries()) {
|
|
212
|
+
if (matchesExcludedPath(filePrivacyCandidates(attachment), privacy)) {
|
|
213
|
+
excludeStoredFilePart(attachment);
|
|
214
|
+
storedAttachments.push(attachment);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const previewMetadata = {
|
|
218
|
+
attachmentIndex: index,
|
|
219
|
+
tool: storedPart.tool,
|
|
220
|
+
title: storedPart.state.status === 'completed' ? storedPart.state.title : undefined,
|
|
221
|
+
};
|
|
222
|
+
artifacts.push(await buildBinaryPreviewArtifact(bindings, attachment, `attachment:${index}`, `Tool attachment for ${storedPart.tool}`, createdAt, previewMetadata));
|
|
223
|
+
if (attachment.source?.text?.value) {
|
|
224
|
+
attachment.source.text.value = externalize('file', `attachment_text:${index}`, attachment.source.text.value, buildFileArtifactMetadata(attachment, previewMetadata));
|
|
225
|
+
attachment.source.text.start = 0;
|
|
226
|
+
attachment.source.text.end = attachment.source.text.value.length;
|
|
227
|
+
}
|
|
228
|
+
storedAttachments.push(attachment);
|
|
229
|
+
}
|
|
230
|
+
storedPart.state.attachments = storedAttachments;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (storedPart.state.status === 'error') {
|
|
234
|
+
storedPart.state.error = externalize('tool', 'error', storedPart.state.error, {}, undefined, true);
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
case 'file':
|
|
238
|
+
if (matchesExcludedPath(filePrivacyCandidates(storedPart), privacy)) {
|
|
239
|
+
excludeStoredFilePart(storedPart);
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
artifacts.push(await buildBinaryPreviewArtifact(bindings, storedPart, 'reference', 'File reference', createdAt));
|
|
243
|
+
if (storedPart.source?.text?.value) {
|
|
244
|
+
storedPart.source.text.value = externalize('file', 'source', storedPart.source.text.value, buildFileArtifactMetadata(storedPart));
|
|
245
|
+
storedPart.source.text.start = 0;
|
|
246
|
+
storedPart.source.text.end = storedPart.source.text.value.length;
|
|
247
|
+
}
|
|
248
|
+
break;
|
|
249
|
+
case 'snapshot':
|
|
250
|
+
storedPart.snapshot = externalize('snapshot', 'snapshot', storedPart.snapshot, {}, undefined, true);
|
|
251
|
+
break;
|
|
252
|
+
case 'agent':
|
|
253
|
+
if (storedPart.source?.value) {
|
|
254
|
+
storedPart.source.value = externalize('agent', 'source', storedPart.source.value, {}, undefined, true);
|
|
255
|
+
storedPart.source.start = 0;
|
|
256
|
+
storedPart.source.end = storedPart.source.value.length;
|
|
257
|
+
}
|
|
258
|
+
break;
|
|
259
|
+
case 'subtask':
|
|
260
|
+
storedPart.prompt = externalize('subtask', 'prompt', storedPart.prompt, {}, undefined, true);
|
|
261
|
+
storedPart.description = externalize('subtask', 'description', storedPart.description, {}, undefined, true);
|
|
262
|
+
break;
|
|
263
|
+
default:
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
storedPart: redactStructuredValue(storedPart, privacy),
|
|
268
|
+
artifacts,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
export async function externalizeMessage(bindings, message) {
|
|
272
|
+
const artifacts = [];
|
|
273
|
+
const storedInfo = parseJson(JSON.stringify(message.info));
|
|
274
|
+
const storedParts = [];
|
|
275
|
+
for (const part of message.parts) {
|
|
276
|
+
const { storedPart, artifacts: nextArtifacts } = await externalizePart(bindings, part, message.info.time.created);
|
|
277
|
+
artifacts.push(...nextArtifacts);
|
|
278
|
+
storedParts.push(storedPart);
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
storedMessage: {
|
|
282
|
+
info: redactStructuredValue(storedInfo, bindings.options.privacy),
|
|
283
|
+
parts: storedParts,
|
|
284
|
+
},
|
|
285
|
+
artifacts,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
export async function externalizeSession(bindings, session) {
|
|
289
|
+
const artifacts = [];
|
|
290
|
+
const storedMessages = [];
|
|
291
|
+
for (const message of session.messages) {
|
|
292
|
+
const storedInfo = parseJson(JSON.stringify(message.info));
|
|
293
|
+
const storedParts = [];
|
|
294
|
+
for (const part of message.parts) {
|
|
295
|
+
const { storedPart, artifacts: nextArtifacts } = await externalizePart(bindings, part, message.info.time.created);
|
|
296
|
+
artifacts.push(...nextArtifacts);
|
|
297
|
+
storedParts.push(storedPart);
|
|
298
|
+
}
|
|
299
|
+
storedMessages.push({
|
|
300
|
+
info: redactStructuredValue(storedInfo, bindings.options.privacy),
|
|
301
|
+
parts: storedParts,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
storedSession: redactStructuredValue({
|
|
306
|
+
...session,
|
|
307
|
+
messages: storedMessages,
|
|
308
|
+
}, bindings.options.privacy),
|
|
309
|
+
artifacts,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function insertArtifactsSync(bindings, artifacts) {
|
|
313
|
+
if (artifacts.length === 0)
|
|
314
|
+
return;
|
|
315
|
+
const db = bindings.getDb();
|
|
316
|
+
const insertBlob = db.prepare(`INSERT OR IGNORE INTO artifact_blobs (content_hash, content_text, char_count, created_at)
|
|
317
|
+
VALUES (?, ?, ?, ?)`);
|
|
318
|
+
const insertArtifact = db.prepare(`INSERT INTO artifacts
|
|
319
|
+
(artifact_id, session_id, message_id, part_id, artifact_kind, field_name, preview_text, content_text, content_hash, metadata_json, char_count, created_at)
|
|
320
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
321
|
+
const insertFts = db.prepare('INSERT INTO artifact_fts (session_id, artifact_id, message_id, part_id, artifact_kind, created_at, content) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
|
322
|
+
for (const artifact of artifacts) {
|
|
323
|
+
insertBlob.run(artifact.contentHash, artifact.contentText, artifact.charCount, artifact.createdAt);
|
|
324
|
+
insertArtifact.run(artifact.artifactID, artifact.sessionID, artifact.messageID, artifact.partID, artifact.artifactKind, artifact.fieldName, artifact.previewText, '', artifact.contentHash, JSON.stringify(artifact.metadata), artifact.charCount, artifact.createdAt);
|
|
325
|
+
insertFts.run(artifact.sessionID, artifact.artifactID, artifact.messageID, artifact.partID, artifact.artifactKind, String(artifact.createdAt), buildArtifactSearchContent(artifact));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
export function persistStoredSessionSync(bindings, storedSession, artifacts) {
|
|
329
|
+
const db = bindings.getDb();
|
|
330
|
+
bindings.upsertSessionRowSync(storedSession);
|
|
331
|
+
db.prepare('DELETE FROM artifact_fts WHERE session_id = ?').run(storedSession.sessionID);
|
|
332
|
+
db.prepare('DELETE FROM artifacts WHERE session_id = ?').run(storedSession.sessionID);
|
|
333
|
+
db.prepare('DELETE FROM parts WHERE session_id = ?').run(storedSession.sessionID);
|
|
334
|
+
db.prepare('DELETE FROM messages WHERE session_id = ?').run(storedSession.sessionID);
|
|
335
|
+
const insertMessage = db.prepare('INSERT INTO messages (message_id, session_id, created_at, info_json) VALUES (?, ?, ?, ?)');
|
|
336
|
+
const insertPart = db.prepare('INSERT INTO parts (part_id, session_id, message_id, sort_key, part_json) VALUES (?, ?, ?, ?, ?)');
|
|
337
|
+
for (const message of storedSession.messages) {
|
|
338
|
+
insertMessage.run(message.info.id, storedSession.sessionID, message.info.time.created, JSON.stringify(message.info));
|
|
339
|
+
message.parts.forEach((part, index) => {
|
|
340
|
+
insertPart.run(part.id, storedSession.sessionID, part.messageID, index, JSON.stringify(part));
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
insertArtifactsSync(bindings, artifacts);
|
|
344
|
+
bindings.replaceMessageSearchRowsSync(storedSession);
|
|
345
|
+
}
|
|
346
|
+
export function replaceStoredMessageSync(bindings, sessionID, storedMessage, artifacts) {
|
|
347
|
+
const db = bindings.getDb();
|
|
348
|
+
bindings.deleteMessageSync(sessionID, storedMessage.info.id);
|
|
349
|
+
bindings.upsertMessageInfoSync(sessionID, storedMessage);
|
|
350
|
+
const insertPart = db.prepare('INSERT INTO parts (part_id, session_id, message_id, sort_key, part_json) VALUES (?, ?, ?, ?, ?)');
|
|
351
|
+
storedMessage.parts.forEach((part, index) => {
|
|
352
|
+
insertPart.run(part.id, sessionID, part.messageID, index, JSON.stringify(part));
|
|
353
|
+
});
|
|
354
|
+
insertArtifactsSync(bindings, artifacts);
|
|
355
|
+
bindings.replaceMessageSearchRowSync(sessionID, storedMessage);
|
|
356
|
+
}
|
|
357
|
+
export function materializeArtifactRow(bindings, row) {
|
|
358
|
+
const blob = bindings.readArtifactBlobSync(row.content_hash);
|
|
359
|
+
const contentText = blob?.content_text ?? row.content_text;
|
|
360
|
+
return {
|
|
361
|
+
artifactID: row.artifact_id,
|
|
362
|
+
sessionID: row.session_id,
|
|
363
|
+
messageID: row.message_id,
|
|
364
|
+
partID: row.part_id,
|
|
365
|
+
artifactKind: row.artifact_kind,
|
|
366
|
+
fieldName: row.field_name,
|
|
367
|
+
previewText: row.preview_text,
|
|
368
|
+
contentText,
|
|
369
|
+
contentHash: row.content_hash ?? hashContent(contentText),
|
|
370
|
+
charCount: blob?.char_count ?? row.char_count,
|
|
371
|
+
createdAt: row.created_at,
|
|
372
|
+
metadata: parseJson(row.metadata_json || '{}'),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SqlDatabaseLike } from './store-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Retention policy operations.
|
|
4
|
+
* Handles stale/deleted session pruning and orphan blob cleanup.
|
|
5
|
+
*/
|
|
6
|
+
export type RetentionSessionCandidate = {
|
|
7
|
+
session_id: string;
|
|
8
|
+
title: string | null;
|
|
9
|
+
session_directory: string | null;
|
|
10
|
+
root_session_id: string | null;
|
|
11
|
+
pinned: number;
|
|
12
|
+
deleted: number;
|
|
13
|
+
updated_at: number;
|
|
14
|
+
event_count: number;
|
|
15
|
+
message_count: number;
|
|
16
|
+
artifact_count: number;
|
|
17
|
+
};
|
|
18
|
+
export type RetentionBlobCandidate = {
|
|
19
|
+
content_hash: string;
|
|
20
|
+
char_count: number;
|
|
21
|
+
created_at: number;
|
|
22
|
+
};
|
|
23
|
+
export type ResolvedRetentionPolicy = {
|
|
24
|
+
staleSessionDays?: number;
|
|
25
|
+
deletedSessionDays?: number;
|
|
26
|
+
orphanBlobDays?: number;
|
|
27
|
+
};
|
|
28
|
+
export type RetentionPruneResult = {
|
|
29
|
+
deletedSessions: number;
|
|
30
|
+
deletedBlobs: number;
|
|
31
|
+
deletedBlobChars: number;
|
|
32
|
+
};
|
|
33
|
+
export declare function retentionCutoff(days: number): number;
|
|
34
|
+
export declare function readSessionRetentionCandidates(db: SqlDatabaseLike, deleted: boolean, days: number, limit?: number): RetentionSessionCandidate[];
|
|
35
|
+
export declare function countSessionRetentionCandidates(db: SqlDatabaseLike, deleted: boolean, days: number): number;
|
|
36
|
+
export declare function readOrphanBlobRetentionCandidates(db: SqlDatabaseLike, days: number, limit?: number): RetentionBlobCandidate[];
|
|
37
|
+
export declare function countOrphanBlobRetentionCandidates(db: SqlDatabaseLike, days: number): number;
|
|
38
|
+
export declare function sumOrphanBlobRetentionChars(db: SqlDatabaseLike, days: number): number;
|
|
39
|
+
export declare function clearSessionData(db: SqlDatabaseLike, sessionID: string): void;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export function retentionCutoff(days) {
|
|
2
|
+
return Date.now() - days * 24 * 60 * 60 * 1000;
|
|
3
|
+
}
|
|
4
|
+
export function readSessionRetentionCandidates(db, deleted, days, limit) {
|
|
5
|
+
const params = [retentionCutoff(days), deleted ? 1 : 0];
|
|
6
|
+
const sql = `
|
|
7
|
+
SELECT
|
|
8
|
+
s.session_id,
|
|
9
|
+
s.title,
|
|
10
|
+
s.session_directory,
|
|
11
|
+
s.root_session_id,
|
|
12
|
+
s.pinned,
|
|
13
|
+
s.deleted,
|
|
14
|
+
s.updated_at,
|
|
15
|
+
s.event_count,
|
|
16
|
+
(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.session_id) AS message_count,
|
|
17
|
+
(SELECT COUNT(*) FROM artifacts a WHERE a.session_id = s.session_id) AS artifact_count
|
|
18
|
+
FROM sessions s
|
|
19
|
+
WHERE s.updated_at <= ?
|
|
20
|
+
AND s.deleted = ?
|
|
21
|
+
AND s.pinned = 0
|
|
22
|
+
AND NOT EXISTS (
|
|
23
|
+
SELECT 1 FROM sessions child WHERE child.parent_session_id = s.session_id
|
|
24
|
+
)
|
|
25
|
+
ORDER BY s.updated_at ASC
|
|
26
|
+
${limit ? 'LIMIT ?' : ''}`;
|
|
27
|
+
if (limit)
|
|
28
|
+
params.push(limit);
|
|
29
|
+
return db.prepare(sql).all(...params);
|
|
30
|
+
}
|
|
31
|
+
export function countSessionRetentionCandidates(db, deleted, days) {
|
|
32
|
+
const row = db
|
|
33
|
+
.prepare(`SELECT COUNT(*) AS count
|
|
34
|
+
FROM sessions s
|
|
35
|
+
WHERE s.updated_at <= ?
|
|
36
|
+
AND s.deleted = ?
|
|
37
|
+
AND s.pinned = 0
|
|
38
|
+
AND NOT EXISTS (
|
|
39
|
+
SELECT 1 FROM sessions child WHERE child.parent_session_id = s.session_id
|
|
40
|
+
)`)
|
|
41
|
+
.get(retentionCutoff(days), deleted ? 1 : 0);
|
|
42
|
+
return row.count;
|
|
43
|
+
}
|
|
44
|
+
export function readOrphanBlobRetentionCandidates(db, days, limit) {
|
|
45
|
+
const params = [retentionCutoff(days)];
|
|
46
|
+
const sql = `
|
|
47
|
+
SELECT content_hash, char_count, created_at
|
|
48
|
+
FROM artifact_blobs b
|
|
49
|
+
WHERE b.created_at <= ?
|
|
50
|
+
AND NOT EXISTS (
|
|
51
|
+
SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
|
|
52
|
+
)
|
|
53
|
+
ORDER BY char_count DESC, created_at ASC
|
|
54
|
+
${limit ? 'LIMIT ?' : ''}`;
|
|
55
|
+
if (limit)
|
|
56
|
+
params.push(limit);
|
|
57
|
+
return db.prepare(sql).all(...params);
|
|
58
|
+
}
|
|
59
|
+
export function countOrphanBlobRetentionCandidates(db, days) {
|
|
60
|
+
const row = db
|
|
61
|
+
.prepare(`SELECT COUNT(*) AS count
|
|
62
|
+
FROM artifact_blobs b
|
|
63
|
+
WHERE b.created_at <= ?
|
|
64
|
+
AND NOT EXISTS (
|
|
65
|
+
SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
|
|
66
|
+
)`)
|
|
67
|
+
.get(retentionCutoff(days));
|
|
68
|
+
return row.count;
|
|
69
|
+
}
|
|
70
|
+
export function sumOrphanBlobRetentionChars(db, days) {
|
|
71
|
+
const row = db
|
|
72
|
+
.prepare(`SELECT COALESCE(SUM(char_count), 0) AS chars
|
|
73
|
+
FROM artifact_blobs b
|
|
74
|
+
WHERE b.created_at <= ?
|
|
75
|
+
AND NOT EXISTS (
|
|
76
|
+
SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
|
|
77
|
+
)`)
|
|
78
|
+
.get(retentionCutoff(days));
|
|
79
|
+
return row.chars;
|
|
80
|
+
}
|
|
81
|
+
export function clearSessionData(db, sessionID) {
|
|
82
|
+
db.prepare('DELETE FROM parts WHERE session_id = ?').run(sessionID);
|
|
83
|
+
db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionID);
|
|
84
|
+
db.prepare('DELETE FROM artifacts WHERE session_id = ?').run(sessionID);
|
|
85
|
+
db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionID);
|
|
86
|
+
db.prepare('DELETE FROM resumes WHERE session_id = ?').run(sessionID);
|
|
87
|
+
db.prepare('DELETE FROM summary_nodes WHERE session_id = ?').run(sessionID);
|
|
88
|
+
db.prepare('DELETE FROM summary_edges WHERE session_id = ?').run(sessionID);
|
|
89
|
+
db.prepare('DELETE FROM summary_state WHERE session_id = ?').run(sessionID);
|
|
90
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ArtifactRow, SummaryNodeRow } from './store-snapshot.js';
|
|
2
|
+
import type { SqlDatabaseLike } from './store-types.js';
|
|
3
|
+
import type { NormalizedSession, SearchResult } from './types.js';
|
|
4
|
+
type FtsDeps = {
|
|
5
|
+
getDb(): SqlDatabaseLike;
|
|
6
|
+
readScopedSessionsSync(sessionIDs?: string[]): NormalizedSession[];
|
|
7
|
+
readScopedSummaryRowsSync(sessionIDs?: string[]): SummaryNodeRow[];
|
|
8
|
+
readScopedArtifactRowsSync(sessionIDs?: string[]): ArtifactRow[];
|
|
9
|
+
buildArtifactSearchContent(row: ArtifactRow): string;
|
|
10
|
+
ignoreToolPrefixes: string[];
|
|
11
|
+
guessMessageText(message: NormalizedSession['messages'][number], ignorePrefixes: string[]): string;
|
|
12
|
+
};
|
|
13
|
+
export declare function buildFtsQuery(query: string): string | undefined;
|
|
14
|
+
export declare function computeTfidfWeights(db: SqlDatabaseLike, candidateTokens: string[]): Array<{
|
|
15
|
+
token: string;
|
|
16
|
+
idf: number;
|
|
17
|
+
docFreq: number;
|
|
18
|
+
}>;
|
|
19
|
+
/**
|
|
20
|
+
* Filter candidate tokens using TF-IDF weights.
|
|
21
|
+
* Drops tokens whose IDF is below the median (corpus-common terms)
|
|
22
|
+
* and tokens that appear in >80% of documents.
|
|
23
|
+
* Returns tokens sorted by descending IDF.
|
|
24
|
+
*/
|
|
25
|
+
export declare function filterTokensByTfidf(db: SqlDatabaseLike, candidateTokens: string[], options?: {
|
|
26
|
+
maxCommonRatio?: number;
|
|
27
|
+
minTokens?: number;
|
|
28
|
+
}): string[];
|
|
29
|
+
export declare function searchWithFts(deps: FtsDeps, query: string, sessionIDs?: string[], limit?: number): SearchResult[];
|
|
30
|
+
export declare function searchByScan(deps: FtsDeps, query: string, sessionIDs?: string[], limit?: number): SearchResult[];
|
|
31
|
+
export declare function replaceMessageSearchRowsSync(deps: FtsDeps, session: NormalizedSession): void;
|
|
32
|
+
export declare function replaceMessageSearchRowSync(deps: FtsDeps, sessionID: string, message: NormalizedSession['messages'][number]): void;
|
|
33
|
+
export declare function replaceSummarySearchRowsSync(deps: FtsDeps, sessionIDs?: string[]): void;
|
|
34
|
+
export declare function replaceArtifactSearchRowsSync(deps: FtsDeps, sessionIDs?: string[]): void;
|
|
35
|
+
export declare function refreshSearchIndexesSync(deps: FtsDeps, sessionIDs?: string[]): void;
|
|
36
|
+
export declare function rebuildSearchIndexesSync(deps: FtsDeps): void;
|
|
37
|
+
export {};
|