opencode-lcm 0.12.0 → 0.13.1

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 CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.1] - 2026-04-07
11
+
12
+ ### Fixed
13
+ - Archive transform now removes malformed messages from the outbound message array before returning control to OpenCode, preventing follow-on backend `Bad Request` failures
14
+ - Archive, resume, describe, search indexing, and capture paths now skip malformed `message.info` metadata defensively instead of throwing when required fields are missing
15
+
10
16
  ### Added
11
17
  - Opt-in `perf:archive` harness for large-archive regression coverage across transform, grep, snapshot, reopen, resume, and retention paths
12
18
  - Separate advisory `Archive Performance` workflow for scheduled/manual perf runs with JSON artifact upload
@@ -0,0 +1,73 @@
1
+ import type { SqlDatabaseLike } from './store-types.js';
2
+ /**
3
+ * Doctor diagnostics operations.
4
+ * Analyzes store health: summary graph integrity, FTS index consistency, orphan detection.
5
+ */
6
+ export type DoctorSessionIssue = {
7
+ sessionID: string;
8
+ issues: string[];
9
+ };
10
+ export type DoctorReport = {
11
+ scope: string;
12
+ checkedSessions: number;
13
+ summarySessionsNeedingRebuild: DoctorSessionIssue[];
14
+ lineageSessionsNeedingRefresh: string[];
15
+ orphanSummaryEdges: number;
16
+ messageFts: {
17
+ expected: number;
18
+ actual: number;
19
+ };
20
+ summaryFts: {
21
+ expected: number;
22
+ actual: number;
23
+ };
24
+ artifactFts: {
25
+ expected: number;
26
+ actual: number;
27
+ };
28
+ orphanArtifactBlobs: number;
29
+ status: 'clean' | 'issues-found';
30
+ };
31
+ type SessionSnapshot = {
32
+ sessionID: string;
33
+ messages: {
34
+ info: {
35
+ id: string;
36
+ time: {
37
+ created: number;
38
+ };
39
+ };
40
+ parts: unknown[];
41
+ }[];
42
+ rootSessionID?: string;
43
+ lineageDepth?: number;
44
+ };
45
+ type SummaryNodeRow = {
46
+ node_id: string;
47
+ session_id: string;
48
+ level: number;
49
+ slot: number;
50
+ archived_message_ids_json: string;
51
+ summary_text: string;
52
+ created_at: number;
53
+ };
54
+ type DoctorDeps = {
55
+ db: SqlDatabaseLike;
56
+ getArchivedMessages: (messages: SessionSnapshot['messages']) => SessionSnapshot['messages'];
57
+ buildArchivedSignature: (messages: SessionSnapshot['messages']) => string;
58
+ readSummaryNode: (nodeID: string) => SummaryNodeRow | undefined;
59
+ canReuseSummaryGraph: (sessionID: string, archived: SessionSnapshot['messages'], roots: SummaryNodeRow[]) => boolean;
60
+ readScopedSummaryRows: (sessionIDs?: string[]) => unknown[];
61
+ readScopedArtifactRows: (sessionIDs?: string[]) => unknown[];
62
+ readOrphanArtifactBlobRows: () => unknown[];
63
+ countScopedFtsRows: (table: 'message_fts' | 'summary_fts' | 'artifact_fts', sessionIDs?: string[]) => number;
64
+ countScopedOrphanSummaryEdges: (sessionIDs?: string[]) => number;
65
+ guessMessageText: (message: SessionSnapshot['messages'][number], ignorePrefixes: string[]) => string;
66
+ ignoreToolPrefixes: string[];
67
+ parseJson: <T>(value: string) => T;
68
+ };
69
+ export declare function collectDoctorReport(sessions: SessionSnapshot[], sessionID: string | undefined, deps: DoctorDeps, readLineageChain: (sessionID: string) => {
70
+ sessionID: string;
71
+ }[]): DoctorReport;
72
+ export declare function hasDoctorIssues(report: DoctorReport): boolean;
73
+ export {};
@@ -0,0 +1,106 @@
1
+ function countFtsExpected(sessions, deps) {
2
+ return sessions.reduce((count, session) => {
3
+ return (count +
4
+ session.messages.filter((message) => deps.guessMessageText(message, deps.ignoreToolPrefixes).length > 0).length);
5
+ }, 0);
6
+ }
7
+ function diagnoseSummarySession(session, deps) {
8
+ const issues = [];
9
+ const archived = deps.getArchivedMessages(session.messages);
10
+ const state = deps.db
11
+ .prepare('SELECT * FROM summary_state WHERE session_id = ?')
12
+ .get(session.sessionID);
13
+ const summaryNodeCount = deps.db
14
+ .prepare('SELECT COUNT(*) AS count FROM summary_nodes WHERE session_id = ?')
15
+ .get(session.sessionID);
16
+ const summaryEdgeCount = deps.db
17
+ .prepare('SELECT COUNT(*) AS count FROM summary_edges WHERE session_id = ?')
18
+ .get(session.sessionID);
19
+ if (archived.length === 0) {
20
+ if (state)
21
+ issues.push('unexpected-summary-state');
22
+ if (summaryNodeCount.count > 0)
23
+ issues.push('unexpected-summary-nodes');
24
+ if (summaryEdgeCount.count > 0)
25
+ issues.push('unexpected-summary-edges');
26
+ return issues.length > 0 ? { sessionID: session.sessionID, issues } : undefined;
27
+ }
28
+ const latestMessageCreated = archived.at(-1)?.info.time.created ?? 0;
29
+ const archivedSignature = deps.buildArchivedSignature(archived);
30
+ const rootIDs = state ? deps.parseJson(state.root_node_ids_json) : [];
31
+ const roots = rootIDs
32
+ .map((nodeID) => deps.readSummaryNode(nodeID))
33
+ .filter((node) => Boolean(node));
34
+ if (!state) {
35
+ issues.push('missing-summary-state');
36
+ }
37
+ else {
38
+ if (state.archived_count !== archived.length)
39
+ issues.push('archived-count-mismatch');
40
+ if (state.latest_message_created !== latestMessageCreated)
41
+ issues.push('latest-message-mismatch');
42
+ if (state.archived_signature !== archivedSignature)
43
+ issues.push('archived-signature-mismatch');
44
+ if (rootIDs.length === 0)
45
+ issues.push('missing-root-node-ids');
46
+ if (roots.length !== rootIDs.length) {
47
+ issues.push('missing-root-node-record');
48
+ }
49
+ else if (rootIDs.length > 0 &&
50
+ !deps.canReuseSummaryGraph(session.sessionID, archived, roots)) {
51
+ issues.push('invalid-summary-graph');
52
+ }
53
+ }
54
+ if (summaryNodeCount.count === 0)
55
+ issues.push('missing-summary-nodes');
56
+ return issues.length > 0 ? { sessionID: session.sessionID, issues } : undefined;
57
+ }
58
+ function needsLineageRefresh(session, readLineageChain) {
59
+ const chain = readLineageChain(session.sessionID);
60
+ const expectedRoot = chain[0]?.sessionID ?? session.sessionID;
61
+ const expectedDepth = Math.max(0, chain.length - 1);
62
+ return ((session.rootSessionID ?? session.sessionID) !== expectedRoot ||
63
+ (session.lineageDepth ?? 0) !== expectedDepth);
64
+ }
65
+ export function collectDoctorReport(sessions, sessionID, deps, readLineageChain) {
66
+ const sessionIDs = sessions.map((session) => session.sessionID);
67
+ const summarySessionsNeedingRebuild = sessions
68
+ .map((session) => diagnoseSummarySession(session, deps))
69
+ .filter((issue) => Boolean(issue));
70
+ const lineageSessionsNeedingRefresh = sessions
71
+ .filter((session) => needsLineageRefresh(session, readLineageChain))
72
+ .map((session) => session.sessionID);
73
+ const messageFtsExpected = countFtsExpected(sessions, deps);
74
+ const report = {
75
+ scope: sessionID ? `session:${sessionID}` : 'all',
76
+ checkedSessions: sessions.length,
77
+ summarySessionsNeedingRebuild,
78
+ lineageSessionsNeedingRefresh,
79
+ orphanSummaryEdges: deps.countScopedOrphanSummaryEdges(sessionIDs),
80
+ messageFts: {
81
+ expected: messageFtsExpected,
82
+ actual: deps.countScopedFtsRows('message_fts', sessionIDs),
83
+ },
84
+ summaryFts: {
85
+ expected: deps.readScopedSummaryRows(sessionIDs).length,
86
+ actual: deps.countScopedFtsRows('summary_fts', sessionIDs),
87
+ },
88
+ artifactFts: {
89
+ expected: deps.readScopedArtifactRows(sessionIDs).length,
90
+ actual: deps.countScopedFtsRows('artifact_fts', sessionIDs),
91
+ },
92
+ orphanArtifactBlobs: deps.readOrphanArtifactBlobRows().length,
93
+ status: 'clean',
94
+ };
95
+ report.status = hasDoctorIssues(report) ? 'issues-found' : 'clean';
96
+ return report;
97
+ }
98
+ export function hasDoctorIssues(report) {
99
+ return (report.summarySessionsNeedingRebuild.length > 0 ||
100
+ report.lineageSessionsNeedingRefresh.length > 0 ||
101
+ report.orphanSummaryEdges > 0 ||
102
+ report.messageFts.expected !== report.messageFts.actual ||
103
+ report.summaryFts.expected !== report.summaryFts.actual ||
104
+ report.artifactFts.expected !== report.artifactFts.actual ||
105
+ report.orphanArtifactBlobs > 0);
106
+ }
@@ -0,0 +1,8 @@
1
+ import type { SqlDatabaseLike } from './store-types.js';
2
+ /**
3
+ * Schema version management operations.
4
+ * Handles reading, writing, and validating the SQLite store schema version.
5
+ */
6
+ export declare function readSchemaVersionSync(db: SqlDatabaseLike): number;
7
+ export declare function assertSupportedSchemaVersionSync(db: SqlDatabaseLike, maxVersion: number): void;
8
+ export declare function writeSchemaVersionSync(db: SqlDatabaseLike, version: number): void;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Schema version management operations.
3
+ * Handles reading, writing, and validating the SQLite store schema version.
4
+ */
5
+ export function readSchemaVersionSync(db) {
6
+ const result = db.prepare('PRAGMA user_version').get();
7
+ if (!result || typeof result !== 'object')
8
+ return 0;
9
+ for (const value of Object.values(result)) {
10
+ if (typeof value === 'number' && Number.isFinite(value))
11
+ return value;
12
+ }
13
+ return 0;
14
+ }
15
+ export function assertSupportedSchemaVersionSync(db, maxVersion) {
16
+ const schemaVersion = readSchemaVersionSync(db);
17
+ if (schemaVersion <= maxVersion)
18
+ return;
19
+ throw new Error(`Unsupported store schema version: ${schemaVersion}. This build supports up to ${maxVersion}.`);
20
+ }
21
+ export function writeSchemaVersionSync(db, version) {
22
+ db.exec(`PRAGMA user_version = ${Math.max(0, Math.trunc(version))}`);
23
+ }
@@ -0,0 +1,97 @@
1
+ import type { SqlDatabaseLike } from './store-types.js';
2
+ /**
3
+ * Session read operations.
4
+ * Handles reading sessions, messages, parts, artifacts from the store.
5
+ */
6
+ export type SessionRow = {
7
+ session_id: string;
8
+ title: string | null;
9
+ parent_session_id: string | null;
10
+ root_session_id: string | null;
11
+ lineage_depth: number | null;
12
+ session_directory: string | null;
13
+ worktree_key: string | null;
14
+ pinned: number;
15
+ pin_reason: string | null;
16
+ deleted: number;
17
+ updated_at: number;
18
+ created_at: number;
19
+ event_count: number;
20
+ };
21
+ export type MessageRow = {
22
+ session_id: string;
23
+ message_id: string;
24
+ role: string;
25
+ created_at: number;
26
+ };
27
+ export type PartRow = {
28
+ session_id: string;
29
+ message_id: string;
30
+ part_id: string;
31
+ part_type: string;
32
+ sort_key: number;
33
+ state_json: string;
34
+ created_at: number;
35
+ };
36
+ export type ArtifactRow = {
37
+ artifact_id: string;
38
+ session_id: string;
39
+ message_id: string;
40
+ part_id: string;
41
+ artifact_kind: string;
42
+ field_name: string;
43
+ content_hash: string | null;
44
+ preview_text: string;
45
+ metadata_json: string;
46
+ char_count: number;
47
+ created_at: number;
48
+ };
49
+ export type ArtifactBlobRow = {
50
+ content_hash: string;
51
+ content_text: string;
52
+ char_count: number;
53
+ created_at: number;
54
+ };
55
+ export type SummaryNodeRow = {
56
+ node_id: string;
57
+ session_id: string;
58
+ level: number;
59
+ slot: number;
60
+ archived_message_ids_json: string;
61
+ summary_text: string;
62
+ created_at: number;
63
+ };
64
+ export type SummaryEdgeRow = {
65
+ session_id: string;
66
+ parent_id: string;
67
+ child_id: string;
68
+ child_position: number;
69
+ };
70
+ export type SummaryStateRow = {
71
+ session_id: string;
72
+ archived_count: number;
73
+ latest_message_created: number;
74
+ archived_signature: string;
75
+ root_node_ids_json: string;
76
+ updated_at: number;
77
+ };
78
+ export declare function readSessionHeader(db: SqlDatabaseLike, sessionID: string): SessionRow | undefined;
79
+ export declare function readAllSessions(db: SqlDatabaseLike): SessionRow[];
80
+ export declare function readChildSessions(db: SqlDatabaseLike, parentSessionID: string): SessionRow[];
81
+ export declare function readLineageChain(db: SqlDatabaseLike, sessionID: string): SessionRow[];
82
+ export declare function readMessagesForSession(db: SqlDatabaseLike, sessionID: string): MessageRow[];
83
+ export declare function readPartsForSession(db: SqlDatabaseLike, sessionID: string): PartRow[];
84
+ export declare function readArtifactsForSession(db: SqlDatabaseLike, sessionID: string): ArtifactRow[];
85
+ export declare function readArtifact(db: SqlDatabaseLike, artifactID: string): ArtifactRow | undefined;
86
+ export declare function readArtifactBlob(db: SqlDatabaseLike, contentHash: string): ArtifactBlobRow | undefined;
87
+ export declare function readOrphanArtifactBlobRows(db: SqlDatabaseLike): ArtifactBlobRow[];
88
+ export declare function readLatestSessionID(db: SqlDatabaseLike): string | undefined;
89
+ export declare function readSessionStats(db: SqlDatabaseLike): {
90
+ sessionCount: number;
91
+ messageCount: number;
92
+ artifactCount: number;
93
+ summaryNodeCount: number;
94
+ blobCount: number;
95
+ orphanBlobCount: number;
96
+ orphanBlobChars: number;
97
+ };
@@ -0,0 +1,80 @@
1
+ export function readSessionHeader(db, sessionID) {
2
+ return db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(sessionID);
3
+ }
4
+ export function readAllSessions(db) {
5
+ return db.prepare('SELECT * FROM sessions ORDER BY updated_at DESC').all();
6
+ }
7
+ export function readChildSessions(db, parentSessionID) {
8
+ return db
9
+ .prepare('SELECT * FROM sessions WHERE parent_session_id = ? ORDER BY updated_at DESC')
10
+ .all(parentSessionID);
11
+ }
12
+ export function readLineageChain(db, sessionID) {
13
+ const chain = [];
14
+ let current = readSessionHeader(db, sessionID);
15
+ while (current) {
16
+ chain.unshift(current);
17
+ if (!current.parent_session_id)
18
+ break;
19
+ current = readSessionHeader(db, current.parent_session_id);
20
+ }
21
+ return chain;
22
+ }
23
+ export function readMessagesForSession(db, sessionID) {
24
+ return db
25
+ .prepare('SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC')
26
+ .all(sessionID);
27
+ }
28
+ export function readPartsForSession(db, sessionID) {
29
+ return db
30
+ .prepare('SELECT * FROM parts WHERE session_id = ? ORDER BY message_id ASC, sort_key ASC')
31
+ .all(sessionID);
32
+ }
33
+ export function readArtifactsForSession(db, sessionID) {
34
+ return db
35
+ .prepare('SELECT * FROM artifacts WHERE session_id = ? ORDER BY created_at DESC')
36
+ .all(sessionID);
37
+ }
38
+ export function readArtifact(db, artifactID) {
39
+ return db.prepare('SELECT * FROM artifacts WHERE artifact_id = ?').get(artifactID);
40
+ }
41
+ export function readArtifactBlob(db, contentHash) {
42
+ return db.prepare('SELECT * FROM artifact_blobs WHERE content_hash = ?').get(contentHash);
43
+ }
44
+ export function readOrphanArtifactBlobRows(db) {
45
+ return db
46
+ .prepare(`SELECT b.* FROM artifact_blobs b
47
+ WHERE NOT EXISTS (
48
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
49
+ )
50
+ ORDER BY b.created_at ASC`)
51
+ .all();
52
+ }
53
+ export function readLatestSessionID(db) {
54
+ const row = db
55
+ .prepare('SELECT session_id FROM sessions ORDER BY updated_at DESC LIMIT 1')
56
+ .get();
57
+ return row?.session_id;
58
+ }
59
+ export function readSessionStats(db) {
60
+ const sessions = db.prepare('SELECT COUNT(*) AS count FROM sessions').get();
61
+ const messages = db.prepare('SELECT COUNT(*) AS count FROM messages').get();
62
+ const artifacts = db.prepare('SELECT COUNT(*) AS count FROM artifacts').get();
63
+ const summaryNodes = db.prepare('SELECT COUNT(*) AS count FROM summary_nodes').get();
64
+ const blobs = db
65
+ .prepare(`SELECT COUNT(*) AS count, COALESCE(SUM(char_count), 0) AS chars
66
+ FROM artifact_blobs b
67
+ WHERE NOT EXISTS (
68
+ SELECT 1 FROM artifacts a WHERE a.content_hash = b.content_hash
69
+ )`)
70
+ .get();
71
+ return {
72
+ sessionCount: sessions.count,
73
+ messageCount: messages.count,
74
+ artifactCount: artifacts.count,
75
+ summaryNodeCount: summaryNodes.count,
76
+ blobCount: blobs.count,
77
+ orphanBlobCount: blobs.count,
78
+ orphanBlobChars: blobs.chars,
79
+ };
80
+ }
package/dist/store.d.ts CHANGED
@@ -95,11 +95,17 @@ export declare class SqliteLcmStore {
95
95
  private readonly workspaceDirectory;
96
96
  private db?;
97
97
  private dbReadyPromise?;
98
+ private deferredInitTimer?;
99
+ private deferredInitPromise?;
100
+ private deferredInitRequested;
101
+ private activeOperationCount;
98
102
  private readonly pendingPartUpdates;
99
103
  private pendingPartUpdateTimer?;
100
104
  private pendingPartUpdateFlushPromise?;
101
105
  constructor(projectDir: string, options: OpencodeLcmOptions);
102
106
  init(): Promise<void>;
107
+ private withStoreActivity;
108
+ private waitForDeferredInitIfRunning;
103
109
  private prepareForRead;
104
110
  private scheduleDeferredPartUpdateFlush;
105
111
  private clearDeferredPartUpdateTimer;
@@ -111,6 +117,9 @@ export declare class SqliteLcmStore {
111
117
  private ensureDbReady;
112
118
  private openAndInitializeDb;
113
119
  private deferredInitCompleted;
120
+ private runDeferredInit;
121
+ private scheduleDeferredInit;
122
+ private ensureDeferredInitComplete;
114
123
  private readSchemaVersionSync;
115
124
  private assertSupportedSchemaVersionSync;
116
125
  private writeSchemaVersionSync;
@@ -239,6 +248,8 @@ export declare class SqliteLcmStore {
239
248
  buildCompactionContext(sessionID: string): Promise<string | undefined>;
240
249
  transformMessages(messages: ConversationMessage[]): Promise<boolean>;
241
250
  systemHint(): string | undefined;
251
+ private sanitizeSessionMessages;
252
+ private shouldSkipMalformedCapturedEvent;
242
253
  private buildAutomaticRetrievalContext;
243
254
  private buildAutomaticRetrievalQuery;
244
255
  private buildAutomaticRetrievalQueries;
@@ -289,6 +300,7 @@ export declare class SqliteLcmStore {
289
300
  private resolveLineageSync;
290
301
  private applyEvent;
291
302
  private getResumeSync;
303
+ private materializeSessionRow;
292
304
  private readSessionHeaderSync;
293
305
  private clearSessionDataSync;
294
306
  private readChildSessionsSync;