@martian-engineering/lossless-claw 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -115,7 +115,8 @@ Add a `lossless-claw` entry under `plugins.entries` in your OpenClaw config:
115
115
  ],
116
116
  "summaryModel": "anthropic/claude-haiku-4-5",
117
117
  "expansionModel": "anthropic/claude-haiku-4-5",
118
- "delegationTimeoutMs": 300000
118
+ "delegationTimeoutMs": 300000,
119
+ "summaryTimeoutMs": 60000
119
120
  }
120
121
  }
121
122
  }
@@ -123,7 +124,7 @@ Add a `lossless-claw` entry under `plugins.entries` in your OpenClaw config:
123
124
  }
124
125
  ```
125
126
 
126
- `leafChunkTokens` controls how many source tokens can accumulate in a leaf compaction chunk before summarization is triggered. The default is `20000`, but quota-limited summary providers may benefit from a larger value to reduce compaction frequency. `summaryModel` and `summaryProvider` let you pin compaction summarization to a cheaper or faster model than your main OpenClaw session model. `expansionModel` does the same for `lcm_expand_query` sub-agent calls (drilling into summaries to recover detail). `delegationTimeoutMs` controls how long `lcm_expand_query` waits for that delegated sub-agent to finish before returning a timeout error; it defaults to `120000` (120s). When unset, the model settings still fall back to OpenClaw's configured default model/provider. See [Expansion model override requirements](#expansion-model-override-requirements) for the required `subagent` trust policy when using `expansionModel`.
127
+ `leafChunkTokens` controls how many source tokens can accumulate in a leaf compaction chunk before summarization is triggered. The default is `20000`, but quota-limited summary providers may benefit from a larger value to reduce compaction frequency. `summaryModel` and `summaryProvider` let you pin compaction summarization to a cheaper or faster model than your main OpenClaw session model. `expansionModel` does the same for `lcm_expand_query` sub-agent calls (drilling into summaries to recover detail). `delegationTimeoutMs` controls how long `lcm_expand_query` waits for that delegated sub-agent to finish before returning a timeout error; it defaults to `120000` (120s). `summaryTimeoutMs` controls the per-call timeout for model-backed LCM summarization; it defaults to `60000` (60s). When unset, the model settings still fall back to OpenClaw's configured default model/provider. See [Expansion model override requirements](#expansion-model-override-requirements) for the required `subagent` trust policy when using `expansionModel`.
127
128
 
128
129
  ### Environment variables
129
130
 
@@ -154,6 +155,7 @@ Add a `lossless-claw` entry under `plugins.entries` in your OpenClaw config:
154
155
  | `LCM_EXPANSION_MODEL` | *(from OpenClaw)* | Model override for `lcm_expand_query` sub-agent (e.g. `anthropic/claude-haiku-4-5`) |
155
156
  | `LCM_EXPANSION_PROVIDER` | *(from OpenClaw)* | Provider override for `lcm_expand_query` sub-agent |
156
157
  | `LCM_DELEGATION_TIMEOUT_MS` | `120000` | Max time to wait for delegated `lcm_expand_query` sub-agent completion |
158
+ | `LCM_SUMMARY_TIMEOUT_MS` | `60000` | Max time to wait for a single model-backed LCM summarizer call |
157
159
  | `LCM_PRUNE_HEARTBEAT_OK` | `false` | Retroactively delete `HEARTBEAT_OK` turn cycles from LCM storage |
158
160
 
159
161
  ### Expansion model override requirements
@@ -198,6 +200,7 @@ Plugin config equivalents:
198
200
  - `summaryModel`
199
201
  - `summaryProvider`
200
202
  - `delegationTimeoutMs`
203
+ - `summaryTimeoutMs`
201
204
 
202
205
  Environment variables still win over plugin config when both are set.
203
206
 
@@ -84,6 +84,10 @@
84
84
  "label": "Delegation Timeout (ms)",
85
85
  "help": "Maximum time to wait for delegated lcm_expand_query sub-agent completion before timing out"
86
86
  },
87
+ "summaryTimeoutMs": {
88
+ "label": "Summary Timeout (ms)",
89
+ "help": "Maximum time to wait for a single model-backed LCM summarizer call before timing out"
90
+ },
87
91
  "maxAssemblyTokenBudget": {
88
92
  "label": "Max Assembly Token Budget",
89
93
  "help": "Hard ceiling for assembly token budget — caps runtime-provided and fallback budgets. Set for smaller context-window models (e.g., 30000 for 32k models)"
@@ -205,6 +209,10 @@
205
209
  "type": "integer",
206
210
  "minimum": 1
207
211
  },
212
+ "summaryTimeoutMs": {
213
+ "type": "integer",
214
+ "minimum": 1
215
+ },
208
216
  "maxAssemblyTokenBudget": {
209
217
  "type": "integer",
210
218
  "minimum": 1000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martian-engineering/lossless-claw",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Lossless Context Management plugin for OpenClaw — DAG-based conversation summarization with incremental compaction",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/src/compaction.ts CHANGED
@@ -1379,45 +1379,47 @@ export class CompactionEngine {
1379
1379
  const summaryId = generateSummaryId(summary.content);
1380
1380
  const tokenCount = estimateTokens(summary.content);
1381
1381
 
1382
- await this.summaryStore.insertSummary({
1383
- summaryId,
1384
- conversationId,
1385
- kind: "leaf",
1386
- depth: 0,
1387
- content: summary.content,
1388
- tokenCount,
1389
- fileIds,
1390
- earliestAt:
1391
- messageContents.length > 0
1392
- ? new Date(Math.min(...messageContents.map((message) => message.createdAt.getTime())))
1393
- : undefined,
1394
- latestAt:
1395
- messageContents.length > 0
1396
- ? new Date(Math.max(...messageContents.map((message) => message.createdAt.getTime())))
1397
- : undefined,
1398
- descendantCount: 0,
1399
- descendantTokenCount: 0,
1400
- sourceMessageTokenCount: messageContents.reduce(
1401
- (sum, message) => sum + Math.max(0, Math.floor(message.tokenCount)),
1402
- 0,
1403
- ),
1404
- model: summaryModel,
1405
- });
1382
+ await this.summaryStore.withTransaction(async () => {
1383
+ await this.summaryStore.insertSummary({
1384
+ summaryId,
1385
+ conversationId,
1386
+ kind: "leaf",
1387
+ depth: 0,
1388
+ content: summary.content,
1389
+ tokenCount,
1390
+ fileIds,
1391
+ earliestAt:
1392
+ messageContents.length > 0
1393
+ ? new Date(Math.min(...messageContents.map((message) => message.createdAt.getTime())))
1394
+ : undefined,
1395
+ latestAt:
1396
+ messageContents.length > 0
1397
+ ? new Date(Math.max(...messageContents.map((message) => message.createdAt.getTime())))
1398
+ : undefined,
1399
+ descendantCount: 0,
1400
+ descendantTokenCount: 0,
1401
+ sourceMessageTokenCount: messageContents.reduce(
1402
+ (sum, message) => sum + Math.max(0, Math.floor(message.tokenCount)),
1403
+ 0,
1404
+ ),
1405
+ model: summaryModel,
1406
+ });
1406
1407
 
1407
- // Link to source messages
1408
- const messageIds = messageContents.map((m) => m.messageId);
1409
- await this.summaryStore.linkSummaryToMessages(summaryId, messageIds);
1408
+ // Link to source messages before the context swap becomes visible.
1409
+ const messageIds = messageContents.map((m) => m.messageId);
1410
+ await this.summaryStore.linkSummaryToMessages(summaryId, messageIds);
1410
1411
 
1411
- // Replace the message range in context with the new summary
1412
- const ordinals = messageItems.map((ci) => ci.ordinal);
1413
- const startOrdinal = Math.min(...ordinals);
1414
- const endOrdinal = Math.max(...ordinals);
1412
+ // Replace the message range in context with the new summary.
1413
+ const ordinals = messageItems.map((ci) => ci.ordinal);
1414
+ const startOrdinal = Math.min(...ordinals);
1415
+ const endOrdinal = Math.max(...ordinals);
1415
1416
 
1416
- await this.summaryStore.replaceContextRangeWithSummary({
1417
- conversationId,
1418
- startOrdinal,
1419
- endOrdinal,
1420
- summaryId,
1417
+ await this.summaryStore.replaceContextRangeWithSummary({
1418
+ conversationId,
1419
+ startOrdinal,
1420
+ endOrdinal,
1421
+ summaryId,
1422
+ });
1421
1423
  });
1422
1424
 
1423
1425
  return { summaryId, level: summary.level, content: summary.content };
@@ -1487,72 +1489,76 @@ export class CompactionEngine {
1487
1489
  const summaryId = generateSummaryId(condensed.content);
1488
1490
  const tokenCount = estimateTokens(condensed.content);
1489
1491
 
1490
- await this.summaryStore.insertSummary({
1491
- summaryId,
1492
- conversationId,
1493
- kind: "condensed",
1494
- depth: targetDepth + 1,
1495
- content: condensed.content,
1496
- tokenCount,
1497
- fileIds,
1498
- earliestAt:
1499
- summaryRecords.length > 0
1500
- ? new Date(
1501
- Math.min(
1502
- ...summaryRecords.map((summary) =>
1503
- (summary.earliestAt ?? summary.createdAt).getTime(),
1492
+ await this.summaryStore.withTransaction(async () => {
1493
+ await this.summaryStore.insertSummary({
1494
+ summaryId,
1495
+ conversationId,
1496
+ kind: "condensed",
1497
+ depth: targetDepth + 1,
1498
+ content: condensed.content,
1499
+ tokenCount,
1500
+ fileIds,
1501
+ earliestAt:
1502
+ summaryRecords.length > 0
1503
+ ? new Date(
1504
+ Math.min(
1505
+ ...summaryRecords.map((summary) =>
1506
+ (summary.earliestAt ?? summary.createdAt).getTime(),
1507
+ ),
1504
1508
  ),
1505
- ),
1506
- )
1507
- : undefined,
1508
- latestAt:
1509
- summaryRecords.length > 0
1510
- ? new Date(
1511
- Math.max(
1512
- ...summaryRecords.map((summary) => (summary.latestAt ?? summary.createdAt).getTime()),
1513
- ),
1514
- )
1515
- : undefined,
1516
- descendantCount: summaryRecords.reduce((count, summary) => {
1517
- const childDescendants =
1518
- typeof summary.descendantCount === "number" && Number.isFinite(summary.descendantCount)
1519
- ? Math.max(0, Math.floor(summary.descendantCount))
1520
- : 0;
1521
- return count + childDescendants + 1;
1522
- }, 0),
1523
- descendantTokenCount: summaryRecords.reduce((count, summary) => {
1524
- const childDescendantTokens =
1525
- typeof summary.descendantTokenCount === "number" &&
1526
- Number.isFinite(summary.descendantTokenCount)
1527
- ? Math.max(0, Math.floor(summary.descendantTokenCount))
1528
- : 0;
1529
- return count + Math.max(0, Math.floor(summary.tokenCount)) + childDescendantTokens;
1530
- }, 0),
1531
- sourceMessageTokenCount: summaryRecords.reduce((count, summary) => {
1532
- const sourceTokens =
1533
- typeof summary.sourceMessageTokenCount === "number" &&
1534
- Number.isFinite(summary.sourceMessageTokenCount)
1535
- ? Math.max(0, Math.floor(summary.sourceMessageTokenCount))
1536
- : 0;
1537
- return count + sourceTokens;
1538
- }, 0),
1539
- model: summaryModel,
1540
- });
1509
+ )
1510
+ : undefined,
1511
+ latestAt:
1512
+ summaryRecords.length > 0
1513
+ ? new Date(
1514
+ Math.max(
1515
+ ...summaryRecords.map(
1516
+ (summary) => (summary.latestAt ?? summary.createdAt).getTime(),
1517
+ ),
1518
+ ),
1519
+ )
1520
+ : undefined,
1521
+ descendantCount: summaryRecords.reduce((count, summary) => {
1522
+ const childDescendants =
1523
+ typeof summary.descendantCount === "number" && Number.isFinite(summary.descendantCount)
1524
+ ? Math.max(0, Math.floor(summary.descendantCount))
1525
+ : 0;
1526
+ return count + childDescendants + 1;
1527
+ }, 0),
1528
+ descendantTokenCount: summaryRecords.reduce((count, summary) => {
1529
+ const childDescendantTokens =
1530
+ typeof summary.descendantTokenCount === "number" &&
1531
+ Number.isFinite(summary.descendantTokenCount)
1532
+ ? Math.max(0, Math.floor(summary.descendantTokenCount))
1533
+ : 0;
1534
+ return count + Math.max(0, Math.floor(summary.tokenCount)) + childDescendantTokens;
1535
+ }, 0),
1536
+ sourceMessageTokenCount: summaryRecords.reduce((count, summary) => {
1537
+ const sourceTokens =
1538
+ typeof summary.sourceMessageTokenCount === "number" &&
1539
+ Number.isFinite(summary.sourceMessageTokenCount)
1540
+ ? Math.max(0, Math.floor(summary.sourceMessageTokenCount))
1541
+ : 0;
1542
+ return count + sourceTokens;
1543
+ }, 0),
1544
+ model: summaryModel,
1545
+ });
1541
1546
 
1542
- // Link to parent summaries
1543
- const parentSummaryIds = summaryRecords.map((s) => s.summaryId);
1544
- await this.summaryStore.linkSummaryToParents(summaryId, parentSummaryIds);
1547
+ // Link to parent summaries before the context swap becomes visible.
1548
+ const parentSummaryIds = summaryRecords.map((s) => s.summaryId);
1549
+ await this.summaryStore.linkSummaryToParents(summaryId, parentSummaryIds);
1545
1550
 
1546
- // Replace all summary items in context with the condensed summary
1547
- const ordinals = summaryItems.map((ci) => ci.ordinal);
1548
- const startOrdinal = Math.min(...ordinals);
1549
- const endOrdinal = Math.max(...ordinals);
1551
+ // Replace all summary items in context with the condensed summary.
1552
+ const ordinals = summaryItems.map((ci) => ci.ordinal);
1553
+ const startOrdinal = Math.min(...ordinals);
1554
+ const endOrdinal = Math.max(...ordinals);
1550
1555
 
1551
- await this.summaryStore.replaceContextRangeWithSummary({
1552
- conversationId,
1553
- startOrdinal,
1554
- endOrdinal,
1555
- summaryId,
1556
+ await this.summaryStore.replaceContextRangeWithSummary({
1557
+ conversationId,
1558
+ startOrdinal,
1559
+ endOrdinal,
1560
+ summaryId,
1561
+ });
1556
1562
  });
1557
1563
 
1558
1564
  return { summaryId, level: condensed.level };
package/src/db/config.ts CHANGED
@@ -42,6 +42,8 @@ export type LcmConfig = {
42
42
  expansionModel: string;
43
43
  /** Max time to wait for delegated lcm_expand_query sub-agent completion. */
44
44
  delegationTimeoutMs: number;
45
+ /** Max time to wait for a single model-backed LCM summarizer call. */
46
+ summaryTimeoutMs: number;
45
47
  /** IANA timezone for timestamps in summaries (from TZ env or system default) */
46
48
  timezone: string;
47
49
  /** When true, retroactively delete HEARTBEAT_OK turn cycles from LCM storage. */
@@ -219,6 +221,9 @@ export function resolveLcmConfig(
219
221
  expansionModel:
220
222
  env.LCM_EXPANSION_MODEL?.trim() ?? toStr(pc.expansionModel) ?? "",
221
223
  delegationTimeoutMs: envDelegationTimeoutMs ?? toNumber(pc.delegationTimeoutMs) ?? 120000,
224
+ summaryTimeoutMs:
225
+ parseFiniteInt(env.LCM_SUMMARY_TIMEOUT_MS)
226
+ ?? toNumber(pc.summaryTimeoutMs) ?? 60000,
222
227
  timezone: env.TZ ?? toStr(pc.timezone) ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
223
228
  pruneHeartbeatOk:
224
229
  env.LCM_PRUNE_HEARTBEAT_OK !== undefined
package/src/engine.ts CHANGED
@@ -3020,8 +3020,9 @@ export class LcmContextEngine implements ContextEngine {
3020
3020
  };
3021
3021
  }
3022
3022
 
3023
- const useSweep =
3024
- manualCompactionRequested || forceCompaction || params.compactionTarget === "threshold";
3023
+ // Forced budget recovery should use the capped convergence loop so live
3024
+ // overflow counts can drive recovery even when persisted context is already small.
3025
+ const useSweep = manualCompactionRequested || params.compactionTarget === "threshold";
3025
3026
  if (useSweep) {
3026
3027
  const sweepResult = await this.compaction.compactFullSweep({
3027
3028
  conversationId,
@@ -1171,6 +1171,13 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1171
1171
 
1172
1172
  return {
1173
1173
  config,
1174
+ isRuntimeManagedAuthProvider: (provider: string, providerApi?: string) => {
1175
+ const normalizedProvider = normalizeProviderId(provider);
1176
+ if (normalizedProvider === "openai-codex" || normalizedProvider === "github-copilot") {
1177
+ return true;
1178
+ }
1179
+ return shouldOmitTemperatureForApi(providerApi);
1180
+ },
1174
1181
  complete: async ({
1175
1182
  provider,
1176
1183
  model,
@@ -1,4 +1,5 @@
1
1
  import type { DatabaseSync } from "node:sqlite";
2
+ import { withDatabaseTransaction } from "../transaction-mutex.js";
2
3
  import { formatTimestamp } from "../compaction.js";
3
4
  import type { LcmConfig } from "../db/config.js";
4
5
  import type { LcmSummarizeFn } from "../summarize.js";
@@ -139,27 +140,22 @@ export async function applyScopedDoctorRepair(params: {
139
140
  }
140
141
 
141
142
  if (repairedSummaryIds.length > 0) {
142
- params.db.exec("BEGIN IMMEDIATE");
143
- try {
144
- for (const summaryId of repairedSummaryIds) {
145
- const override = overrides.get(summaryId);
146
- if (!override) {
147
- continue;
143
+ await withDatabaseTransaction(params.db, "BEGIN IMMEDIATE", async () => {
144
+ for (const summaryId of repairedSummaryIds) {
145
+ const override = overrides.get(summaryId);
146
+ if (!override) {
147
+ continue;
148
+ }
149
+ params.db
150
+ .prepare(
151
+ `UPDATE summaries
152
+ SET content = ?, token_count = ?
153
+ WHERE summary_id = ?`,
154
+ )
155
+ .run(override.content, override.tokenCount, summaryId);
156
+ updateSummaryFts(params.db, summaryId, override.content);
148
157
  }
149
- params.db
150
- .prepare(
151
- `UPDATE summaries
152
- SET content = ?, token_count = ?
153
- WHERE summary_id = ?`,
154
- )
155
- .run(override.content, override.tokenCount, summaryId);
156
- updateSummaryFts(params.db, summaryId, override.content);
157
- }
158
- params.db.exec("COMMIT");
159
- } catch (error) {
160
- params.db.exec("ROLLBACK");
161
- throw error;
162
- }
158
+ });
163
159
  }
164
160
 
165
161
  return {
@@ -1,5 +1,6 @@
1
1
  import type { DatabaseSync } from "node:sqlite";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { withDatabaseTransaction } from "../transaction-mutex.js";
3
4
  import { sanitizeFts5Query } from "./fts5-sanitize.js";
4
5
  import { buildLikeSearchPlan, containsCjk, createFallbackSnippet } from "./full-text-fallback.js";
5
6
  import { parseUtcTimestamp, parseUtcTimestampOrNull } from "./parse-utc-timestamp.js";
@@ -270,15 +271,7 @@ export class ConversationStore {
270
271
  // ── Transaction helpers ──────────────────────────────────────────────────
271
272
 
272
273
  async withTransaction<T>(operation: () => Promise<T> | T): Promise<T> {
273
- this.db.exec("BEGIN IMMEDIATE");
274
- try {
275
- const result = await operation();
276
- this.db.exec("COMMIT");
277
- return result;
278
- } catch (error) {
279
- this.db.exec("ROLLBACK");
280
- throw error;
281
- }
274
+ return withDatabaseTransaction(this.db, "BEGIN IMMEDIATE", operation);
282
275
  }
283
276
 
284
277
  // ── Conversation operations ───────────────────────────────────────────────
@@ -1,4 +1,5 @@
1
1
  import type { DatabaseSync } from "node:sqlite";
2
+ import { withDatabaseTransaction } from "../transaction-mutex.js";
2
3
  import { sanitizeFts5Query } from "./fts5-sanitize.js";
3
4
  import { buildLikeSearchPlan, containsCjk, createFallbackSnippet } from "./full-text-fallback.js";
4
5
  import { parseUtcTimestamp, parseUtcTimestampOrNull } from "./parse-utc-timestamp.js";
@@ -820,6 +821,11 @@ export class SummaryStore {
820
821
  return rows.map((row) => row.depth);
821
822
  }
822
823
 
824
+ /** Serialize a multi-step summary write sequence on the shared database. */
825
+ async withTransaction<T>(operation: () => Promise<T> | T): Promise<T> {
826
+ return withDatabaseTransaction(this.db, "BEGIN", operation);
827
+ }
828
+
823
829
  async pruneForNewSession(conversationId: number, retainDepth: number): Promise<void> {
824
830
  if (Number.isFinite(retainDepth) && retainDepth < 0) {
825
831
  return;
@@ -919,56 +925,60 @@ export class SummaryStore {
919
925
  endOrdinal: number;
920
926
  summaryId: string;
921
927
  }): Promise<void> {
928
+ await this.withTransaction(() => {
929
+ this.replaceContextRangeWithSummaryInTransaction(input);
930
+ });
931
+ }
932
+
933
+ // Update the context slice in-place while the caller already owns the txn.
934
+ private replaceContextRangeWithSummaryInTransaction(input: {
935
+ conversationId: number;
936
+ startOrdinal: number;
937
+ endOrdinal: number;
938
+ summaryId: string;
939
+ }): void {
922
940
  const { conversationId, startOrdinal, endOrdinal, summaryId } = input;
923
941
 
924
- this.db.exec("BEGIN");
925
- try {
926
- // 1. Delete context items in the range [startOrdinal, endOrdinal]
927
- this.db
928
- .prepare(
929
- `DELETE FROM context_items
942
+ // 1. Delete context items in the range [startOrdinal, endOrdinal]
943
+ this.db
944
+ .prepare(
945
+ `DELETE FROM context_items
930
946
  WHERE conversation_id = ?
931
947
  AND ordinal >= ?
932
948
  AND ordinal <= ?`,
933
- )
934
- .run(conversationId, startOrdinal, endOrdinal);
949
+ )
950
+ .run(conversationId, startOrdinal, endOrdinal);
935
951
 
