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/CHANGELOG.md +16 -0
- package/README.md +7 -3
- package/dist/bin/bulk-export.js +827 -159
- package/dist/bin/check-deps.js +836 -167
- package/dist/bin/dedupe-export.js +825 -157
- package/dist/lib/export-reconcile-worker.js +1519 -0
- package/dist/plugin.js +1133 -208
- package/docs/getting-started.md +5 -4
- package/docs/guides/export-and-maintenance.md +6 -3
- package/docs/guides/search-and-grep.md +6 -3
- package/docs/reference/architecture.md +10 -5
- package/package.json +2 -2
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
|
|
12893
|
-
import { join as
|
|
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
|
-
|
|
12988
|
-
|
|
12989
|
-
|
|
12990
|
-
}
|
|
12991
|
-
|
|
12992
|
-
|
|
12993
|
-
|
|
12994
|
-
|
|
12995
|
-
|
|
12996
|
-
|
|
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
|
-
|
|
12999
|
-
|
|
13000
|
-
|
|
13001
|
-
|
|
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
|
|
13005
|
-
|
|
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 (
|
|
13008
|
-
|
|
13009
|
-
|
|
13010
|
-
|
|
13011
|
-
|
|
13012
|
-
|
|
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
|
-
|
|
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
|
|
13020
|
-
|
|
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
|
-
|
|
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 =
|
|
13722
|
+
const p = join5(dir, "meta.json");
|
|
13267
13723
|
const tmp = p + ".tmp";
|
|
13268
|
-
|
|
13269
|
-
|
|
13724
|
+
writeFileSync3(tmp, JSON.stringify(meta, null, 2));
|
|
13725
|
+
renameSync2(tmp, p);
|
|
13270
13726
|
}
|
|
13271
13727
|
function writePartFile(dir, filename, content) {
|
|
13272
|
-
const p =
|
|
13273
|
-
const tmp =
|
|
13274
|
-
|
|
13275
|
-
|
|
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
|
|
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
|
-
|
|
13743
|
+
unlinkSync3(join5(dir, f));
|
|
13288
13744
|
} catch {}
|
|
13289
13745
|
}
|
|
13290
13746
|
}
|
|
13291
13747
|
} catch {}
|
|
13292
13748
|
}
|
|
13293
|
-
function
|
|
13294
|
-
return
|
|
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 =
|
|
13299
|
-
if (!
|
|
13754
|
+
const dir = channelDir2(root, ch, sessionId);
|
|
13755
|
+
if (!existsSync5(dir))
|
|
13300
13756
|
continue;
|
|
13301
13757
|
try {
|
|
13302
|
-
for (const f of
|
|
13758
|
+
for (const f of readdirSync2(dir)) {
|
|
13303
13759
|
if (f === `${partId}.txt` || f.endsWith(`-${partId}.txt`)) {
|
|
13304
13760
|
try {
|
|
13305
|
-
|
|
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 =
|
|
13316
|
-
if (!
|
|
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 =
|
|
13323
|
-
if (!
|
|
13778
|
+
const dir = channelDir2(dirRoot, "session-summary", s.id);
|
|
13779
|
+
if (!existsSync5(dir))
|
|
13324
13780
|
mkdirSync(dir, { recursive: true });
|
|
13325
|
-
const p =
|
|
13781
|
+
const p = join5(dir, "summary.txt");
|
|
13326
13782
|
const tmp = p + ".tmp";
|
|
13327
|
-
|
|
13328
|
-
|
|
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 =
|
|
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
|
|
13406
|
-
const
|
|
13407
|
-
|
|
13408
|
-
|
|
13409
|
-
|
|
13410
|
-
|
|
13411
|
-
|
|
13412
|
-
|
|
13413
|
-
|
|
13414
|
-
|
|
13415
|
-
|
|
13416
|
-
|
|
13417
|
-
|
|
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
|
-
|
|
13420
|
-
|
|
13421
|
-
|
|
13422
|
-
|
|
13423
|
-
|
|
13424
|
-
|
|
13425
|
-
|
|
13426
|
-
|
|
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
|
|
13430
|
-
if (
|
|
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
|
-
|
|
13476
|
-
|
|
13477
|
-
|
|
13478
|
-
|
|
13479
|
-
|
|
13480
|
-
|
|
13481
|
-
|
|
13482
|
-
|
|
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
|
|
13487
|
-
|
|
13488
|
-
|
|
13489
|
-
|
|
13490
|
-
|
|
13491
|
-
|
|
13492
|
-
|
|
13493
|
-
|
|
13494
|
-
|
|
13495
|
-
|
|
13496
|
-
|
|
13497
|
-
|
|
13498
|
-
|
|
13499
|
-
|
|
13500
|
-
|
|
13501
|
-
|
|
13502
|
-
|
|
13503
|
-
|
|
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
|
|
13508
|
-
import { dirname
|
|
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 ?
|
|
13524
|
-
const sessionMeta = sessionDir ?
|
|
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 ?
|
|
14284
|
+
this_session_export_dir_exists: sessionDir ? existsSync6(sessionDir) : false,
|
|
13617
14285
|
this_session_meta_json: sessionMeta,
|
|
13618
|
-
this_session_meta_json_exists: sessionMeta ?
|
|
13619
|
-
tool_output_dir: dbPath ?
|
|
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
|
|
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 ??
|
|
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 ??
|
|
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
|
|
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("/") &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
14946
|
+
async function ckIndexFreshness(root = exportRoot(), timeoutMs = 1500) {
|
|
14240
14947
|
const manifestPath = `${root}/.ck/manifest.json`;
|
|
14241
|
-
|
|
14242
|
-
|
|
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
|
-
|
|
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
|
|
14253
|
-
import { join as
|
|
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
|
-
|
|
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 =
|
|
15229
|
+
const rawDir = join8(root, "by-session", sessionId);
|
|
14343
15230
|
if (channels.includes("raw"))
|
|
14344
|
-
return
|
|
15231
|
+
return existsSync8(rawDir) ? [rawDir] : [];
|
|
14345
15232
|
if (!channelExportComplete(root)) {
|
|
14346
|
-
if (
|
|
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) =>
|
|
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 (
|
|
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
|
|
14775
|
-
import { join as
|
|
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-
|
|
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
|
-
|
|
14806
|
-
|
|
14807
|
-
|
|
14808
|
-
|
|
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
|
-
|
|
14823
|
-
|
|
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
|
-
|
|
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 =
|
|
15774
|
+
const rawRoot = join9(root, "by-session");
|
|
14877
15775
|
if (rawOnly) {
|
|
14878
15776
|
if (scopeIds === "all")
|
|
14879
|
-
return
|
|
14880
|
-
return scopeIds.map((id) =>
|
|
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
|
|
14886
|
-
return scopeIds.map((id) =>
|
|
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) =>
|
|
15786
|
+
const channelRoots = channels.map((ch) => join9(root, "by-channel", ch, "by-session"));
|
|
14889
15787
|
if (scopeIds === "all") {
|
|
14890
|
-
const existing = channelRoots.filter((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
|
|
15792
|
+
return existsSync9(rawRoot) ? [rawRoot] : [root];
|
|
14895
15793
|
}
|
|
14896
|
-
const scoped = channelRoots.flatMap((base) => scopeIds.map((id) =>
|
|
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) =>
|
|
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)
|