opencode-sessions-explorer 0.1.2 → 0.1.4

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/plugin.js CHANGED
@@ -12889,8 +12889,8 @@ function decodeModel(modelStr) {
12889
12889
  }
12890
12890
 
12891
12891
  // src/lib/export.ts
12892
- import { mkdirSync, existsSync as existsSync2, renameSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "fs";
12893
- import { join as join2 } from "path";
12892
+ import { mkdirSync, existsSync as existsSync5, renameSync as renameSync2, writeFileSync as writeFileSync3, unlinkSync as unlinkSync3, readdirSync as readdirSync2 } from "fs";
12893
+ import { join as join5 } from "path";
12894
12894
  import { homedir as homedir2 } from "os";
12895
12895
 
12896
12896
  // src/lib/channel.ts
@@ -12978,50 +12978,506 @@ function looksLikeExactIdentifier(q) {
12978
12978
  return /\b(?:[A-Z][A-Z0-9_]+-\d+|ses_[A-Za-z0-9_-]+|msg_[A-Za-z0-9_-]+|prt_[A-Za-z0-9_-]+)\b/.test(q) || /https?:\/\/\S+/.test(q) || /(?:\/[\w .@-]+){2,}/.test(q);
12979
12979
  }
12980
12980
 
12981
- // src/lib/export.ts
12982
- var DEFAULT_EXPORT_ROOT = join2(homedir2(), ".local/share/opencode-sessions-explorer");
12983
- var BODY_CAP_BYTES = 256 * 1024;
12984
- var SAFETY_PART_CAP_BYTES = 50 * 1024 * 1024;
12985
- var CHANNEL_COMPLETE_MARKER = ".channels_v1_complete";
12981
+ // src/lib/export-constants.ts
12986
12982
  var SEARCHABLE_TYPES = ["text", "reasoning", "tool", "file", "patch", "subtask"];
12987
- var PART_CHANNELS = CHANNELS.filter((c) => c !== "session-summary" && c !== "raw");
12988
- function exportRoot() {
12989
- return process.env.OPENCODE_SESSIONS_EXPLORER_EXPORT_ROOT || DEFAULT_EXPORT_ROOT;
12990
- }
12991
- function ensureRoot(root = exportRoot()) {
12992
- mkdirSync(join2(root, "by-session"), { recursive: true });
12993
- return root;
12994
- }
12995
- function channelExportComplete(root = exportRoot()) {
12996
- return existsSync2(join2(root, CHANNEL_COMPLETE_MARKER));
12983
+
12984
+ // src/lib/export-lock.ts
12985
+ import { closeSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
12986
+ import { hostname as hostname3 } from "os";
12987
+ import { join as join2 } from "path";
12988
+ import { randomUUID } from "crypto";
12989
+ var LOCK_FILE = ".export.lock";
12990
+ var DEFAULT_STALE_MS = 2 * 60 * 1000;
12991
+ var HEARTBEAT_MS = 15000;
12992
+ function acquireExportLock(root, staleMs = DEFAULT_STALE_MS) {
12993
+ const path = join2(root, LOCK_FILE);
12994
+ const first = tryCreateLock(path);
12995
+ if (first)
12996
+ return first;
12997
+ const stale = staleCandidate(path, staleMs);
12998
+ if (!stale)
12999
+ return null;
13000
+ if (!removeStaleLock(path, stale, staleMs))
13001
+ return tryCreateLock(path);
13002
+ return tryCreateLock(path);
12997
13003
  }
12998
- var CURSOR_SCHEMA = "v2";
12999
- function getLastSync(root = exportRoot()) {
13000
- const p = join2(root, ".last_sync");
13001
- if (!existsSync2(p))
13004
+ function tryCreateLock(path) {
13005
+ let fd = null;
13006
+ try {
13007
+ const token = randomUUID();
13008
+ const now = Date.now();
13009
+ const record2 = { token, pid: process.pid, hostname: hostname3(), created_at: now, updated_at: now };
13010
+ fd = openSync(path, "wx");
13011
+ writeFileSync(fd, JSON.stringify(record2));
13012
+ closeSync(fd);
13013
+ fd = null;
13014
+ let lastHeartbeat = now;
13015
+ return {
13016
+ token,
13017
+ release: () => releaseLock(path, token),
13018
+ heartbeat: () => {
13019
+ const current = Date.now();
13020
+ if (current - lastHeartbeat < HEARTBEAT_MS)
13021
+ return;
13022
+ lastHeartbeat = current;
13023
+ heartbeatLock(path, token, current);
13024
+ }
13025
+ };
13026
+ } catch {
13027
+ if (fd != null)
13028
+ try {
13029
+ closeSync(fd);
13030
+ } catch {}
13002
13031
  return null;
13032
+ }
13033
+ }
13034
+ function staleCandidate(path, staleMs) {
13003
13035
  try {
13004
- const raw = readFileSync(p, "utf8").trim();
13005
- if (!raw)
13036
+ const stat = statSync(path);
13037
+ const now = Date.now();
13038
+ if (now - stat.mtimeMs <= staleMs)
13039
+ return null;
13040
+ const parsed = readLockRecord(path);
13041
+ if (!parsed)
13042
+ return { token: null, updatedAt: null, mtimeMs: stat.mtimeMs };
13043
+ if (now - parsed.updated_at <= staleMs)
13006
13044
  return null;
13007
- if (raw.startsWith(`${CURSOR_SCHEMA} `)) {
13008
- const [tsStr, id] = raw.slice(CURSOR_SCHEMA.length + 1).split(":");
13009
- const ts = Number(tsStr);
13010
- if (!Number.isFinite(ts) || !id)
13011
- return null;
13012
- return { ts, id };
13045
+ if (isLiveLocalOwner(parsed))
13046
+ return null;
13047
+ return { token: parsed.token, updatedAt: parsed.updated_at, mtimeMs: stat.mtimeMs };
13048
+ } catch {
13049
+ return { token: null, updatedAt: null, mtimeMs: 0 };
13050
+ }
13051
+ }
13052
+ function removeStaleLock(path, stale, staleMs) {
13053
+ try {
13054
+ const stat = statSync(path);
13055
+ if (stat.mtimeMs !== stale.mtimeMs)
13056
+ return false;
13057
+ if (stale.token) {
13058
+ const current = readLockRecord(path);
13059
+ if (current?.token !== stale.token)
13060
+ return false;
13061
+ if (current.updated_at !== stale.updatedAt)
13062
+ return false;
13063
+ const now = Date.now();
13064
+ if (now - current.updated_at <= staleMs)
13065
+ return false;
13066
+ if (now - stat.mtimeMs <= staleMs)
13067
+ return false;
13068
+ if (isLiveLocalOwner(current))
13069
+ return false;
13070
+ } else {
13071
+ if (Date.now() - stat.mtimeMs <= staleMs)
13072
+ return false;
13013
13073
  }
13014
- return null;
13074
+ unlinkSync(path);
13075
+ return true;
13076
+ } catch {
13077
+ return false;
13078
+ }
13079
+ }
13080
+ function releaseLock(path, token) {
13081
+ try {
13082
+ const current = readLockRecord(path);
13083
+ if (current?.token === token)
13084
+ unlinkSync(path);
13085
+ } catch {}
13086
+ }
13087
+ function heartbeatLock(path, token, now) {
13088
+ try {
13089
+ const current = readLockRecord(path);
13090
+ if (current?.token !== token)
13091
+ return;
13092
+ writeFileSync(path, JSON.stringify({ ...current, updated_at: now }));
13093
+ } catch {}
13094
+ }
13095
+ function readLockRecord(path) {
13096
+ try {
13097
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
13098
+ if (typeof parsed.token !== "string")
13099
+ return null;
13100
+ if (typeof parsed.pid !== "number" || !Number.isFinite(parsed.pid))
13101
+ return null;
13102
+ if (typeof parsed.hostname !== "string")
13103
+ return null;
13104
+ if (typeof parsed.created_at !== "number" || !Number.isFinite(parsed.created_at))
13105
+ return null;
13106
+ const updated = typeof parsed.updated_at === "number" && Number.isFinite(parsed.updated_at) ? parsed.updated_at : parsed.created_at;
13107
+ return { token: parsed.token, pid: parsed.pid, hostname: parsed.hostname, created_at: parsed.created_at, updated_at: updated };
13015
13108
  } catch {
13016
13109
  return null;
13017
13110
  }
13018
13111
  }
13019
- function setLastSync(c, root = exportRoot()) {
13020
- const p = join2(root, ".last_sync");
13112
+ function isLiveLocalOwner(record2) {
13113
+ if (record2.hostname !== hostname3())
13114
+ return false;
13115
+ if (!Number.isSafeInteger(record2.pid) || record2.pid <= 0)
13116
+ return false;
13117
+ try {
13118
+ process.kill(record2.pid, 0);
13119
+ return true;
13120
+ } catch (error45) {
13121
+ return error45?.code === "EPERM";
13122
+ }
13123
+ }
13124
+
13125
+ // src/lib/export-tombstones.ts
13126
+ import { existsSync as existsSync2, readdirSync, rmSync, statSync as statSync2, unlinkSync as unlinkSync2 } from "fs";
13127
+ import { join as join3 } from "path";
13128
+ var PART_FILE_RE = /^(?:\d{5}-)?(prt_[A-Za-z0-9_-]+)\.txt$/;
13129
+ function reconcileTombstones(root, heartbeat = () => {}) {
13130
+ const progress = {
13131
+ scanned_sessions: 0,
13132
+ removed_sessions: 0,
13133
+ removed_parts: 0,
13134
+ removed_channel_sessions: 0
13135
+ };
13136
+ const bySession = join3(root, "by-session");
13137
+ const sessions = loadSessionIds();
13138
+ if (existsSync2(bySession)) {
13139
+ for (const entry of safeReadDir(bySession)) {
13140
+ const dir = join3(bySession, entry);
13141
+ if (!isDirectory(dir))
13142
+ continue;
13143
+ if (!sessions.has(entry)) {
13144
+ rmSync(dir, { recursive: true, force: true });
13145
+ removeChannelSessionDirs(root, entry);
13146
+ progress.removed_sessions++;
13147
+ heartbeat();
13148
+ continue;
13149
+ }
13150
+ progress.scanned_sessions++;
13151
+ progress.removed_parts += removeOrphanPartFiles(root, entry, dir, heartbeat);
13152
+ heartbeat();
13153
+ }
13154
+ }
13155
+ progress.removed_channel_sessions += removeOrphanChannelSessions(root, sessions, heartbeat);
13156
+ return progress;
13157
+ }
13158
+ function removeOrphanPartFiles(root, sessionId, dir, heartbeat) {
13159
+ const livePartIds = loadSearchablePartIds(sessionId);
13160
+ let removed = 0;
13161
+ for (const file2 of safeReadDir(dir)) {
13162
+ heartbeat();
13163
+ const partId = partIdFromFile(file2);
13164
+ if (!partId || livePartIds.has(partId))
13165
+ continue;
13166
+ try {
13167
+ unlinkSync2(join3(dir, file2));
13168
+ removeChannelPartFiles(root, sessionId, partId);
13169
+ removed++;
13170
+ } catch {}
13171
+ }
13172
+ return removed;
13173
+ }
13174
+ function removeOrphanChannelSessions(root, sessions, heartbeat) {
13175
+ let removed = 0;
13176
+ for (const channel of CHANNELS) {
13177
+ const base = join3(root, "by-channel", channel, "by-session");
13178
+ if (!existsSync2(base))
13179
+ continue;
13180
+ for (const sessionId of safeReadDir(base)) {
13181
+ heartbeat();
13182
+ const dir = join3(base, sessionId);
13183
+ if (!isDirectory(dir) || sessions.has(sessionId))
13184
+ continue;
13185
+ rmSync(dir, { recursive: true, force: true });
13186
+ removed++;
13187
+ }
13188
+ }
13189
+ return removed;
13190
+ }
13191
+ function removeChannelSessionDirs(root, sessionId) {
13192
+ for (const channel of CHANNELS) {
13193
+ rmSync(channelDir(root, channel, sessionId), { recursive: true, force: true });
13194
+ }
13195
+ }
13196
+ function removeChannelPartFiles(root, sessionId, partId) {
13197
+ for (const channel of CHANNELS) {
13198
+ const dir = channelDir(root, channel, sessionId);
13199
+ if (!existsSync2(dir))
13200
+ continue;
13201
+ for (const file2 of safeReadDir(dir)) {
13202
+ if (file2 === `${partId}.txt` || file2.endsWith(`-${partId}.txt`)) {
13203
+ try {
13204
+ unlinkSync2(join3(dir, file2));
13205
+ } catch {}
13206
+ }
13207
+ }
13208
+ }
13209
+ }
13210
+ function channelDir(root, channel, sessionId) {
13211
+ return join3(root, "by-channel", channel, "by-session", sessionId);
13212
+ }
13213
+ function loadSessionIds() {
13214
+ const rows = stmt(`SELECT id FROM session`).all();
13215
+ return new Set(rows.map((row) => row.id));
13216
+ }
13217
+ function loadSearchablePartIds(sessionId) {
13218
+ const placeholders = SEARCHABLE_TYPES.map(() => "?").join(",");
13219
+ const rows = stmt(`
13220
+ SELECT id
13221
+ FROM part
13222
+ WHERE session_id = ?
13223
+ AND json_extract(data,'$.type') IN (${placeholders})
13224
+ ORDER BY id ASC`).all(sessionId, ...SEARCHABLE_TYPES);
13225
+ return new Set(rows.map((row) => row.id));
13226
+ }
13227
+ function safeReadDir(dir) {
13228
+ try {
13229
+ return readdirSync(dir);
13230
+ } catch {
13231
+ return [];
13232
+ }
13233
+ }
13234
+ function isDirectory(path) {
13235
+ try {
13236
+ return statSync2(path).isDirectory();
13237
+ } catch {
13238
+ return false;
13239
+ }
13240
+ }
13241
+ function partIdFromFile(file2) {
13242
+ const match = PART_FILE_RE.exec(file2);
13243
+ return match ? match[1] : null;
13244
+ }
13245
+
13246
+ // src/lib/export-background.ts
13247
+ import { existsSync as existsSync3 } from "fs";
13248
+ import { fileURLToPath } from "url";
13249
+ var DEFAULT_MIN_INTERVAL_MS = 5 * 60 * 1000;
13250
+ var inFlight = false;
13251
+ var lastStartedAt = 0;
13252
+ function scheduleBackgroundReconcile(opts) {
13253
+ const now = Date.now();
13254
+ const minIntervalMs = opts.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
13255
+ if (inFlight)
13256
+ return { scheduled: false, reason: "already_running" };
13257
+ if (now - lastStartedAt < minIntervalMs)
13258
+ return { scheduled: false, reason: "throttled" };
13259
+ const url2 = resolveWorkerUrl();
13260
+ try {
13261
+ const worker = new Worker(url2, { type: "module" });
13262
+ inFlight = true;
13263
+ lastStartedAt = now;
13264
+ const cleanup = () => {
13265
+ inFlight = false;
13266
+ worker.terminate();
13267
+ };
13268
+ worker.addEventListener("message", cleanup, { once: true });
13269
+ worker.addEventListener("error", cleanup, { once: true });
13270
+ const maybeUnref = worker;
13271
+ maybeUnref.unref?.();
13272
+ const request = { root: opts.root, batchSize: opts.batchSize ?? 2000 };
13273
+ worker.postMessage(request);
13274
+ return { scheduled: true };
13275
+ } catch {
13276
+ inFlight = false;
13277
+ return { scheduled: false, reason: "worker_unavailable" };
13278
+ }
13279
+ }
13280
+ function resolveWorkerUrl() {
13281
+ const js = new URL("./export-reconcile-worker.js", import.meta.url);
13282
+ if (existsSync3(fileURLToPath(js)))
13283
+ return js;
13284
+ const ts = new URL("./export-reconcile-worker.ts", import.meta.url);
13285
+ if (existsSync3(fileURLToPath(ts)))
13286
+ return ts;
13287
+ const bundled = new URL("./lib/export-reconcile-worker.js", import.meta.url);
13288
+ if (existsSync3(fileURLToPath(bundled)))
13289
+ return bundled;
13290
+ return js;
13291
+ }
13292
+
13293
+ // src/lib/export-state.ts
13294
+ import { existsSync as existsSync4, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "fs";
13295
+ import { join as join4 } from "path";
13296
+ var CURSOR_SCHEMA = "v3";
13297
+ var LAST_SYNC_FILE = ".last_sync";
13298
+ function freshSyncState(migratedFrom, legacyCursor = null) {
13299
+ return {
13300
+ schema: CURSOR_SCHEMA,
13301
+ insert_cursor: { id: "" },
13302
+ session_cursor: null,
13303
+ session_dirty_hints: {},
13304
+ reconcile_watermark: null,
13305
+ failed_parts: {},
13306
+ dead_letters: {},
13307
+ last_reconcile_at: null,
13308
+ legacy_cursor: legacyCursor,
13309
+ migrated_from: migratedFrom
13310
+ };
13311
+ }
13312
+ function getSyncState(root) {
13313
+ const p = join4(root, LAST_SYNC_FILE);
13314
+ if (!existsSync4(p))
13315
+ return freshSyncState();
13316
+ try {
13317
+ return parseSyncState(readFileSync2(p, "utf8"));
13318
+ } catch {
13319
+ return freshSyncState("unreadable");
13320
+ }
13321
+ }
13322
+ function setSyncState(state, root) {
13323
+ const p = join4(root, LAST_SYNC_FILE);
13021
13324
  const tmp = p + ".tmp";
13022
- writeFileSync(tmp, `${CURSOR_SCHEMA} ${c.ts}:${c.id}`);
13325
+ writeFileSync2(tmp, `${CURSOR_SCHEMA} ${JSON.stringify(normalizeSyncState(state))}`);
13023
13326
  renameSync(tmp, p);
13024
13327
  }
13328
+ function getLastSync(root) {
13329
+ const p = join4(root, LAST_SYNC_FILE);
13330
+ if (!existsSync4(p))
13331
+ return null;
13332
+ const state = getSyncState(root);
13333
+ if (state.legacy_cursor)
13334
+ return state.legacy_cursor;
13335
+ if (!state.insert_cursor.id)
13336
+ return null;
13337
+ return { ts: 0, id: state.insert_cursor.id };
13338
+ }
13339
+ function parseSyncState(rawInput) {
13340
+ const raw = rawInput.trim();
13341
+ if (!raw)
13342
+ return freshSyncState("empty");
13343
+ if (raw.startsWith(`${CURSOR_SCHEMA} `)) {
13344
+ const parsed = JSON.parse(raw.slice(CURSOR_SCHEMA.length + 1));
13345
+ return normalizeSyncState(parsed);
13346
+ }
13347
+ if (raw.startsWith("{")) {
13348
+ return normalizeSyncState(JSON.parse(raw));
13349
+ }
13350
+ if (raw.startsWith("v2 ")) {
13351
+ return freshSyncState("v2", parseLegacyCursor(raw.slice(3)));
13352
+ }
13353
+ const legacy = parseLegacyCursor(raw);
13354
+ return freshSyncState(legacy ? "v1" : "unknown", legacy);
13355
+ }
13356
+ function normalizeSyncState(input) {
13357
+ if (!isRecord(input))
13358
+ return freshSyncState("invalid");
13359
+ const state = freshSyncState(asString(input.migrated_from) ?? undefined, cursorOrNull(input.legacy_cursor));
13360
+ const insert = isRecord(input.insert_cursor) ? input.insert_cursor : null;
13361
+ state.insert_cursor.id = asString(insert?.id) ?? "";
13362
+ state.session_cursor = cursorOrNull(input.session_cursor);
13363
+ state.session_dirty_hints = dirtyHints(input.session_dirty_hints);
13364
+ state.reconcile_watermark = reconcileWatermark(input.reconcile_watermark);
13365
+ state.failed_parts = failedParts(input.failed_parts);
13366
+ state.dead_letters = failedParts(input.dead_letters);
13367
+ state.last_reconcile_at = finiteOrNull(input.last_reconcile_at);
13368
+ return state;
13369
+ }
13370
+ function parseLegacyCursor(raw) {
13371
+ const idx = raw.indexOf(":");
13372
+ if (idx <= 0)
13373
+ return null;
13374
+ const ts = Number(raw.slice(0, idx));
13375
+ const id = raw.slice(idx + 1);
13376
+ if (!Number.isFinite(ts) || !id)
13377
+ return null;
13378
+ return { ts, id };
13379
+ }
13380
+ function cursorOrNull(value) {
13381
+ if (!isRecord(value))
13382
+ return null;
13383
+ const ts = finiteOrNull(value.ts);
13384
+ const id = asString(value.id);
13385
+ if (ts == null || !id)
13386
+ return null;
13387
+ return { ts, id };
13388
+ }
13389
+ function dirtyHints(value) {
13390
+ if (!isRecord(value))
13391
+ return {};
13392
+ const out = {};
13393
+ for (const [id, raw] of Object.entries(value)) {
13394
+ if (!id)
13395
+ continue;
13396
+ if (typeof raw === "number" && Number.isFinite(raw)) {
13397
+ out[id] = { time_updated: raw, part_cursor: null };
13398
+ continue;
13399
+ }
13400
+ if (!isRecord(raw))
13401
+ continue;
13402
+ const timeUpdated = finiteOrNull(raw.time_updated);
13403
+ if (timeUpdated == null)
13404
+ continue;
13405
+ out[id] = { time_updated: timeUpdated, part_cursor: asString(raw.part_cursor) };
13406
+ }
13407
+ return out;
13408
+ }
13409
+ function reconcileWatermark(value) {
13410
+ if (!isRecord(value))
13411
+ return null;
13412
+ const at = finiteOrNull(value.at);
13413
+ if (at == null)
13414
+ return null;
13415
+ return {
13416
+ part_id: asString(value.part_id),
13417
+ session_id: asString(value.session_id),
13418
+ at
13419
+ };
13420
+ }
13421
+ function failedParts(value) {
13422
+ if (!isRecord(value))
13423
+ return {};
13424
+ const out = {};
13425
+ for (const [id, raw] of Object.entries(value)) {
13426
+ if (!id || !isRecord(raw))
13427
+ continue;
13428
+ const attempts = finiteOrNull(raw.attempts);
13429
+ const firstFailedAt = finiteOrNull(raw.first_failed_at);
13430
+ const lastFailedAt = finiteOrNull(raw.last_failed_at);
13431
+ if (attempts == null || firstFailedAt == null || lastFailedAt == null)
13432
+ continue;
13433
+ out[id] = {
13434
+ id,
13435
+ attempts,
13436
+ first_failed_at: firstFailedAt,
13437
+ last_failed_at: lastFailedAt,
13438
+ last_error: asString(raw.last_error) ?? "unknown export failure"
13439
+ };
13440
+ }
13441
+ return out;
13442
+ }
13443
+ function finiteOrNull(value) {
13444
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
13445
+ }
13446
+ function asString(value) {
13447
+ return typeof value === "string" && value.length > 0 ? value : null;
13448
+ }
13449
+ function isRecord(value) {
13450
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13451
+ }
13452
+
13453
+ // src/lib/export.ts
13454
+ var DEFAULT_EXPORT_ROOT = join5(homedir2(), ".local/share/opencode-sessions-explorer");
13455
+ var BODY_CAP_BYTES = 256 * 1024;
13456
+ var SAFETY_PART_CAP_BYTES = 50 * 1024 * 1024;
13457
+ var CHANNEL_COMPLETE_MARKER = ".channels_v1_complete";
13458
+ var INSERT_REWIND_MS = 3 * 60 * 1000;
13459
+ var INSERT_REWIND_MAX_ROWS = 512;
13460
+ var MAX_FAILED_ATTEMPTS = 5;
13461
+ var PART_CHANNELS = CHANNELS.filter((c) => c !== "session-summary" && c !== "raw");
13462
+ function exportRoot() {
13463
+ return process.env.OPENCODE_SESSIONS_EXPLORER_EXPORT_ROOT || DEFAULT_EXPORT_ROOT;
13464
+ }
13465
+ function getSyncState2(root = exportRoot()) {
13466
+ return getSyncState(root);
13467
+ }
13468
+ function setSyncState2(state, root = exportRoot()) {
13469
+ setSyncState(state, root);
13470
+ }
13471
+ function getLastSync2(root = exportRoot()) {
13472
+ return getLastSync(root);
13473
+ }
13474
+ function ensureRoot(root = exportRoot()) {
13475
+ mkdirSync(join5(root, "by-session"), { recursive: true });
13476
+ return root;
13477
+ }
13478
+ function channelExportComplete(root = exportRoot()) {
13479
+ return existsSync5(join5(root, CHANNEL_COMPLETE_MARKER));
13480
+ }
13025
13481
  var sessionCache = new Map;
13026
13482
  function getSession(id) {
13027
13483
  const cached2 = sessionCache.get(id);
@@ -13263,46 +13719,46 @@ function writeMeta(s, dir) {
13263
13719
  time_updated: s.time_updated,
13264
13720
  archived: s.time_archived != null
13265
13721
  };
13266
- const p = join2(dir, "meta.json");
13722
+ const p = join5(dir, "meta.json");
13267
13723
  const tmp = p + ".tmp";
13268
- writeFileSync(tmp, JSON.stringify(meta, null, 2));
13269
- renameSync(tmp, p);
13724
+ writeFileSync3(tmp, JSON.stringify(meta, null, 2));
13725
+ renameSync2(tmp, p);
13270
13726
  }
13271
13727
  function writePartFile(dir, filename, content) {
13272
- const p = join2(dir, filename);
13273
- const tmp = join2(dir, "." + filename + ".tmp");
13274
- writeFileSync(tmp, content);
13275
- renameSync(tmp, p);
13728
+ const p = join5(dir, filename);
13729
+ const tmp = join5(dir, "." + filename + ".tmp");
13730
+ writeFileSync3(tmp, content);
13731
+ renameSync2(tmp, p);
13276
13732
  const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(filename);
13277
13733
  if (!m)
13278
13734
  return;
13279
13735
  const myPartId = m[2];
13280
13736
  try {
13281
- for (const f of readdirSync(dir)) {
13737
+ for (const f of readdirSync2(dir)) {
13282
13738
  if (f === filename || !f.endsWith(".txt") || f.startsWith("."))
13283
13739
  continue;
13284
13740
  const fm = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
13285
13741
  if (fm && fm[2] === myPartId) {
13286
13742
  try {
13287
- unlinkSync(join2(dir, f));
13743
+ unlinkSync3(join5(dir, f));
13288
13744
  } catch {}
13289
13745
  }
13290
13746
  }
13291
13747
  } catch {}
13292
13748
  }
13293
- function channelDir(root, channel, sessionId) {
13294
- return join2(root, "by-channel", channel, "by-session", sessionId);
13749
+ function channelDir2(root, channel, sessionId) {
13750
+ return join5(root, "by-channel", channel, "by-session", sessionId);
13295
13751
  }
13296
13752
  function deleteChannelPartFiles(root, sessionId, partId) {
13297
13753
  for (const ch of PART_CHANNELS) {
13298
- const dir = channelDir(root, ch, sessionId);
13299
- if (!existsSync2(dir))
13754
+ const dir = channelDir2(root, ch, sessionId);
13755
+ if (!existsSync5(dir))
13300
13756
  continue;
13301
13757
  try {
13302
- for (const f of readdirSync(dir)) {
13758
+ for (const f of readdirSync2(dir)) {
13303
13759
  if (f === `${partId}.txt` || f.endsWith(`-${partId}.txt`)) {
13304
13760
  try {
13305
- unlinkSync(join2(dir, f));
13761
+ unlinkSync3(join5(dir, f));
13306
13762
  } catch {}
13307
13763
  }
13308
13764
  }
@@ -13312,20 +13768,20 @@ function deleteChannelPartFiles(root, sessionId, partId) {
13312
13768
  function writeChannelFiles(root, sessionId, filename, partId, docs) {
13313
13769
  deleteChannelPartFiles(root, sessionId, partId);
13314
13770
  for (const doc2 of docs) {
13315
- const dir = channelDir(root, doc2.channel, sessionId);
13316
- if (!existsSync2(dir))
13771
+ const dir = channelDir2(root, doc2.channel, sessionId);
13772
+ if (!existsSync5(dir))
13317
13773
  mkdirSync(dir, { recursive: true });
13318
13774
  writePartFile(dir, filename, doc2.content);
13319
13775
  }
13320
13776
  }
13321
13777
  function writeSessionSummaryChannel(s, dirRoot = exportRoot()) {
13322
- const dir = channelDir(dirRoot, "session-summary", s.id);
13323
- if (!existsSync2(dir))
13778
+ const dir = channelDir2(dirRoot, "session-summary", s.id);
13779
+ if (!existsSync5(dir))
13324
13780
  mkdirSync(dir, { recursive: true });
13325
- const p = join2(dir, "summary.txt");
13781
+ const p = join5(dir, "summary.txt");
13326
13782
  const tmp = p + ".tmp";
13327
- writeFileSync(tmp, buildSessionSummaryDocument(s));
13328
- renameSync(tmp, p);
13783
+ writeFileSync3(tmp, buildSessionSummaryDocument(s));
13784
+ renameSync2(tmp, p);
13329
13785
  }
13330
13786
  function buildSessionSummaryDocument(s) {
13331
13787
  const firstPrompt = firstUserPrompt(s.id, "ASC");
@@ -13379,7 +13835,7 @@ function getFileIndex(sessionId, dir) {
13379
13835
  return idx;
13380
13836
  idx = { nextSeq: 1, byPartId: new Map };
13381
13837
  try {
13382
- const files = readdirSync(dir).filter((f) => f.endsWith(".txt") && !f.startsWith("."));
13838
+ const files = readdirSync2(dir).filter((f) => f.endsWith(".txt") && !f.startsWith("."));
13383
13839
  let max = 0;
13384
13840
  for (const f of files) {
13385
13841
  const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
@@ -13400,112 +13856,324 @@ function getFileIndex(sessionId, dir) {
13400
13856
  }
13401
13857
  async function runExport(opts = {}) {
13402
13858
  const root = ensureRoot(opts.root ?? exportRoot());
13403
- const cursor = opts.fromCursor !== undefined ? opts.fromCursor : getLastSync(root);
13404
13859
  const batchSize = opts.batchSize ?? 1000;
13405
- const start = Date.now();
13406
- const progress = { exported: 0, inserts: 0, updates: 0, skipped_nontext: 0, skipped_oversize: 0, failed: 0, last_cursor: cursor };
13407
- let where = "";
13408
- const params = [];
13409
- if (cursor) {
13410
- where = "WHERE (p.time_updated > ? OR (p.time_updated = ? AND p.id > ?))";
13411
- params.push(cursor.ts, cursor.ts, cursor.id);
13412
- }
13413
- let updates = 0;
13414
- let inserts = 0;
13415
- const touchedSessions = new Set;
13416
- while (true) {
13417
- if (opts.budgetMs && Date.now() - start > opts.budgetMs)
13860
+ const progress = emptyProgress(getLastSync2(root));
13861
+ const lock = acquireExportLock(root);
13862
+ if (!lock) {
13863
+ progress.lock_skipped = true;
13864
+ return progress;
13865
+ }
13866
+ try {
13867
+ const state = getSyncState2(root);
13868
+ applyCursorOverride(state, opts.fromCursor);
13869
+ const start = Date.now();
13870
+ const touchedSessions = new Set;
13871
+ retryFailedParts(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
13872
+ runInsertFastPath(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
13873
+ runSessionDirtyFastPath(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
13874
+ refreshTouchedSessions(root, touchedSessions);
13875
+ if (!opts.budgetMs) {
13876
+ lock.heartbeat();
13877
+ const tombstones = reconcileTombstones(root, lock.heartbeat);
13878
+ applyTombstoneProgress(progress, tombstones);
13879
+ state.last_reconcile_at = Date.now();
13880
+ state.reconcile_watermark = {
13881
+ part_id: state.insert_cursor.id || null,
13882
+ session_id: state.session_cursor?.id ?? null,
13883
+ at: state.last_reconcile_at
13884
+ };
13885
+ }
13886
+ progress.last_cursor = state.legacy_cursor;
13887
+ setSyncState2(state, root);
13888
+ } finally {
13889
+ lock.release();
13890
+ }
13891
+ if (opts.budgetMs && !opts.skipBackgroundReconcile) {
13892
+ scheduleBackgroundReconcile({ root });
13893
+ }
13894
+ return progress;
13895
+ }
13896
+ function emptyProgress(cursor) {
13897
+ return {
13898
+ exported: 0,
13899
+ inserts: 0,
13900
+ updates: 0,
13901
+ skipped_nontext: 0,
13902
+ skipped_oversize: 0,
13903
+ failed: 0,
13904
+ retried: 0,
13905
+ dead_lettered: 0,
13906
+ tombstones_removed_parts: 0,
13907
+ tombstones_removed_sessions: 0,
13908
+ lock_skipped: false,
13909
+ last_cursor: cursor
13910
+ };
13911
+ }
13912
+ function applyCursorOverride(state, cursor) {
13913
+ if (cursor === undefined)
13914
+ return;
13915
+ state.legacy_cursor = cursor;
13916
+ state.insert_cursor.id = cursor?.id ?? "";
13917
+ state.session_cursor = cursor && cursor.ts > 0 ? cursor : null;
13918
+ state.session_dirty_hints = {};
13919
+ }
13920
+ function retryFailedParts(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
13921
+ const ids = Object.keys(state.failed_parts).sort().slice(0, batchSize);
13922
+ for (const id of ids) {
13923
+ if (timeExceeded(start, budgetMs))
13418
13924
  break;
13419
- const rows = stmt(`
13420
- SELECT p.id, p.session_id, p.message_id, p.time_created, p.time_updated, p.data, LENGTH(p.data) AS data_bytes,
13421
- json_extract(m.data,'$.role') AS role
13422
- FROM part p
13423
- LEFT JOIN message m ON m.id = p.message_id
13424
- ${where}
13425
- ORDER BY p.time_updated ASC, p.id ASC
13426
- LIMIT ?`).all(...params, batchSize);
13925
+ progress.retried++;
13926
+ const row = loadPartById(id);
13927
+ if (!row) {
13928
+ clearPartFailure(state, id);
13929
+ continue;
13930
+ }
13931
+ exportPartRow(root, state, row, progress, touchedSessions);
13932
+ reportProgress(progress, onProgress, heartbeat);
13933
+ }
13934
+ }
13935
+ function runInsertFastPath(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
13936
+ const recentSafeRows = [];
13937
+ let scanCursor = state.insert_cursor.id;
13938
+ while (!timeExceeded(start, budgetMs)) {
13939
+ const rows = loadPartRowsAfterId(scanCursor, batchSize);
13427
13940
  if (rows.length === 0)
13428
13941
  break;
13429
- for (const r of rows) {
13430
- if (opts.budgetMs && Date.now() - start > opts.budgetMs)
13942
+ for (const row of rows) {
13943
+ if (timeExceeded(start, budgetMs))
13944
+ break;
13945
+ scanCursor = row.id;
13946
+ const safe = exportPartRow(root, state, row, progress, touchedSessions);
13947
+ if (safe)
13948
+ rememberSafeRow(recentSafeRows, row);
13949
+ reportProgress(progress, onProgress, heartbeat);
13950
+ }
13951
+ if (rows.length < batchSize)
13952
+ break;
13953
+ }
13954
+ if (recentSafeRows.length > 0) {
13955
+ state.insert_cursor.id = chooseInsertCursor(state.insert_cursor.id, recentSafeRows, budgetMs !== undefined);
13956
+ }
13957
+ }
13958
+ function runSessionDirtyFastPath(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
13959
+ scanDirtySessionHints(state, start, budgetMs, Math.min(batchSize, 500));
13960
+ for (const [sessionId, hint] of sortedDirtyHints(state.session_dirty_hints)) {
13961
+ while (!timeExceeded(start, budgetMs)) {
13962
+ const rows = loadSessionPartRows(sessionId, hint.part_cursor, batchSize);
13963
+ if (rows.length === 0) {
13964
+ delete state.session_dirty_hints[sessionId];
13965
+ break;
13966
+ }
13967
+ for (const row of rows) {
13968
+ if (timeExceeded(start, budgetMs))
13969
+ break;
13970
+ hint.part_cursor = row.id;
13971
+ exportPartRow(root, state, row, progress, touchedSessions);
13972
+ reportProgress(progress, onProgress, heartbeat);
13973
+ }
13974
+ if (rows.length < batchSize) {
13975
+ delete state.session_dirty_hints[sessionId];
13431
13976
  break;
13432
- if (r.data_bytes > SAFETY_PART_CAP_BYTES) {
13433
- progress.skipped_oversize++;
13434
- } else {
13435
- try {
13436
- const s = getSession(r.session_id);
13437
- if (!s) {
13438
- progress.failed++;
13439
- continue;
13440
- }
13441
- const built = buildPartFile(r.id, r.session_id, r.message_id, r.data, s.time_archived != null);
13442
- if (!built) {
13443
- progress.skipped_nontext++;
13444
- continue;
13445
- }
13446
- const channelDocs = buildChannelDocuments(r.id, r.session_id, r.message_id, r.data, s.time_archived != null, r.role ?? null, s.directory);
13447
- const dir = join2(root, "by-session", r.session_id);
13448
- if (!existsSync2(dir)) {
13449
- mkdirSync(dir, { recursive: true });
13450
- writeMeta(s, dir);
13451
- }
13452
- const idx = getFileIndex(r.session_id, dir);
13453
- const existing = idx.byPartId.get(r.id);
13454
- if (existing) {
13455
- writePartFile(dir, existing, built.content);
13456
- updates++;
13457
- } else {
13458
- const seq = idx.nextSeq++;
13459
- const filename2 = safePartFilename(seq, r.id);
13460
- writePartFile(dir, filename2, built.content);
13461
- idx.byPartId.set(r.id, filename2);
13462
- inserts++;
13463
- }
13464
- const filename = idx.byPartId.get(r.id);
13465
- if (filename)
13466
- writeChannelFiles(root, r.session_id, filename, r.id, channelDocs);
13467
- touchedSessions.add(r.session_id);
13468
- progress.exported++;
13469
- } catch {
13470
- progress.failed++;
13471
- }
13472
13977
  }
13473
- progress.last_cursor = { ts: r.time_updated, id: r.id };
13474
13978
  }
13475
- const last = rows[rows.length - 1];
13476
- where = "WHERE (p.time_updated > ? OR (p.time_updated = ? AND p.id > ?))";
13477
- params.length = 0;
13478
- params.push(last.time_updated, last.time_updated, last.id);
13479
- if (opts.onProgress && progress.exported % 5000 === 0)
13480
- opts.onProgress(progress);
13481
- if (progress.last_cursor && progress.exported > 0 && progress.exported % 5000 === 0) {
13482
- setLastSync(progress.last_cursor, root);
13979
+ if (timeExceeded(start, budgetMs))
13980
+ break;
13981
+ }
13982
+ }
13983
+ function scanDirtySessionHints(state, start, budgetMs, limit) {
13984
+ while (!timeExceeded(start, budgetMs)) {
13985
+ const rows = loadDirtySessionsAfter(state.session_cursor, limit);
13986
+ if (rows.length === 0)
13987
+ break;
13988
+ for (const row of rows) {
13989
+ state.session_dirty_hints[row.id] = { time_updated: row.time_updated, part_cursor: null };
13990
+ state.session_cursor = { ts: row.time_updated, id: row.id };
13483
13991
  }
13992
+ if (rows.length < limit)
13993
+ break;
13484
13994
  }
13995
+ }
13996
+ function exportPartRow(root, state, row, progress, touchedSessions) {
13997
+ if (row.data_bytes > SAFETY_PART_CAP_BYTES) {
13998
+ removeExistingPartExport(root, row.session_id, row.id);
13999
+ progress.skipped_oversize++;
14000
+ markSafeCursor(state, progress, row);
14001
+ clearPartFailure(state, row.id);
14002
+ return true;
14003
+ }
14004
+ try {
14005
+ const session = getSession(row.session_id);
14006
+ if (!session)
14007
+ throw new Error(`missing session ${row.session_id}`);
14008
+ const archived = session.time_archived != null;
14009
+ const built = buildPartFile(row.id, row.session_id, row.message_id, row.data, archived);
14010
+ if (!built) {
14011
+ removeExistingPartExport(root, row.session_id, row.id);
14012
+ progress.skipped_nontext++;
14013
+ markSafeCursor(state, progress, row);
14014
+ clearPartFailure(state, row.id);
14015
+ return true;
14016
+ }
14017
+ const channelDocs = buildChannelDocuments(row.id, row.session_id, row.message_id, row.data, archived, row.role, session.directory);
14018
+ const dir = join5(root, "by-session", row.session_id);
14019
+ if (!existsSync5(dir)) {
14020
+ mkdirSync(dir, { recursive: true });
14021
+ writeMeta(session, dir);
14022
+ }
14023
+ const idx = getFileIndex(row.session_id, dir);
14024
+ const existing = idx.byPartId.get(row.id);
14025
+ if (existing) {
14026
+ writePartFile(dir, existing, built.content);
14027
+ progress.updates++;
14028
+ } else {
14029
+ const filename2 = safePartFilename(idx.nextSeq++, row.id);
14030
+ writePartFile(dir, filename2, built.content);
14031
+ idx.byPartId.set(row.id, filename2);
14032
+ progress.inserts++;
14033
+ }
14034
+ const filename = idx.byPartId.get(row.id);
14035
+ if (filename)
14036
+ writeChannelFiles(root, row.session_id, filename, row.id, channelDocs);
14037
+ touchedSessions.add(row.session_id);
14038
+ progress.exported++;
14039
+ markSafeCursor(state, progress, row);
14040
+ clearPartFailure(state, row.id);
14041
+ return true;
14042
+ } catch (error45) {
14043
+ progress.failed++;
14044
+ markPartFailure(state, row.id, errorMessage(error45), progress);
14045
+ return false;
14046
+ }
14047
+ }
14048
+ function removeExistingPartExport(root, sessionId, partId) {
14049
+ const dir = join5(root, "by-session", sessionId);
14050
+ try {
14051
+ if (existsSync5(dir)) {
14052
+ const idx = getFileIndex(sessionId, dir);
14053
+ const existing = idx.byPartId.get(partId);
14054
+ if (existing)
14055
+ unlinkSync3(join5(dir, existing));
14056
+ idx.byPartId.delete(partId);
14057
+ }
14058
+ deleteChannelPartFiles(root, sessionId, partId);
14059
+ } catch {}
14060
+ }
14061
+ function markSafeCursor(state, progress, row) {
14062
+ const cursor = { ts: row.time_updated, id: row.id };
14063
+ state.legacy_cursor = cursor;
14064
+ progress.last_cursor = cursor;
14065
+ }
14066
+ function markPartFailure(state, partId, message, progress) {
14067
+ if (state.dead_letters[partId])
14068
+ return;
14069
+ const now = Date.now();
14070
+ const existing = state.failed_parts[partId];
14071
+ const failure = {
14072
+ id: partId,
14073
+ attempts: (existing?.attempts ?? 0) + 1,
14074
+ first_failed_at: existing?.first_failed_at ?? now,
14075
+ last_failed_at: now,
14076
+ last_error: message
14077
+ };
14078
+ if (failure.attempts >= MAX_FAILED_ATTEMPTS) {
14079
+ state.dead_letters[partId] = failure;
14080
+ delete state.failed_parts[partId];
14081
+ progress.dead_lettered++;
14082
+ } else {
14083
+ state.failed_parts[partId] = failure;
14084
+ }
14085
+ }
14086
+ function clearPartFailure(state, partId) {
14087
+ delete state.failed_parts[partId];
14088
+ }
14089
+ function rememberSafeRow(rows, row) {
14090
+ rows.push(row);
14091
+ const maxRows = INSERT_REWIND_MAX_ROWS * 4;
14092
+ if (rows.length > maxRows)
14093
+ rows.splice(0, rows.length - maxRows);
14094
+ }
14095
+ function chooseInsertCursor(previousId, rows, useRewind) {
14096
+ const last = rows[rows.length - 1];
14097
+ if (!last || !useRewind || rows.length <= INSERT_REWIND_MAX_ROWS)
14098
+ return last?.id ?? previousId;
14099
+ const maxCreated = rows.reduce((max, row) => Math.max(max, row.time_created), 0);
14100
+ const cutoff = maxCreated - INSERT_REWIND_MS;
14101
+ const timeIndex = rows.findIndex((row) => row.time_created >= cutoff);
14102
+ const rewindIndex = Math.max(timeIndex <= 0 ? rows.length - INSERT_REWIND_MAX_ROWS : timeIndex - 1, rows.length - INSERT_REWIND_MAX_ROWS);
14103
+ return rows[Math.max(0, rewindIndex)]?.id ?? last.id;
14104
+ }
14105
+ function sortedDirtyHints(hints) {
14106
+ return Object.entries(hints).sort((a, b) => a[1].time_updated - b[1].time_updated || a[0].localeCompare(b[0]));
14107
+ }
14108
+ function refreshTouchedSessions(root, touchedSessions) {
13485
14109
  for (const sid of touchedSessions) {
13486
- const s = getSession(sid);
13487
- if (s) {
13488
- const dir = join2(root, "by-session", sid);
13489
- const fresh = stmt(`
13490
- SELECT id, title, project_id, directory, agent, model, cost,
13491
- time_created, time_updated, time_archived, parent_id
13492
- FROM session WHERE id = ?`).get(sid);
13493
- if (fresh)
13494
- writeMeta(fresh, dir);
13495
- if (fresh)
13496
- writeSessionSummaryChannel(fresh, root);
13497
- }
13498
- }
13499
- if (progress.last_cursor)
13500
- setLastSync(progress.last_cursor, root);
13501
- progress.updates = updates;
13502
- progress.inserts = inserts;
13503
- return progress;
14110
+ const dir = join5(root, "by-session", sid);
14111
+ const fresh = stmt(`
14112
+ SELECT id, title, project_id, directory, agent, model, cost,
14113
+ time_created, time_updated, time_archived, parent_id
14114
+ FROM session WHERE id = ?`).get(sid);
14115
+ if (fresh) {
14116
+ writeMeta(fresh, dir);
14117
+ writeSessionSummaryChannel(fresh, root);
14118
+ }
14119
+ }
14120
+ }
14121
+ function applyTombstoneProgress(progress, tombstones) {
14122
+ progress.tombstones_removed_parts = tombstones.removed_parts;
14123
+ progress.tombstones_removed_sessions = tombstones.removed_sessions;
14124
+ }
14125
+ function loadPartById(partId) {
14126
+ return stmt(partSelectSql("WHERE p.id = ?")).get(partId);
14127
+ }
14128
+ function loadPartRowsAfterId(afterId, limit) {
14129
+ if (!afterId)
14130
+ return stmt(`${partSelectSql("")} ORDER BY p.id ASC LIMIT ?`).all(limit);
14131
+ return stmt(`${partSelectSql("WHERE p.id > ?")} ORDER BY p.id ASC LIMIT ?`).all(afterId, limit);
14132
+ }
14133
+ function loadSessionPartRows(sessionId, afterId, limit) {
14134
+ if (!afterId) {
14135
+ return stmt(`${partSelectSql("WHERE p.session_id = ?")} ORDER BY p.id ASC LIMIT ?`).all(sessionId, limit);
14136
+ }
14137
+ return stmt(`${partSelectSql("WHERE p.session_id = ? AND p.id > ?")} ORDER BY p.id ASC LIMIT ?`).all(sessionId, afterId, limit);
14138
+ }
14139
+ function loadDirtySessionsAfter(cursor, limit) {
14140
+ if (!cursor) {
14141
+ return stmt(`SELECT id, time_updated FROM session ORDER BY time_updated ASC, id ASC LIMIT ?`).all(limit);
14142
+ }
14143
+ return stmt(`
14144
+ SELECT id, time_updated
14145
+ FROM session
14146
+ WHERE (time_updated > ? OR (time_updated = ? AND id > ?))
14147
+ ORDER BY time_updated ASC, id ASC
14148
+ LIMIT ?`).all(cursor.ts, cursor.ts, cursor.id, limit);
14149
+ }
14150
+ function partSelectSql(where) {
14151
+ return `
14152
+ SELECT p.id, p.session_id, p.message_id, p.time_created, p.time_updated,
14153
+ p.data, LENGTH(p.data) AS data_bytes,
14154
+ json_extract(m.data,'$.role') AS role
14155
+ FROM part p
14156
+ LEFT JOIN message m ON m.id = p.message_id
14157
+ ${where}`;
14158
+ }
14159
+ function timeExceeded(start, budgetMs) {
14160
+ return budgetMs !== undefined && Date.now() - start > budgetMs;
14161
+ }
14162
+ function reportProgress(progress, onProgress, heartbeat) {
14163
+ heartbeat();
14164
+ if (!onProgress)
14165
+ return;
14166
+ const processed = progress.exported + progress.skipped_nontext + progress.skipped_oversize + progress.failed;
14167
+ if (processed > 0 && processed % 5000 === 0)
14168
+ onProgress(progress);
14169
+ }
14170
+ function errorMessage(error45) {
14171
+ return error45 instanceof Error ? error45.message : String(error45);
13504
14172
  }
13505
14173
 
13506
14174
  // src/tools/current-session.ts
13507
- import { existsSync as existsSync3 } from "fs";
13508
- import { dirname as dirname2, join as join3 } from "path";
14175
+ import { existsSync as existsSync6 } from "fs";
14176
+ import { dirname, join as join6 } from "path";
13509
14177
  var currentSession = tool({
13510
14178
  description: "opencode-sessions-explorer: identify the CURRENT OpenCode session you (the assistant) are running in \u2014 return this session's id, message id, agent, model, directory, parent_id, cost-so-far, message/part/tool-call counters, immediate child sessions, and useful filesystem paths (opencode.db, the opencode-sessions-explorer tree, this session's export subdirectory and meta.json). " + `Answers: "what session am I in", "what's my current session id", "tell me about this session", "who am I (which agent/model)", "where am I (directory/worktree)", "my session context", "this session's metadata", "first-call orientation", "self-introspection", "what's the OpenCode db path", "where is the export tree", "what's my session id so I can pass it to other opencode-sessions-explorer-* tools". ` + "Useful as the FIRST opencode-sessions-explorer-* call when you need to know your own context \u2014 e.g. to then feed your own session_id into session-timeline, session-genealogy, grep-session, or to compare against siblings via list-sessions / search-sessions-meta. " + "Default detail is compact to avoid noise. Pass detail:'full' for counters, child sessions, paths, and suggestions. " + "Takes no args. Cap 8 KB.",
13511
14179
  args: {
@@ -13520,8 +14188,8 @@ var currentSession = tool({
13520
14188
  const directory = ctx?.directory ?? "";
13521
14189
  const worktree = ctx?.worktree ?? "";
13522
14190
  const root = exportRoot();
13523
- const sessionDir = sessionId ? join3(root, "by-session", sessionId) : null;
13524
- const sessionMeta = sessionDir ? join3(sessionDir, "meta.json") : null;
14191
+ const sessionDir = sessionId ? join6(root, "by-session", sessionId) : null;
14192
+ const sessionMeta = sessionDir ? join6(sessionDir, "meta.json") : null;
13525
14193
  const dbPath = safe(() => locateDb());
13526
14194
  let sessionRow = null;
13527
14195
  if (sessionId) {
@@ -13613,10 +14281,10 @@ var currentSession = tool({
13613
14281
  db: dbPath ?? null,
13614
14282
  export_root: root,
13615
14283
  this_session_export_dir: sessionDir,
13616
- this_session_export_dir_exists: sessionDir ? existsSync3(sessionDir) : false,
14284
+ this_session_export_dir_exists: sessionDir ? existsSync6(sessionDir) : false,
13617
14285
  this_session_meta_json: sessionMeta,
13618
- this_session_meta_json_exists: sessionMeta ? existsSync3(sessionMeta) : false,
13619
- tool_output_dir: dbPath ? join3(dirname2(dbPath), "tool-output") : null
14286
+ this_session_meta_json_exists: sessionMeta ? existsSync6(sessionMeta) : false,
14287
+ tool_output_dir: dbPath ? join6(dirname(dbPath), "tool-output") : null
13620
14288
  },
13621
14289
  suggestions
13622
14290
  };
@@ -13859,7 +14527,7 @@ import { resolve as resolve2 } from "path";
13859
14527
 
13860
14528
  // src/lib/path-guard.ts
13861
14529
  import { homedir as homedir3, platform as platform2 } from "os";
13862
- import { resolve, join as join4 } from "path";
14530
+ import { resolve, join as join7 } from "path";
13863
14531
  import { realpathSync } from "fs";
13864
14532
  function defaultToolOutputDir() {
13865
14533
  if (process.env.OPENCODE_SESSIONS_EXPLORER_TOOL_OUTPUT_DIR) {
@@ -13868,13 +14536,13 @@ function defaultToolOutputDir() {
13868
14536
  const home = homedir3();
13869
14537
  switch (platform2()) {
13870
14538
  case "win32": {
13871
- const local = process.env.LOCALAPPDATA ?? join4(home, "AppData", "Local");
14539
+ const local = process.env.LOCALAPPDATA ?? join7(home, "AppData", "Local");
13872
14540
  return resolve(local, "opencode", "tool-output");
13873
14541
  }
13874
14542
  case "darwin":
13875
14543
  case "linux":
13876
14544
  default: {
13877
- const dataHome = process.env.XDG_DATA_HOME ?? join4(home, ".local", "share");
14545
+ const dataHome = process.env.XDG_DATA_HOME ?? join7(home, ".local", "share");
13878
14546
  return resolve(dataHome, "opencode", "tool-output");
13879
14547
  }
13880
14548
  }
@@ -14084,11 +14752,10 @@ var getSession2 = tool({
14084
14752
 
14085
14753
  // src/lib/ck.ts
14086
14754
  import { spawn } from "child_process";
14087
- import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
14755
+ import { existsSync as existsSync7, readFileSync as readFileSync3, statSync as statSync3 } from "fs";
14088
14756
  function defaultCkCandidates() {
14089
14757
  const home = process.env.HOME ?? "";
14090
14758
  const candidates = [
14091
- process.env.OPENCODE_SESSIONS_EXPLORER_CK_BIN,
14092
14759
  home ? `${home}/.cargo/bin/ck` : null,
14093
14760
  "/usr/local/bin/ck",
14094
14761
  "/opt/homebrew/bin/ck",
@@ -14097,8 +14764,10 @@ function defaultCkCandidates() {
14097
14764
  return candidates;
14098
14765
  }
14099
14766
  function locateCk() {
14767
+ if (process.env.OPENCODE_SESSIONS_EXPLORER_CK_BIN)
14768
+ return process.env.OPENCODE_SESSIONS_EXPLORER_CK_BIN;
14100
14769
  for (const c of defaultCkCandidates()) {
14101
- if (c.startsWith("/") && existsSync4(c))
14770
+ if (c.startsWith("/") && existsSync7(c))
14102
14771
  return c;
14103
14772
  }
14104
14773
  return "ck";
@@ -14160,6 +14829,7 @@ async function runCk(opts) {
14160
14829
  let timedOut = false;
14161
14830
  proc.stdout.setEncoding("utf8");
14162
14831
  proc.stderr.setEncoding("utf8");
14832
+ let procError = null;
14163
14833
  proc.stdout.on("data", (chunk) => {
14164
14834
  buf += chunk;
14165
14835
  let idx;
@@ -14181,7 +14851,8 @@ async function runCk(opts) {
14181
14851
  });
14182
14852
  const rc = await new Promise((resolve3) => {
14183
14853
  proc.on("error", (e) => {
14184
- if (e?.name === "AbortError" || ctl.signal.aborted) {
14854
+ procError = e;
14855
+ if (e.name === "AbortError" || ctl.signal.aborted) {
14185
14856
  timedOut = true;
14186
14857
  resolve3(124);
14187
14858
  } else
@@ -14191,6 +14862,12 @@ async function runCk(opts) {
14191
14862
  });
14192
14863
  if (timer)
14193
14864
  clearTimeout(timer);
14865
+ const childError = procError;
14866
+ if (childError && !timedOut) {
14867
+ if (childError.code === "ENOENT")
14868
+ throw new SessionsError("CK_NOT_FOUND", `ck CLI not found at '${locateCk()}'; install via 'cargo install ck-search'`);
14869
+ throw new SessionsError("CK_FAILED", `ck process failed: ${childError.message}`);
14870
+ }
14194
14871
  if (buf.trim()) {
14195
14872
  try {
14196
14873
  const obj = JSON.parse(buf.trim());
@@ -14198,7 +14875,22 @@ async function runCk(opts) {
14198
14875
  hits.push(obj);
14199
14876
  } catch {}
14200
14877
  }
14201
- return { hits, rc, stderr, durationMs: Date.now() - start, timedOut };
14878
+ const totalScopes = opts.scopes.length === 0 ? 1 : opts.scopes.length;
14879
+ return {
14880
+ hits,
14881
+ rc,
14882
+ stderr,
14883
+ durationMs: Date.now() - start,
14884
+ timedOut,
14885
+ scopeCoverage: {
14886
+ strategy: "single",
14887
+ searched_scopes: totalScopes,
14888
+ total_scopes: totalScopes,
14889
+ omitted_scopes: 0,
14890
+ truncated: false,
14891
+ timed_out: timedOut
14892
+ }
14893
+ };
14202
14894
  }
14203
14895
  async function runCkMultiScope(opts) {
14204
14896
  const start = Date.now();
@@ -14206,6 +14898,7 @@ async function runCkMultiScope(opts) {
14206
14898
  let stderr = "";
14207
14899
  let rc = 1;
14208
14900
  let timedOut = false;
14901
+ let searchedScopes = 0;
14209
14902
  const topk = opts.topk ?? 50;
14210
14903
  const perScopeTopk = Math.max(5, Math.ceil(topk / Math.max(1, opts.scopes.length)));
14211
14904
  for (const scope of opts.scopes) {
@@ -14216,6 +14909,7 @@ async function runCkMultiScope(opts) {
14216
14909
  break;
14217
14910
  }
14218
14911
  const res = await runCk({ ...opts, scopes: [scope], timeoutMs: remaining, topk: perScopeTopk });
14912
+ searchedScopes++;
14219
14913
  hits.push(...res.hits);
14220
14914
  if (res.stderr)
14221
14915
  stderr += (stderr && !stderr.endsWith(`
@@ -14227,30 +14921,211 @@ async function runCkMultiScope(opts) {
14227
14921
  rc = 0;
14228
14922
  else if (rc !== 0 && res.rc !== 1)
14229
14923
  rc = res.rc;
14924
+ if (timedOut && opts.timeoutMs != null && Date.now() - start >= opts.timeoutMs)
14925
+ break;
14230
14926
  }
14927
+ const truncated = searchedScopes < opts.scopes.length;
14928
+ if (truncated && timedOut && rc === 1)
14929
+ rc = 124;
14231
14930
  return {
14232
14931
  hits: hits.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, topk),
14233
14932
  rc,
14234
14933
  stderr,
14235
14934
  durationMs: Date.now() - start,
14236
- timedOut
14935
+ timedOut,
14936
+ scopeCoverage: {
14937
+ strategy: "fanout",
14938
+ searched_scopes: searchedScopes,
14939
+ total_scopes: opts.scopes.length,
14940
+ omitted_scopes: Math.max(0, opts.scopes.length - searchedScopes),
14941
+ truncated,
14942
+ timed_out: timedOut
14943
+ }
14237
14944
  };
14238
14945
  }
14239
- function ckIndexPresent(root = exportRoot()) {
14946
+ async function ckIndexFreshness(root = exportRoot(), timeoutMs = 1500) {
14240
14947
  const manifestPath = `${root}/.ck/manifest.json`;
14241
- if (!existsSync4(manifestPath))
14242
- return { present: false, embedded_chunks: null };
14948
+ const markerMs = exportMarkerMtime(root);
14949
+ const manifest = readManifestProbe(manifestPath);
14950
+ if (!manifest.present) {
14951
+ return {
14952
+ status: "missing",
14953
+ present: false,
14954
+ embedded_chunks: null,
14955
+ index_updated_ms: null,
14956
+ export_marker_ms: markerMs,
14957
+ status_json_available: false,
14958
+ source: "missing",
14959
+ warning: ckIndexWarning("missing", root)
14960
+ };
14961
+ }
14962
+ const statusJson = await readStatusJson(root, timeoutMs);
14963
+ const statusProbe = statusJson.ok ? probeFromUnknown(statusJson.value) : null;
14964
+ const embedded = statusProbe?.embedded_chunks ?? manifest.embedded_chunks;
14965
+ const indexUpdated = statusProbe?.index_updated_ms ?? manifest.index_updated_ms;
14966
+ let status = statusFromProbe(statusProbe?.status, embedded, indexUpdated, markerMs);
14967
+ const source = statusProbe ? "status-json" : "manifest";
14968
+ if (!statusJson.ok && status === "fresh")
14969
+ status = "partial";
14970
+ const warning = status === "fresh" ? null : ckIndexWarning(status, root, statusJson.ok ? null : statusJson.reason);
14971
+ return {
14972
+ status,
14973
+ present: true,
14974
+ embedded_chunks: embedded,
14975
+ index_updated_ms: indexUpdated,
14976
+ export_marker_ms: markerMs,
14977
+ status_json_available: statusJson.ok,
14978
+ source,
14979
+ warning
14980
+ };
14981
+ }
14982
+ function statusFromProbe(explicit, embeddedChunks, indexUpdatedMs, exportMarkerMs) {
14983
+ if (explicit === "missing" || explicit === "stale" || explicit === "partial")
14984
+ return explicit;
14985
+ if (embeddedChunks != null && embeddedChunks <= 0)
14986
+ return "partial";
14987
+ if (indexUpdatedMs == null)
14988
+ return "partial";
14989
+ if (exportMarkerMs != null && indexUpdatedMs + 1000 < exportMarkerMs)
14990
+ return "stale";
14991
+ return "fresh";
14992
+ }
14993
+ function ckIndexWarning(status, root, probeFailure) {
14994
+ const prewarm = `opencode-sessions-explorer will not call ck --index or ck --reindex inline; run 'cd "${root}" && ck --index .' or 'ck --reindex .' only to prewarm or troubleshoot.`;
14995
+ if (status === "missing")
14996
+ return `ck semantic index is missing at ${root}/.ck. ck will lazily create or update the index during sem/hybrid search; the first run may be slow. ${prewarm}`;
14997
+ if (status === "stale")
14998
+ return `ck semantic index appears stale relative to the export tree; ck will attempt a lazy refresh during sem/hybrid search. Results may be partial if refresh fails or times out. ${prewarm}`;
14999
+ const reason = probeFailure ? ` (${probeFailure})` : "";
15000
+ return `ck semantic index freshness is partial/unverified${reason}; ck will attempt lazy index refresh during sem/hybrid search. Results may cover only indexed files if refresh fails or times out. ${prewarm}`;
15001
+ }
15002
+ function exportMarkerMtime(root) {
15003
+ const markers = [".last_sync", ".channels_v1_complete"];
15004
+ let newest = null;
15005
+ for (const marker of markers) {
15006
+ const path = `${root}/${marker}`;
15007
+ if (!existsSync7(path))
15008
+ continue;
15009
+ try {
15010
+ const mtime = statSync3(path).mtimeMs;
15011
+ newest = newest == null ? mtime : Math.max(newest, mtime);
15012
+ } catch {}
15013
+ }
15014
+ return newest;
15015
+ }
15016
+ function readManifestProbe(manifestPath) {
15017
+ if (!existsSync7(manifestPath))
15018
+ return { present: false, embedded_chunks: null, index_updated_ms: null };
14243
15019
  try {
14244
- const m = JSON.parse(readFileSync2(manifestPath, "utf8"));
14245
- return { present: true, embedded_chunks: m?.totals?.embedded_chunks ?? null };
15020
+ return { present: true, ...probeFromUnknown(JSON.parse(readFileSync3(manifestPath, "utf8"))) };
14246
15021
  } catch {
14247
- return { present: true, embedded_chunks: null };
15022
+ return { present: true, embedded_chunks: null, index_updated_ms: null, status: "partial" };
15023
+ }
15024
+ }
15025
+ function probeFromUnknown(value) {
15026
+ return {
15027
+ embedded_chunks: firstNumber(value, [["totals", "embedded_chunks"], ["embedded_chunks"], ["index", "embedded_chunks"]]),
15028
+ index_updated_ms: firstTimeMs(value, [["index_updated"], ["index_updated_ms"], ["indexed_at"], ["updated_at"], ["last_indexed"], ["manifest", "index_updated"]]),
15029
+ status: firstStatus(value)
15030
+ };
15031
+ }
15032
+ function firstStatus(value) {
15033
+ const raw = firstString(value, [["status"], ["index_status"], ["semantic_status"], ["index", "status"]])?.toLowerCase();
15034
+ if (raw === "fresh" || raw === "stale" || raw === "missing" || raw === "partial")
15035
+ return raw;
15036
+ return null;
15037
+ }
15038
+ function firstNumber(value, paths) {
15039
+ for (const path of paths) {
15040
+ const n = numberFromUnknown(valueAtPath(value, path));
15041
+ if (n != null)
15042
+ return n;
15043
+ }
15044
+ return null;
15045
+ }
15046
+ function firstTimeMs(value, paths) {
15047
+ for (const path of paths) {
15048
+ const ms = timeMsFromUnknown(valueAtPath(value, path));
15049
+ if (ms != null)
15050
+ return ms;
15051
+ }
15052
+ return null;
15053
+ }
15054
+ function firstString(value, paths) {
15055
+ for (const path of paths) {
15056
+ const v = valueAtPath(value, path);
15057
+ if (typeof v === "string" && v.trim())
15058
+ return v.trim();
15059
+ }
15060
+ return null;
15061
+ }
15062
+ function valueAtPath(value, path) {
15063
+ let cur = value;
15064
+ for (const key of path) {
15065
+ if (!cur || typeof cur !== "object")
15066
+ return;
15067
+ cur = cur[key];
15068
+ }
15069
+ return cur;
15070
+ }
15071
+ function numberFromUnknown(value) {
15072
+ if (typeof value === "number" && Number.isFinite(value))
15073
+ return value;
15074
+ if (typeof value === "string" && value.trim()) {
15075
+ const n = Number(value);
15076
+ if (Number.isFinite(n))
15077
+ return n;
15078
+ }
15079
+ return null;
15080
+ }
15081
+ function timeMsFromUnknown(value) {
15082
+ const numeric = numberFromUnknown(value);
15083
+ if (numeric != null)
15084
+ return numeric < 10000000000 ? numeric * 1000 : numeric;
15085
+ if (typeof value === "string" && value.trim()) {
15086
+ const parsed = Date.parse(value);
15087
+ if (Number.isFinite(parsed))
15088
+ return parsed;
15089
+ }
15090
+ return null;
15091
+ }
15092
+ async function readStatusJson(root, timeoutMs) {
15093
+ const ctl = new AbortController;
15094
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
15095
+ let proc;
15096
+ try {
15097
+ proc = spawn(locateCk(), ["--status-json"], { cwd: root, signal: ctl.signal });
15098
+ } catch (e) {
15099
+ clearTimeout(timer);
15100
+ return { ok: false, reason: `ck --status-json spawn failed: ${e.message}` };
15101
+ }
15102
+ let stdout = "";
15103
+ let stderr = "";
15104
+ proc.stdout.setEncoding("utf8");
15105
+ proc.stderr.setEncoding("utf8");
15106
+ proc.stdout.on("data", (chunk) => {
15107
+ stdout += chunk;
15108
+ });
15109
+ proc.stderr.on("data", (chunk) => {
15110
+ stderr += chunk;
15111
+ });
15112
+ const rc = await new Promise((resolve3) => {
15113
+ proc.on("error", (e) => resolve3(e.name === "AbortError" || ctl.signal.aborted ? 124 : 2));
15114
+ proc.on("close", (code) => resolve3(code ?? 0));
15115
+ });
15116
+ clearTimeout(timer);
15117
+ if (rc !== 0)
15118
+ return { ok: false, reason: `ck --status-json unavailable (rc=${rc}${stderr ? `: ${stderr.trim().slice(0, 120)}` : ""})` };
15119
+ try {
15120
+ return { ok: true, value: JSON.parse(stdout.trim()) };
15121
+ } catch {
15122
+ return { ok: false, reason: "ck --status-json returned non-JSON output" };
14248
15123
  }
14249
15124
  }
14250
15125
 
14251
15126
  // src/tools/grep-session.ts
14252
- import { existsSync as existsSync5 } from "fs";
14253
- import { join as join5 } from "path";
15127
+ import { existsSync as existsSync8 } from "fs";
15128
+ import { join as join8 } from "path";
14254
15129
  var grepSession = tool({
14255
15130
  description: "opencode-sessions-explorer: grep/regex search inside ONE specific OpenCode session's body content (when you already have the session id). " + 'Answers: "inside session ses_X, grep for Y", "search session ses_Z for pattern P", "find references to X within session ses_Y", "regex search in a single session", "look up keyword W inside session ses_V". ' + "Operates only on the filesystem export of one session's parts (~50-500 files) so it's fast (<200ms typical). Auto delta-syncs any new parts since the last call. " + "Default surface is `recall`, searching curated conversation/session-summary channels when available. Use `surface:'forensics'` or channels:['raw'] to search raw exported bodies including tool output and reasoning. " + "Modes: 'regex' (default \u2014 like grep) or 'lex' (BM25 phrase). Supports fixed_string (literal match, no regex special chars), case_sensitive, whole_word, context_lines (lines before/after match). " + "For CROSS-SESSION content search (across ALL your OpenCode sessions) use search-text instead \u2014 that one supports group_by_session, role filter, and semantic modes.",
14256
15131
  args: {
@@ -14273,7 +15148,12 @@ var grepSession = tool({
14273
15148
  fail("NOT_FOUND", `session not found: ${args.session_id}`);
14274
15149
  try {
14275
15150
  const syncRes = await runExport({ budgetMs: 3000 });
14276
- ctx.indexStatus = syncRes.exported > 0 ? "fresh" : "fresh";
15151
+ if (syncRes.lock_skipped) {
15152
+ ctx.warnings.push("delta sync skipped: export lock is held by another process; results may use stale/partial export data.");
15153
+ ctx.indexStatus = "stale";
15154
+ } else {
15155
+ ctx.indexStatus = "fresh";
15156
+ }
14277
15157
  } catch (e) {
14278
15158
  ctx.warnings.push(`delta sync skipped: ${e.message}`);
14279
15159
  ctx.indexStatus = "stale";
@@ -14296,6 +15176,7 @@ var grepSession = tool({
14296
15176
  });
14297
15177
  if (ck.timedOut)
14298
15178
  ctx.warnings.push("ck timed out at 10 s");
15179
+ warnOnPartialScopeCoverage(ctx, ck.scopeCoverage);
14299
15180
  if (ck.rc !== 0 && ck.rc !== 1)
14300
15181
  ctx.warnings.push(`ck rc=${ck.rc} stderr=${truncateString(ck.stderr, 256).value}`);
14301
15182
  const matches = ck.hits.slice(0, args.limit).map((h) => enrichHit(h, args.redact));
@@ -14309,11 +15190,17 @@ var grepSession = tool({
14309
15190
  mode: args.mode,
14310
15191
  scanned_files: matches.length,
14311
15192
  matches: table(matches, { dict: ["channel"] }),
14312
- ck_duration_ms: ck.durationMs
15193
+ ck_duration_ms: ck.durationMs,
15194
+ ck_scope_coverage: ck.scopeCoverage
14313
15195
  };
14314
15196
  });
14315
15197
  }
14316
15198
  });
15199
+ function warnOnPartialScopeCoverage(ctx, coverage) {
15200
+ if (!coverage.truncated)
15201
+ return;
15202
+ ctx.warnings.push(`ck searched ${coverage.searched_scopes}/${coverage.total_scopes} scopes before stopping; results are partial and ${coverage.omitted_scopes} scopes were not searched. ` + "Narrow channels/surface or retry with a smaller session export.");
15203
+ }
14317
15204
  function enrichHit(h, redact) {
14318
15205
  const parsed = parseHitPath(h.path);
14319
15206
  let snippet2 = h.snippet ?? "";
@@ -14339,20 +15226,20 @@ function parseHitPath(p) {
14339
15226
  return { partId: m?.[1] ?? null, channel: "raw" };
14340
15227
  }
14341
15228
  function resolveSessionScopes(root, sessionId, channels, ctx) {
14342
- const rawDir = join5(root, "by-session", sessionId);
15229
+ const rawDir = join8(root, "by-session", sessionId);
14343
15230
  if (channels.includes("raw"))
14344
- return existsSync5(rawDir) ? [rawDir] : [];
15231
+ return existsSync8(rawDir) ? [rawDir] : [];
14345
15232
  if (!channelExportComplete(root)) {
14346
- if (existsSync5(rawDir)) {
15233
+ if (existsSync8(rawDir)) {
14347
15234
  ctx.warnings.push("curated channel export is partial \u2014 using raw session export to avoid false negatives. Run opencode-sessions-explorer-bulk-export --reset to enable curated channels by default.");
14348
15235
  return [rawDir];
14349
15236
  }
14350
15237
  return [];
14351
15238
  }
14352
- const channelDirs = channels.map((ch) => join5(root, "by-channel", ch, "by-session", sessionId)).filter((p) => existsSync5(p));
15239
+ const channelDirs = channels.map((ch) => join8(root, "by-channel", ch, "by-session", sessionId)).filter((p) => existsSync8(p));
14353
15240
  if (channelDirs.length > 0)
14354
15241
  return channelDirs;
14355
- if (existsSync5(rawDir)) {
15242
+ if (existsSync8(rawDir)) {
14356
15243
  ctx.warnings.push("curated channel export missing for this session \u2014 falling back to raw session export.");
14357
15244
  return [rawDir];
14358
15245
  }
@@ -14771,10 +15658,10 @@ var searchSessionsMeta = tool({
14771
15658
  });
14772
15659
 
14773
15660
  // src/tools/search-text.ts
14774
- import { existsSync as existsSync6 } from "fs";
14775
- import { join as join6 } from "path";
15661
+ import { existsSync as existsSync9 } from "fs";
15662
+ import { join as join9 } from "path";
14776
15663
  var searchText = tool({
14777
- description: "opencode-sessions-explorer: full-text search across the BODIES of all your prior OpenCode sessions (the actual conversation content \u2014 user prompts, assistant responses, tool inputs/outputs, reasoning, file references, patches, subtask prompts). " + 'Answers any of: "where in my OpenCode history did I mention X", "find sessions about Y", "have I ever discussed Z", "look up earlier conversations about W", "all references to V across my OpenCode sessions", "did this topic come up before", "when did I last talk about Q", "find the session where I worked on R", "search my prior chat content for S", "grep across all OpenCode sessions for T", "have I asked about this before". ' + "Default surface is `recall`: session-first, channel-aware, and evidence-limited. It searches high-signal conversation/session-summary views first and returns ranked sessions with raw part refs. " + "Use `surface:'forensics'` (or explicit channels including `raw`) for exhaustive raw replay over tool output, reasoning, patches, and all exported bodies. " + "ARG `group_by_session`: when true, rolls hits up to one row per session with hit_count, first/last seen timestamps, evidence snippets, and channel counts. If omitted, unscoped recall defaults to true; scoped one-session searches default to flat hits. " + `CRITICAL ARG \`role\` (default 'any'): which message roles to search inside. **Default to 'any'** for natural-language questions like "where did I mention X", "find sessions about Y", "did I discuss Z" \u2014 these are asking about appearances ANYWHERE in your conversations (user prompts AND assistant text AND tool I/O AND reasoning). ` + `Only set role='user' when the user EXPLICITLY narrows to authored messages: "what prompts have I typed containing X", "my user-authored messages mentioning Y", "questions I sent OpenCode with Z". Phrases like "did I mention" / "in my history" / "have I discussed" do NOT imply role='user' \u2014 those are asking about the corpus as a whole. ` + `Only set role='assistant' for questions explicitly about what the AI said ("what has the assistant said about X"). ` + "Modes: 'regex' (default \u2014 drop-in grep, no index needed), 'lex' (BM25 phrase search, auto-builds Tantivy index), 'sem' (semantic embeddings, requires `ck --index .` to be run once outside this tool), 'hybrid' (regex + sem). " + "Pre-filter cross-session searches via session_ids[], project_id, agent, since_ms/until_ms \u2014 unscoped full-corpus search can take 10-30 seconds. Scoped searches return in <1s. " + "For grep INSIDE a single known session use grep-session instead (faster, narrower).",
15664
+ description: "opencode-sessions-explorer: full-text search across the BODIES of all your prior OpenCode sessions (the actual conversation content \u2014 user prompts, assistant responses, tool inputs/outputs, reasoning, file references, patches, subtask prompts). " + 'Answers any of: "where in my OpenCode history did I mention X", "find sessions about Y", "have I ever discussed Z", "look up earlier conversations about W", "all references to V across my OpenCode sessions", "did this topic come up before", "when did I last talk about Q", "find the session where I worked on R", "search my prior chat content for S", "grep across all OpenCode sessions for T", "have I asked about this before". ' + "Default surface is `recall`: session-first, channel-aware, and evidence-limited. It searches high-signal conversation/session-summary views first and returns ranked sessions with raw part refs. " + "Use `surface:'forensics'` (or explicit channels including `raw`) for exhaustive raw replay over tool output, reasoning, patches, and all exported bodies. " + "ARG `group_by_session`: when true, rolls hits up to one row per session with hit_count, first/last seen timestamps, evidence snippets, and channel counts. If omitted, unscoped recall defaults to true; scoped one-session searches default to flat hits. " + `CRITICAL ARG \`role\` (default 'any'): which message roles to search inside. **Default to 'any'** for natural-language questions like "where did I mention X", "find sessions about Y", "did I discuss Z" \u2014 these are asking about appearances ANYWHERE in your conversations (user prompts AND assistant text AND tool I/O AND reasoning). ` + `Only set role='user' when the user EXPLICITLY narrows to authored messages: "what prompts have I typed containing X", "my user-authored messages mentioning Y", "questions I sent OpenCode with Z". Phrases like "did I mention" / "in my history" / "have I discussed" do NOT imply role='user' \u2014 those are asking about the corpus as a whole. ` + `Only set role='assistant' for questions explicitly about what the AI said ("what has the assistant said about X"). ` + "Modes: 'regex' (default \u2014 drop-in grep, no index needed), 'lex' (BM25 phrase search, lets ck auto-build/update its Tantivy index), 'sem' (semantic embeddings, lets ck lazily build/refresh its index), 'hybrid' (regex + sem). " + "Pre-filter cross-session searches via session_ids[], project_id, agent, since_ms/until_ms \u2014 unscoped full-corpus search can take 10-30 seconds. Scoped searches return in <1s. " + "For grep INSIDE a single known session use grep-session instead (faster, narrower).",
14778
15665
  args: {
14779
15666
  q: tool.schema.string().describe("Search query (regex pattern, BM25 phrase, or natural-language depending on mode)"),
14780
15667
  mode: tool.schema.enum(["regex", "lex", "sem", "hybrid"]).default("regex"),
@@ -14802,36 +15689,38 @@ var searchText = tool({
14802
15689
  const groupBySession = args.group_by_session ?? (surface !== "forensics" && !args.session_ids?.length);
14803
15690
  const scopeIds = resolveScope(args);
14804
15691
  const root = exportRoot();
14805
- let scopes = resolveCkScopes(root, scopeIds, channels, ctx);
14806
- if (scopeIds !== "all") {
14807
- if (scopes.length === 0) {
14808
- return groupBySession ? { sessions: table([]), hits_total: 0, mode: args.mode, surface, channels, scope_session_count: 0, ck_duration_ms: 0, suppressed: emptySuppressed(channels) } : { hits: table([]), mode: args.mode, scope_session_count: 0, ck_duration_ms: 0 };
14809
- }
14810
- } else if (surface === "forensics" || channels.includes("raw")) {
15692
+ if (scopeIds !== "all" && scopeIds.length === 0) {
15693
+ return groupBySession ? { sessions: table([]), hits_total: 0, mode: args.mode, surface, channels, scope_session_count: 0, ck_duration_ms: 0, suppressed: emptySuppressed(channels) } : { hits: table([]), mode: args.mode, scope_session_count: 0, ck_duration_ms: 0 };
15694
+ }
15695
+ if (scopeIds === "all" && (surface === "forensics" || channels.includes("raw"))) {
14811
15696
  ctx.warnings.push("raw unscoped forensic search can take 10-30s. Add session_ids/project_id/agent/since_ms to narrow.");
14812
15697
  }
15698
+ let exportStatus = "fresh";
14813
15699
  try {
14814
- await runExport({ budgetMs: 4000 });
14815
- ctx.indexStatus = "fresh";
15700
+ exportStatus = applyExportProgress(ctx, await runExport({ budgetMs: 4000 }));
14816
15701
  } catch (e) {
14817
15702
  ctx.warnings.push(`delta sync skipped: ${e.message}`);
14818
15703
  ctx.indexStatus = "stale";
15704
+ exportStatus = "stale";
15705
+ }
15706
+ const scopes = resolveCkScopes(root, scopeIds, channels, ctx);
15707
+ if (scopeIds !== "all" && scopes.length === 0) {
15708
+ ctx.warnings.push(`export scope missing after delta sync for ${scopeIds.length} DB session(s); returning empty results from stale/partial export data.`);
15709
+ return groupBySession ? { sessions: table([]), hits_total: 0, mode: args.mode, surface, channels, scope_session_count: scopeIds.length, ck_duration_ms: 0, suppressed: emptySuppressed(channels) } : { hits: table([]), mode: args.mode, scope_session_count: scopeIds.length, ck_duration_ms: 0 };
14819
15710
  }
14820
15711
  let effectiveMode = args.mode;
15712
+ let preSearchFreshness = null;
14821
15713
  if (args.mode === "sem" || args.mode === "hybrid") {
14822
- const ck = ckIndexPresent(root);
14823
- if (!ck.present) {
14824
- ctx.warnings.push(`ck semantic index not present at ${root}/.ck \u2014 falling back to regex. Run 'ck --index .' inside the export root to enable semantic search.`);
14825
- effectiveMode = "regex";
14826
- ctx.mode = "fallback-regex";
14827
- } else if (ck.embedded_chunks != null) {
14828
- ctx.warnings.push(`ck index present (${ck.embedded_chunks} embedded chunks). Semantic results limited to indexed files only.`);
14829
- }
15714
+ preSearchFreshness = await ckIndexFreshness(root);
15715
+ ctx.indexStatus = combineExportAndCkStatus(exportStatus, preSearchFreshness.status);
14830
15716
  }
14831
15717
  if (!ctx.mode)
14832
15718
  ctx.mode = effectiveMode;
14833
15719
  const ckTopk = groupBySession ? Math.min(Math.max(args.limit * 20, 100), 500) : Math.min(args.limit * (effectiveMode === "regex" ? 2 : 3), 150);
14834
- return await runWithCk(args, scopes, effectiveMode, ctx, scopeIds === "all" ? null : scopeIds.length, ckTopk, channels, surface, groupBySession);
15720
+ const result = await runWithCk(args, scopes, effectiveMode, ctx, scopeIds === "all" ? null : scopeIds.length, ckTopk, channels, surface, groupBySession);
15721
+ if (preSearchFreshness)
15722
+ await refreshCkStatusAfterSearch(ctx, root, exportStatus, preSearchFreshness);
15723
+ return result;
14835
15724
  });
14836
15725
  }
14837
15726
  });
@@ -14871,33 +15760,42 @@ function resolveScope(args) {
14871
15760
  function normalizeChannels(channels) {
14872
15761
  return Array.from(new Set(channels.length ? channels : ["conversation", "session-summary"]));
14873
15762
  }
15763
+ function applyExportProgress(ctx, progress) {
15764
+ if (progress.lock_skipped) {
15765
+ ctx.warnings.push("delta sync skipped: export lock is held by another process; results may use stale/partial export data.");
15766
+ ctx.indexStatus = "stale";
15767
+ return "stale";
15768
+ }
15769
+ ctx.indexStatus = "fresh";
15770
+ return "fresh";
15771
+ }
14874
15772
  function resolveCkScopes(root, scopeIds, channels, ctx) {
14875
15773
  const rawOnly = channels.includes("raw");
14876
- const rawRoot = join6(root, "by-session");
15774
+ const rawRoot = join9(root, "by-session");
14877
15775
  if (rawOnly) {
14878
15776
  if (scopeIds === "all")
14879
- return existsSync6(rawRoot) ? [rawRoot] : [root];
14880
- return scopeIds.map((id) => join6(rawRoot, id)).filter((p) => existsSync6(p));
15777
+ return existsSync9(rawRoot) ? [rawRoot] : [root];
15778
+ return scopeIds.map((id) => join9(rawRoot, id)).filter((p) => existsSync9(p));
14881
15779
  }
14882
15780
  if (!channelExportComplete(root)) {
14883
15781
  ctx.warnings.push("curated channel export is partial \u2014 using raw by-session export to avoid false negatives. Run opencode-sessions-explorer-bulk-export --reset to enable curated channels by default.");
14884
15782
  if (scopeIds === "all")
14885
- return existsSync6(rawRoot) ? [rawRoot] : [root];
14886
- return scopeIds.map((id) => join6(rawRoot, id)).filter((p) => existsSync6(p));
15783
+ return existsSync9(rawRoot) ? [rawRoot] : [root];
15784
+ return scopeIds.map((id) => join9(rawRoot, id)).filter((p) => existsSync9(p));
14887
15785
  }
14888
- const channelRoots = channels.map((ch) => join6(root, "by-channel", ch, "by-session"));
15786
+ const channelRoots = channels.map((ch) => join9(root, "by-channel", ch, "by-session"));
14889
15787
  if (scopeIds === "all") {
14890
- const existing = channelRoots.filter((p) => existsSync6(p));
15788
+ const existing = channelRoots.filter((p) => existsSync9(p));
14891
15789
  if (existing.length > 0)
14892
15790
  return existing;
14893
15791
  ctx.warnings.push("curated channel export is not backfilled yet \u2014 falling back to raw by-session export. Run opencode-sessions-explorer-bulk-export to build channels.");
14894
- return existsSync6(rawRoot) ? [rawRoot] : [root];
15792
+ return existsSync9(rawRoot) ? [rawRoot] : [root];
14895
15793
  }
14896
- const scoped = channelRoots.flatMap((base) => scopeIds.map((id) => join6(base, id))).filter((p) => existsSync6(p));
15794
+ const scoped = channelRoots.flatMap((base) => scopeIds.map((id) => join9(base, id))).filter((p) => existsSync9(p));
14897
15795
  if (scoped.length > 0)
14898
15796
  return scoped;
14899
15797
  ctx.warnings.push("curated channel export missing for scoped sessions \u2014 falling back to raw session export.");
14900
- return scopeIds.map((id) => join6(rawRoot, id)).filter((p) => existsSync6(p));
15798
+ return scopeIds.map((id) => join9(rawRoot, id)).filter((p) => existsSync9(p));
14901
15799
  }
14902
15800
  function emptySuppressed(channels) {
14903
15801
  return { duplicate_hits: 0, omitted_channels: CHANNELS.filter((c) => !channels.includes(c) && c !== "raw") };
@@ -14915,6 +15813,7 @@ async function runWithCk(args, scopes, mode, ctx, sessionCount, topk, channels,
14915
15813
  });
14916
15814
  if (ck.timedOut)
14917
15815
  ctx.warnings.push(`ck timed out at ${args.timeout_ms}ms`);
15816
+ warnOnPartialScopeCoverage2(ctx, ck.scopeCoverage, args.timeout_ms);
14918
15817
  if (ck.rc !== 0 && ck.rc !== 1)
14919
15818
  ctx.warnings.push(`ck rc=${ck.rc} stderr=${truncateString(ck.stderr, 256).value}`);
14920
15819
  const parsed = ck.hits.map((h) => ({ ...parsePath(h.path), ck: h }));
@@ -15042,6 +15941,7 @@ async function runWithCk(args, scopes, mode, ctx, sessionCount, topk, channels,
15042
15941
  scope_session_count: sessionCount,
15043
15942
  ck_duration_ms: ck.durationMs,
15044
15943
  ck_timed_out: ck.timedOut,
15944
+ ck_scope_coverage: ck.scopeCoverage,
15045
15945
  role_filter: args.role,
15046
15946
  suppressed: { ...emptySuppressed(channels), duplicate_hits: duplicateCount }
15047
15947
  };
@@ -15064,10 +15964,35 @@ async function runWithCk(args, scopes, mode, ctx, sessionCount, topk, channels,
15064
15964
  scope_session_count: sessionCount,
15065
15965
  ck_duration_ms: ck.durationMs,
15066
15966
  ck_timed_out: ck.timedOut,
15967
+ ck_scope_coverage: ck.scopeCoverage,
15067
15968
  role_filter: args.role,
15068
15969
  suppressed: { ...emptySuppressed(channels), duplicate_hits: duplicateCount }
15069
15970
  };
15070
15971
  }
15972
+ function combineExportAndCkStatus(exportStatus, ckStatus) {
15973
+ if (ckStatus === "missing" || ckStatus === "partial")
15974
+ return ckStatus;
15975
+ if (exportStatus === "stale")
15976
+ return "stale";
15977
+ return ckStatus;
15978
+ }
15979
+ async function refreshCkStatusAfterSearch(ctx, root, exportStatus, preSearchFreshness) {
15980
+ try {
15981
+ const postSearchFreshness = await ckIndexFreshness(root);
15982
+ ctx.indexStatus = combineExportAndCkStatus(exportStatus, postSearchFreshness.status);
15983
+ if (postSearchFreshness.status !== "fresh") {
15984
+ ctx.warnings.push(postSearchFreshness.warning ?? preSearchFreshness.warning ?? "ck semantic index freshness is not verified after search; results may be partial.");
15985
+ }
15986
+ } catch (e) {
15987
+ ctx.indexStatus = combineExportAndCkStatus(exportStatus, preSearchFreshness.status);
15988
+ ctx.warnings.push(preSearchFreshness.warning ?? `ck semantic index freshness recheck failed after search: ${e.message}`);
15989
+ }
15990
+ }
15991
+ function warnOnPartialScopeCoverage2(ctx, coverage, timeoutMs) {
15992
+ if (!coverage.truncated)
15993
+ return;
15994
+ ctx.warnings.push(`ck searched ${coverage.searched_scopes}/${coverage.total_scopes} scopes before stopping; results are partial and ${coverage.omitted_scopes} scopes were not searched. ` + `Narrow session_ids/project_id/since_ms or raise timeout_ms (current ${timeoutMs}ms).`);
15995
+ }
15071
15996
  function parsePath(p) {
15072
15997
  const ch = /\/by-channel\/([^/]+)\/by-session\/(ses_[A-Za-z0-9_-]+)\/(?:summary\.txt|(?:\d{5}-)?(prt_[A-Za-z0-9_-]+)\.txt)$/.exec(p);
15073
15998
  if (ch)