936
- // 2. Insert the replacement summary item at startOrdinal
937
- this.db
938
- .prepare(
939
- `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id)
952
+ // 2. Insert the replacement summary item at startOrdinal
953
+ this.db
954
+ .prepare(
955
+ `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id)
940
956
  VALUES (?, ?, 'summary', ?)`,
941
- )
942
- .run(conversationId, startOrdinal, summaryId);
957
+ )
958
+ .run(conversationId, startOrdinal, summaryId);
943
959
 
944
- // 3. Resequence all ordinals to maintain contiguity (no gaps).
945
- // Fetch current items, then update ordinals in order.
946
- const items = this.db
947
- .prepare(
948
- `SELECT ordinal FROM context_items
960
+ // 3. Resequence all ordinals to maintain contiguity (no gaps).
961
+ // Fetch current items, then update ordinals in order.
962
+ const items = this.db
963
+ .prepare(
964
+ `SELECT ordinal FROM context_items
949
965
  WHERE conversation_id = ?
950
966
  ORDER BY ordinal`,
951
- )
952
- .all(conversationId) as unknown as { ordinal: number }[];
953
-
954
- const updateStmt = this.db.prepare(
955
- `UPDATE context_items
956
- SET ordinal = ?
957
- WHERE conversation_id = ? AND ordinal = ?`,
958
- );
967
+ )
968
+ .all(conversationId) as unknown as { ordinal: number }[];
959
969
 
960
- // Use negative temp ordinals first to avoid unique constraint conflicts
961
- for (let i = 0; i < items.length; i++) {
962
- updateStmt.run(-(i + 1), conversationId, items[i].ordinal);
963
- }
964
- for (let i = 0; i < items.length; i++) {
965
- updateStmt.run(i, conversationId, -(i + 1));
966
- }
970
+ const updateStmt = this.db.prepare(
971
+ `UPDATE context_items
972
+ SET ordinal = ?
973
+ WHERE conversation_id = ? AND ordinal = ?`,
974
+ );
967
975
 
968
- this.db.exec("COMMIT");
969
- } catch (err) {
970
- this.db.exec("ROLLBACK");
971
- throw err;
976
+ // Use negative temp ordinals first to avoid unique constraint conflicts.
977
+ for (let i = 0; i < items.length; i++) {
978
+ updateStmt.run(-(i + 1), conversationId, items[i].ordinal);
979
+ }
980
+ for (let i = 0; i < items.length; i++) {
981
+ updateStmt.run(i, conversationId, -(i + 1));
972
982
  }
973
983
  }
974
984
 
package/src/summarize.ts CHANGED
@@ -105,7 +105,7 @@ export class LcmProviderAuthError extends Error {
105
105
  * context windows on slower providers, short enough to prevent the gateway
106
106
  * event loop from starving when a provider hangs.
107
107
  */
108
- const SUMMARIZER_TIMEOUT_MS = 60_000;
108
+ const DEFAULT_SUMMARIZER_TIMEOUT_MS = 60_000;
109
109
 
110
110
  /** Error used to distinguish summarizer timeouts from provider failures. */
111
111
  class SummarizerTimeoutError extends Error {
@@ -1136,6 +1136,11 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1136
1136
  ? params.deps.config.leafTargetTokens
1137
1137
  : DEFAULT_LEAF_TARGET_TOKENS;
1138
1138
 
1139
+ const summarizerTimeoutMs =
1140
+ Number.isFinite(params.deps.config.summaryTimeoutMs) && params.deps.config.summaryTimeoutMs > 0
1141
+ ? params.deps.config.summaryTimeoutMs
1142
+ : DEFAULT_SUMMARIZER_TIMEOUT_MS;
1143
+
1139
1144
  const fn: LcmSummarizeFn = async (
1140
1145
  text: string,
1141
1146
  aggressive?: boolean,
@@ -1210,13 +1215,17 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1210
1215
  ],
1211
1216
  maxTokens: targetTokens,
1212
1217
  ...(reasoning ? { reasoning } : {}),
1213
- }), SUMMARIZER_TIMEOUT_MS, label);
1218
+ }), summarizerTimeoutMs, label);
1214
1219
 
1215
1220
  const retryWithoutModelAuth = async (
1216
1221
  failure: ProviderAuthFailure,
1217
1222
  reasoning?: string,
1218
1223
  ): Promise<Awaited<ReturnType<typeof params.deps.complete>>> => {
1219
1224
  const initialAuthError = new LcmProviderAuthError({ provider, model, failure });
1225
+ const runtimeManagedAuth = params.deps.isRuntimeManagedAuthProvider?.(provider, providerApi) === true;
1226
+ if (runtimeManagedAuth) {
1227
+ throw initialAuthError;
1228
+ }
1220
1229
  console.warn(initialAuthError.message);
1221
1230
  console.warn(
1222
1231
  `[lcm] summarizer auth retry: retrying ${provider}/${model} without runtime.modelAuth credentials.`,
@@ -1318,7 +1327,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1318
1327
  const errMsg = err instanceof Error ? err.message : String(err);
1319
1328
  const isTimeout = errMsg.includes("summarizer timeout");
1320
1329
  console.warn(
1321
- `[lcm] summarizer ${isTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${SUMMARIZER_TIMEOUT_MS}ms; error=${errMsg}`,
1330
+ `[lcm] summarizer ${isTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${summarizerTimeoutMs}ms; error=${errMsg}`,
1322
1331
  );
1323
1332
  if (nextCandidate) {
1324
1333
  console.warn(
@@ -1433,12 +1442,12 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1433
1442
  const isRetryTimeout = retryErrMsg.includes("summarizer timeout");
1434
1443
  if (nextCandidate) {
1435
1444
  console.warn(
1436
- `[lcm] retry ${isRetryTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${SUMMARIZER_TIMEOUT_MS}ms; error=${retryErrMsg}; retrying with ${nextCandidate.provider}/${nextCandidate.model}`,
1445
+ `[lcm] retry ${isRetryTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${summarizerTimeoutMs}ms; error=${retryErrMsg}; retrying with ${nextCandidate.provider}/${nextCandidate.model}`,
1437
1446
  );
1438
1447
  continue;
1439
1448
  }
1440
1449
  console.warn(
1441
- `[lcm] retry ${isRetryTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${SUMMARIZER_TIMEOUT_MS}ms; error=${retryErrMsg}; falling back to truncation`,
1450
+ `[lcm] retry ${isRetryTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${summarizerTimeoutMs}ms; error=${retryErrMsg}; falling back to truncation`,
1442
1451
  );
1443
1452
  summary = initialSummary;
1444
1453
  }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Per-database async transaction mutex.
3
+ *
4
+ * Hotfix for https://github.com/Martian-Engineering/lossless-claw/issues/260
5
+ *
6
+ * Problem: Multiple async operations (from different sessions) share one
7
+ * synchronous DatabaseSync handle. SQLite does not support nested transactions.
8
+ * When two async code paths both try to BEGIN while an earlier BEGIN is still
9
+ * in-flight (awaiting async work inside the transaction), the second BEGIN
10
+ * fails with "cannot start a transaction within a transaction".
11
+ *
12
+ * Solution: A per-database async mutex that serializes all explicit transaction
13
+ * entry points. Uses a WeakMap keyed on the DatabaseSync instance so each
14
+ * database gets its own queue, and databases are garbage-collected normally.
15
+ */
16
+
17
+ import { AsyncLocalStorage } from "node:async_hooks";
18
+ import type { DatabaseSync } from "node:sqlite";
19
+
20
+ interface MutexState {
21
+ /** Tail of the promise chain — each acquirer appends to this. */
22
+ tail: Promise<void>;
23
+ }
24
+
25
+ const mutexMap = new WeakMap<DatabaseSync, MutexState>();
26
+ const heldLockContext = new AsyncLocalStorage<Map<DatabaseSync, number>>();
27
+
28
+ let nextSavepointId = 0;
29
+
30
+ function getOrCreateMutex(db: DatabaseSync): MutexState {
31
+ let state = mutexMap.get(db);
32
+ if (!state) {
33
+ state = { tail: Promise.resolve() };
34
+ mutexMap.set(db, state);
35
+ }
36
+ return state;
37
+ }
38
+
39
+ function getHeldLockDepth(db: DatabaseSync): number {
40
+ return heldLockContext.getStore()?.get(db) ?? 0;
41
+ }
42
+
43
+ function nextSavepointName(): string {
44
+ nextSavepointId += 1;
45
+ return `lcm_txn_savepoint_${nextSavepointId}`;
46
+ }
47
+
48
+ /**
49
+ * Acquire exclusive async access to the database for a transaction.
50
+ *
51
+ * Direct lock acquisition is intentionally low-level and non-reentrant.
52
+ * Callers that need nested transaction scopes should use
53
+ * `withDatabaseTransaction()`, which reuses the held lock and isolates nested
54
+ * work with SQLite savepoints.
55
+ *
56
+ * Usage:
57
+ * const release = await acquireTransactionLock(this.db);
58
+ * try {
59
+ * this.db.exec("BEGIN IMMEDIATE");
60
+ * // ... do work ...
61
+ * this.db.exec("COMMIT");
62
+ * } catch (err) {
63
+ * this.db.exec("ROLLBACK");
64
+ * throw err;
65
+ * } finally {
66
+ * release();
67
+ * }
68
+ *
69
+ * Returns a release function that MUST be called in a finally block.
70
+ */
71
+ export function acquireTransactionLock(db: DatabaseSync): Promise<() => void> {
72
+ const mutex = getOrCreateMutex(db);
73
+
74
+ let releaseResolve!: () => void;
75
+ const releasePromise = new Promise<void>((resolve) => {
76
+ releaseResolve = resolve;
77
+ });
78
+
79
+ // Capture the current tail — we wait on it
80
+ const waitOn = mutex.tail;
81
+
82
+ // Advance the tail — next acquirer will wait on our release
83
+ mutex.tail = releasePromise;
84
+
85
+ // Wait for the previous holder to release, then return our release fn
86
+ return waitOn.then(() => releaseResolve);
87
+ }
88
+
89
+ export type BeginTransactionStatement = "BEGIN" | "BEGIN IMMEDIATE";
90
+
91
+ /**
92
+ * Run an operation inside a serialized database transaction.
93
+ *
94
+ * The first scope on an async path acquires the per-database mutex and opens
95
+ * the requested transaction mode. Nested scopes on the same database reuse the
96
+ * held lock and isolate their work with a savepoint instead of hanging.
97
+ */
98
+ export async function withDatabaseTransaction<T>(
99
+ db: DatabaseSync,
100
+ beginStatement: BeginTransactionStatement,
101
+ operation: () => Promise<T> | T,
102
+ ): Promise<T> {
103
+ if (getHeldLockDepth(db) > 0) {
104
+ const savepointName = nextSavepointName();
105
+ db.exec(`SAVEPOINT ${savepointName}`);
106
+ try {
107
+ const result = await operation();
108
+ db.exec(`RELEASE SAVEPOINT ${savepointName}`);
109
+ return result;
110
+ } catch (error) {
111
+ db.exec(`ROLLBACK TO SAVEPOINT ${savepointName}`);
112
+ db.exec(`RELEASE SAVEPOINT ${savepointName}`);
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ const release = await acquireTransactionLock(db);
118
+ try {
119
+ const heldLocks = new Map(heldLockContext.getStore() ?? []);
120
+ heldLocks.set(db, (heldLocks.get(db) ?? 0) + 1);
121
+
122
+ return await heldLockContext.run(heldLocks, async () => {
123
+ db.exec(beginStatement);
124
+ try {
125
+ const result = await operation();
126
+ db.exec("COMMIT");
127
+ return result;
128
+ } catch (error) {
129
+ db.exec("ROLLBACK");
130
+ throw error;
131
+ }
132
+ });
133
+ } finally {
134
+ release();
135
+ }
136
+ }
package/src/types.ts CHANGED
@@ -108,6 +108,9 @@ export interface LcmDependencies {
108
108
  /** LLM completion function for summarization */
109
109
  complete: CompleteFn;
110
110
 
111
+ /** Whether a provider uses runtime-managed OAuth / auth profiles instead of direct API keys. */
112
+ isRuntimeManagedAuthProvider?: (provider: string, providerApi?: string) => boolean;
113
+
111
114
  /** Gateway RPC call function (for subagent spawning, session ops) */
112
115
  callGateway: CallGatewayFn;
113
116