@phnx-labs/agents-cli 1.22.24 → 1.22.26
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 +350 -1
- package/README.md +31 -6
- package/dist/bin/agents +0 -0
- package/dist/browser.js +14 -4
- package/dist/commands/apply.js +52 -8
- package/dist/commands/browser.js +35 -0
- package/dist/commands/cloud.js +9 -5
- package/dist/commands/doctor.d.ts +24 -0
- package/dist/commands/doctor.js +108 -9
- package/dist/commands/exec.js +19 -16
- package/dist/commands/feed.d.ts +5 -0
- package/dist/commands/feed.js +21 -1
- package/dist/commands/focus.js +2 -2
- package/dist/commands/insights.d.ts +25 -19
- package/dist/commands/insights.js +107 -33
- package/dist/commands/menubar.js +8 -0
- package/dist/commands/reconnect.d.ts +46 -0
- package/dist/commands/reconnect.js +109 -0
- package/dist/commands/routines.d.ts +3 -0
- package/dist/commands/routines.js +72 -49
- package/dist/commands/run-cloud.js +1 -1
- package/dist/commands/secrets.d.ts +2 -8
- package/dist/commands/secrets.js +29 -105
- package/dist/commands/sessions-browser.d.ts +1 -1
- package/dist/commands/sessions-browser.js +27 -7
- package/dist/commands/sessions-resume.js +3 -2
- package/dist/commands/sessions.d.ts +13 -1
- package/dist/commands/sessions.js +28 -2
- package/dist/commands/setup-secrets.d.ts +1 -0
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/setup-watchdog.js +5 -10
- package/dist/commands/setup.d.ts +26 -3
- package/dist/commands/setup.js +106 -47
- package/dist/commands/teams.d.ts +6 -0
- package/dist/commands/teams.js +46 -2
- package/dist/commands/trends.d.ts +8 -0
- package/dist/commands/trends.js +10 -156
- package/dist/commands/watchdog.d.ts +3 -4
- package/dist/commands/watchdog.js +26 -66
- package/dist/index.js +37 -2
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.js +155 -23
- package/dist/lib/analytics/dashboard.d.ts +10 -6
- package/dist/lib/analytics/dashboard.js +6 -4
- package/dist/lib/analytics/mix-commands.d.ts +53 -0
- package/dist/lib/analytics/mix-commands.js +229 -0
- package/dist/lib/analytics/recipes.d.ts +19 -14
- package/dist/lib/analytics/recipes.js +4 -2
- package/dist/lib/browser/ipc.d.ts +26 -0
- package/dist/lib/browser/ipc.js +139 -24
- package/dist/lib/browser/profiles.d.ts +11 -0
- package/dist/lib/browser/profiles.js +1 -1
- package/dist/lib/browser/stream.d.ts +14 -0
- package/dist/lib/browser/stream.js +71 -0
- package/dist/lib/channels/owner-sink.d.ts +27 -0
- package/dist/lib/channels/owner-sink.js +93 -0
- package/dist/lib/cloud/cursor.d.ts +79 -0
- package/dist/lib/cloud/cursor.js +228 -0
- package/dist/lib/cloud/registry.js +2 -0
- package/dist/lib/cloud/types.d.ts +7 -2
- package/dist/lib/cloud/types.js +14 -0
- package/dist/lib/crabbox/cli.d.ts +2 -2
- package/dist/lib/crabbox/config.d.ts +7 -8
- package/dist/lib/crabbox/config.js +14 -14
- package/dist/lib/crabbox/lease.d.ts +11 -4
- package/dist/lib/crabbox/lease.js +40 -8
- package/dist/lib/crabbox/setup-copy.d.ts +5 -0
- package/dist/lib/crabbox/setup-copy.js +17 -1
- package/dist/lib/daemon.js +27 -1
- package/dist/lib/device-config.js +7 -0
- package/dist/lib/devices/doctor-findings.d.ts +13 -1
- package/dist/lib/devices/doctor-findings.js +73 -2
- package/dist/lib/events.d.ts +9 -0
- package/dist/lib/events.js +58 -0
- package/dist/lib/exec.d.ts +3 -3
- package/dist/lib/exec.js +24 -10
- package/dist/lib/feed-outcome.d.ts +3 -0
- package/dist/lib/feed-outcome.js +18 -10
- package/dist/lib/feed.d.ts +4 -0
- package/dist/lib/fleet/apply.d.ts +59 -3
- package/dist/lib/fleet/apply.js +183 -6
- package/dist/lib/fleet/types.d.ts +21 -2
- package/dist/lib/hooks/cache.js +15 -0
- package/dist/lib/hosts/passthrough.d.ts +44 -0
- package/dist/lib/hosts/passthrough.js +84 -12
- package/dist/lib/hosts/ready.d.ts +2 -0
- package/dist/lib/hosts/ready.js +10 -1
- package/dist/lib/hosts/reconnect.d.ts +14 -12
- package/dist/lib/hosts/reconnect.js +41 -40
- package/dist/lib/mcp.js +5 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/snapshot.d.ts +15 -0
- package/dist/lib/menubar/snapshot.js +40 -0
- package/dist/lib/plugins.js +13 -1
- package/dist/lib/resources/mcp.js +3 -0
- package/dist/lib/routine-process-cleanup.d.ts +9 -0
- package/dist/lib/routine-process-cleanup.js +73 -0
- package/dist/lib/routines.js +14 -2
- package/dist/lib/runner.d.ts +0 -3
- package/dist/lib/runner.js +6 -19
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.js +1 -20
- package/dist/lib/secrets/filestore.d.ts +2 -0
- package/dist/lib/secrets/filestore.js +13 -0
- package/dist/lib/secrets/push.d.ts +94 -0
- package/dist/lib/secrets/push.js +145 -0
- package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
- package/dist/lib/secrets/rc-hygiene.js +14 -1
- package/dist/lib/secrets/reaper.d.ts +15 -1
- package/dist/lib/secrets/reaper.js +30 -3
- package/dist/lib/session/active.d.ts +4 -0
- package/dist/lib/session/db.d.ts +21 -3
- package/dist/lib/session/db.js +229 -15
- package/dist/lib/session/discover.d.ts +1 -0
- package/dist/lib/session/discover.js +115 -19
- package/dist/lib/session/insights.d.ts +18 -0
- package/dist/lib/session/insights.js +143 -1
- package/dist/lib/session/remote-list.d.ts +2 -0
- package/dist/lib/session/remote-list.js +1 -0
- package/dist/lib/session/session-cache.d.ts +4 -4
- package/dist/lib/session/session-cache.js +4 -4
- package/dist/lib/session/tool-index.js +133 -22
- package/dist/lib/session/tool-store.d.ts +26 -2
- package/dist/lib/session/tool-store.js +36 -17
- package/dist/lib/shims.js +21 -1
- package/dist/lib/signin-badge.js +2 -0
- package/dist/lib/ssh-exec.js +8 -2
- package/dist/lib/startup/command-registry.d.ts +16 -0
- package/dist/lib/startup/command-registry.js +46 -0
- package/dist/lib/teams/agents.d.ts +13 -0
- package/dist/lib/teams/agents.js +76 -8
- package/dist/lib/teams/parsers.d.ts +1 -1
- package/dist/lib/teams/placement-probe.d.ts +21 -0
- package/dist/lib/teams/placement-probe.js +135 -0
- package/dist/lib/teams/scheduler.d.ts +74 -1
- package/dist/lib/teams/scheduler.js +187 -10
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/versions.js +16 -1
- package/dist/lib/watchdog/service.d.ts +17 -0
- package/dist/lib/watchdog/service.js +39 -0
- package/package.json +1 -1
package/dist/lib/session/db.js
CHANGED
|
@@ -12,7 +12,7 @@ import Database from '../sqlite.js';
|
|
|
12
12
|
import { parseSession } from './parse.js';
|
|
13
13
|
import { extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
|
|
14
14
|
import { getSessionsDir, getSessionsDbPath } from '../state.js';
|
|
15
|
-
import { query as queryEvents } from '../events.js';
|
|
15
|
+
import { query as queryEvents, queryToolUsageForSessions } from '../events.js';
|
|
16
16
|
import { machineForSessionFile } from './origin-machine.js';
|
|
17
17
|
import { loadSessionActorIndex, readSessionActorRecord } from './actor-sidecar.js';
|
|
18
18
|
import { toolCallsFromEvents } from './tool-calls.js';
|
|
@@ -26,7 +26,7 @@ const DB_PATH = getSessionsDbPath();
|
|
|
26
26
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
27
27
|
* assert against the constant instead of hardcoding a number that every bump
|
|
28
28
|
* then has to chase (docs/05-sessions.md calls the constant the source of truth). */
|
|
29
|
-
export const SCHEMA_VERSION =
|
|
29
|
+
export const SCHEMA_VERSION = 36;
|
|
30
30
|
/**
|
|
31
31
|
* Bump to force `agents sessions backfill resources` to re-derive every
|
|
32
32
|
* session's skill/slash-command tallies on its next run (resource_scan_ledger
|
|
@@ -204,6 +204,12 @@ CREATE TABLE IF NOT EXISTS tool_program_occurrences (
|
|
|
204
204
|
CREATE INDEX IF NOT EXISTS idx_tool_program_occurrences_program
|
|
205
205
|
ON tool_program_occurrences(program, call_key);
|
|
206
206
|
|
|
207
|
+
-- Derived search index over tool_calls. call_key is UNINDEXED -- it is carried
|
|
208
|
+
-- for display, NOT for lookup: an FTS5 table has no index on an ordinary column,
|
|
209
|
+
-- so DELETE ... WHERE call_key = ? scans the whole index once per call, which is
|
|
210
|
+
-- quadratic in a session's call count. Every write here therefore addresses a
|
|
211
|
+
-- row by rowid, mirroring the tool_calls.rowid of the call it describes, so a
|
|
212
|
+
-- delete is a single rowid seek (tool-store.ts persistToolCalls/deleteSessionCalls).
|
|
207
213
|
CREATE VIRTUAL TABLE IF NOT EXISTS tool_call_text USING fts5(
|
|
208
214
|
call_key UNINDEXED,
|
|
209
215
|
tool,
|
|
@@ -224,7 +230,15 @@ CREATE TABLE IF NOT EXISTS tool_scan_ledger (
|
|
|
224
230
|
extractor_version INTEGER NOT NULL,
|
|
225
231
|
indexed_at INTEGER NOT NULL,
|
|
226
232
|
call_count INTEGER NOT NULL,
|
|
227
|
-
evidence_bytes INTEGER NOT NULL
|
|
233
|
+
evidence_bytes INTEGER NOT NULL,
|
|
234
|
+
-- Resume point for the incremental tool scan. parsed_offset is the byte
|
|
235
|
+
-- offset just past the last COMPLETE newline-terminated record consumed, and
|
|
236
|
+
-- parser_state is the serialized ToolCallCollector snapshot at that offset
|
|
237
|
+
-- (next ordinal + still-unresolved calls). Together they let the next scan of
|
|
238
|
+
-- a session that only grew read the appended bytes instead of the whole file.
|
|
239
|
+
-- NULL means "no resume point" — the next scan re-reads from byte 0.
|
|
240
|
+
parser_state TEXT,
|
|
241
|
+
parsed_offset INTEGER
|
|
228
242
|
);
|
|
229
243
|
|
|
230
244
|
-- Skill/slash-command usage per session (#12), computed from a session's
|
|
@@ -294,7 +308,7 @@ CREATE TABLE IF NOT EXISTS session_insights (
|
|
|
294
308
|
* re-derives on the next `agents insights` instead of silently reporting stale
|
|
295
309
|
* numbers alongside fresh ones. Same role as RESOURCE_INDEX_VERSION.
|
|
296
310
|
*/
|
|
297
|
-
export const INSIGHTS_EXTRACTOR_VERSION =
|
|
311
|
+
export const INSIGHTS_EXTRACTOR_VERSION = 4;
|
|
298
312
|
let dbInstance = null;
|
|
299
313
|
/**
|
|
300
314
|
* Apply schema migrations from `fromVersion` → SCHEMA_VERSION. The new
|
|
@@ -810,6 +824,57 @@ function migrateSchema(db, fromVersion) {
|
|
|
810
824
|
);
|
|
811
825
|
`);
|
|
812
826
|
}
|
|
827
|
+
if (fromVersion < 35) {
|
|
828
|
+
// v34 -> v35: the default listing sort was `ORDER BY IFNULL(last_activity,
|
|
829
|
+
// timestamp) DESC` — wrapping the column in IFNULL() makes SQLite unable to
|
|
830
|
+
// satisfy it from idx_sessions_last_activity, so every list/resume query did
|
|
831
|
+
// a full table sort instead of an index walk (RUSH-2211). Every upsert path
|
|
832
|
+
// already writes a non-NULL last_activity (resolveLastActivity falls back to
|
|
833
|
+
// `timestamp`, itself NOT NULL) — the only rows that can still be NULL here
|
|
834
|
+
// are ones written before the v8 migration that somehow slipped the backfill,
|
|
835
|
+
// or seeded directly by a test. Backfill them so the column is unconditionally
|
|
836
|
+
// NOT NULL, then querySessions can sort on the bare column and use the index.
|
|
837
|
+
db.exec(`UPDATE sessions SET last_activity = timestamp WHERE last_activity IS NULL`);
|
|
838
|
+
}
|
|
839
|
+
if (fromVersion < 36) {
|
|
840
|
+
// v35 -> v36: make the tool index incremental, and stop paying a full FTS
|
|
841
|
+
// scan per deleted call.
|
|
842
|
+
//
|
|
843
|
+
// (a) tool_scan_ledger gains a resume point (parser_state + parsed_offset).
|
|
844
|
+
// Existing rows get NULLs, which read as "no resume point": the next
|
|
845
|
+
// scan of each session re-reads it once from byte 0 and records a resume
|
|
846
|
+
// point, so every scan after that is incremental. No ledger is wiped.
|
|
847
|
+
//
|
|
848
|
+
// (b) tool_call_text is rebuilt so its rowid mirrors tool_calls.rowid. The
|
|
849
|
+
// old rows were inserted with FTS5-assigned rowids and are only
|
|
850
|
+
// addressable by the UNINDEXED call_key, i.e. a full index scan per
|
|
851
|
+
// delete. There is no ALTER for that, and the rowids cannot be repaired
|
|
852
|
+
// in place, so the table is dropped and repopulated from tool_calls --
|
|
853
|
+
// the same non-destructive derived-table rebuild v27 did (the source of
|
|
854
|
+
// truth is tool_calls, which is untouched). The rebuild also lands the
|
|
855
|
+
// content as one merged segment, which is the compaction
|
|
856
|
+
// optimizeSessionSearchIndex would otherwise have to do afterwards.
|
|
857
|
+
const ledgerCols = new Set(db.prepare(`PRAGMA table_info(tool_scan_ledger)`).all()
|
|
858
|
+
.map((column) => column.name));
|
|
859
|
+
if (!ledgerCols.has('parser_state'))
|
|
860
|
+
db.exec(`ALTER TABLE tool_scan_ledger ADD COLUMN parser_state TEXT`);
|
|
861
|
+
if (!ledgerCols.has('parsed_offset'))
|
|
862
|
+
db.exec(`ALTER TABLE tool_scan_ledger ADD COLUMN parsed_offset INTEGER`);
|
|
863
|
+
db.exec(`
|
|
864
|
+
DROP TABLE IF EXISTS tool_call_text;
|
|
865
|
+
CREATE VIRTUAL TABLE tool_call_text USING fts5(
|
|
866
|
+
call_key UNINDEXED,
|
|
867
|
+
tool,
|
|
868
|
+
input,
|
|
869
|
+
output,
|
|
870
|
+
error,
|
|
871
|
+
tokenize = 'trigram'
|
|
872
|
+
);
|
|
873
|
+
INSERT INTO tool_call_text (rowid, call_key, tool, input, output, error)
|
|
874
|
+
SELECT rowid, call_key, tool, input, coalesce(output, ''), coalesce(error, '')
|
|
875
|
+
FROM tool_calls;
|
|
876
|
+
`);
|
|
877
|
+
}
|
|
813
878
|
}
|
|
814
879
|
/**
|
|
815
880
|
* Stamp `account_key` / `account_org` / `account` on every Claude row from its
|
|
@@ -988,6 +1053,49 @@ export function optimizeSessionSearchIndex() {
|
|
|
988
1053
|
return { table, segmentsBefore, segmentsAfter: segments(table) };
|
|
989
1054
|
});
|
|
990
1055
|
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Segment count above which a scan pays for a slice of merge work. Below it the
|
|
1058
|
+
* index is small enough that querying it is not the bottleneck and merging is
|
|
1059
|
+
* pure overhead on every scan.
|
|
1060
|
+
*/
|
|
1061
|
+
const FTS_MAINTENANCE_SEGMENT_THRESHOLD = 512;
|
|
1062
|
+
/**
|
|
1063
|
+
* Page budget for one incremental merge. FTS5's `'merge'` command does at most
|
|
1064
|
+
* this much work and returns — it is not `'optimize'`, which merges the whole
|
|
1065
|
+
* index in one unbounded pass. That bound is why this can run on the scan path:
|
|
1066
|
+
* the cost per scan is fixed, and repeated scans converge the index instead of
|
|
1067
|
+
* one scan stalling on a multi-gigabyte compaction.
|
|
1068
|
+
*/
|
|
1069
|
+
const FTS_MAINTENANCE_MERGE_PAGES = 64;
|
|
1070
|
+
/**
|
|
1071
|
+
* Keep the FTS indexes from degrading on the normal scan path.
|
|
1072
|
+
*
|
|
1073
|
+
* `optimizeSessionSearchIndex` is the full, unbounded compaction behind
|
|
1074
|
+
* `agents sessions optimize`. Leaving it as the ONLY compaction meant the index
|
|
1075
|
+
* degraded until a human happened to run that command, which is how
|
|
1076
|
+
* `tool_call_text_data` reached gigabytes for tens of MB of content. This is the
|
|
1077
|
+
* automatic counterpart: bounded, threshold-gated, and safe to call after every
|
|
1078
|
+
* batch of writes. Non-destructive — merging never changes what is searchable.
|
|
1079
|
+
*
|
|
1080
|
+
* Returns one result per table it actually merged (empty when every table is
|
|
1081
|
+
* under the threshold, which is the common case on a warm index).
|
|
1082
|
+
*/
|
|
1083
|
+
export function maintainSessionSearchIndex(db = getDB(), options = {}) {
|
|
1084
|
+
const threshold = options.segmentThreshold ?? FTS_MAINTENANCE_SEGMENT_THRESHOLD;
|
|
1085
|
+
const pages = options.mergePages ?? FTS_MAINTENANCE_MERGE_PAGES;
|
|
1086
|
+
// Hardcoded literals — never interpolate caller input into an identifier.
|
|
1087
|
+
const tables = ['tool_call_text', 'session_text'];
|
|
1088
|
+
const segments = (table) => db.prepare(`SELECT count(*) AS n FROM ${table}_data`).get().n;
|
|
1089
|
+
const results = [];
|
|
1090
|
+
for (const table of tables) {
|
|
1091
|
+
const segmentsBefore = segments(table);
|
|
1092
|
+
if (segmentsBefore < threshold)
|
|
1093
|
+
continue;
|
|
1094
|
+
db.prepare(`INSERT INTO ${table}(${table}, rank) VALUES('merge', ?)`).run(pages);
|
|
1095
|
+
results.push({ table, segmentsBefore, segmentsAfter: segments(table) });
|
|
1096
|
+
}
|
|
1097
|
+
return results;
|
|
1098
|
+
}
|
|
991
1099
|
// ---------------------------------------------------------------------------
|
|
992
1100
|
// Scan coordinator — prevents concurrent full scans across processes
|
|
993
1101
|
// ---------------------------------------------------------------------------
|
|
@@ -1627,9 +1735,10 @@ export function upsertSessionsBatch(entries) {
|
|
|
1627
1735
|
const stat = fs.statSync(toolSourcePath);
|
|
1628
1736
|
return { fileMtimeMs: stat.mtimeMs, fileSize: stat.size };
|
|
1629
1737
|
})();
|
|
1630
|
-
//
|
|
1631
|
-
//
|
|
1632
|
-
|
|
1738
|
+
// Some non-resumable scanners already normalized the transcript while
|
|
1739
|
+
// deriving metadata. Reuse those events; scanners that only read summary
|
|
1740
|
+
// metadata fall back to exactly one normalized parse here.
|
|
1741
|
+
const events = entry.events ?? parseSession(entry.meta.filePath, entry.meta.agent);
|
|
1633
1742
|
writeResourceUsage(entry.meta.id, events, entry.meta.cwd);
|
|
1634
1743
|
return {
|
|
1635
1744
|
...entry,
|
|
@@ -1640,6 +1749,8 @@ export function upsertSessionsBatch(entries) {
|
|
|
1640
1749
|
},
|
|
1641
1750
|
toolCalls: toolCallsFromEvents(events),
|
|
1642
1751
|
toolScan,
|
|
1752
|
+
// These are complete event arrays, not an appended tail. Append would
|
|
1753
|
+
// duplicate existing evidence even when persistToolCalls supports it.
|
|
1643
1754
|
toolIndexMode: 'replace',
|
|
1644
1755
|
};
|
|
1645
1756
|
}
|
|
@@ -1648,6 +1759,12 @@ export function upsertSessionsBatch(entries) {
|
|
|
1648
1759
|
}
|
|
1649
1760
|
});
|
|
1650
1761
|
const writtenEntries = [];
|
|
1762
|
+
// Pre-compute browser/computer usage for all sessions outside the write
|
|
1763
|
+
// transaction. detectToolUsage scans all event log files (O(files) I/O
|
|
1764
|
+
// per call) and holding the SQLite write lock during that scan is what
|
|
1765
|
+
// causes the "DB locked" errors (RUSH-2006). One pass for the whole batch
|
|
1766
|
+
// costs O(files) total instead of O(N × files) inside the lock.
|
|
1767
|
+
const toolUsageBySession = queryToolUsageForSessions(new Set(enrichedEntries.map(e => e.meta.id)));
|
|
1651
1768
|
const txn = db.transaction((items) => {
|
|
1652
1769
|
// Re-read the ledger now that we hold the write lock. Any file committed
|
|
1653
1770
|
// by a concurrent process since our pre-scan is visible here.
|
|
@@ -1678,7 +1795,7 @@ export function upsertSessionsBatch(entries) {
|
|
|
1678
1795
|
// back when the error escapes `fn`, so catching + skipping here leaves the txn valid
|
|
1679
1796
|
// and committable. We deliberately do NOT stamp the ledger for a skipped row, so the
|
|
1680
1797
|
// next scan re-tries it (self-healing once the underlying parser is fixed).
|
|
1681
|
-
const toolUsage =
|
|
1798
|
+
const toolUsage = toolUsageBySession.get(meta.id) ?? { usedBrowser: false, usedComputer: false };
|
|
1682
1799
|
// claude/codex skip enrichCachedSessionMeta above (preserving their
|
|
1683
1800
|
// resumable-parse optimization) — write their pre-computed
|
|
1684
1801
|
// skillsUsed/slashCommandsUsed (folded incrementally by discover.ts's
|
|
@@ -1767,12 +1884,18 @@ export function upsertSessionsBatch(entries) {
|
|
|
1767
1884
|
if (!toolScan || !entry.toolCalls)
|
|
1768
1885
|
continue;
|
|
1769
1886
|
try {
|
|
1770
|
-
persistToolCalls(db, entry.meta, entry.toolCalls, toolScan, entry.toolIndexMode ?? 'replace');
|
|
1887
|
+
persistToolCalls(db, entry.meta, entry.toolCalls, toolScan, { mode: entry.toolIndexMode ?? 'replace' });
|
|
1771
1888
|
}
|
|
1772
1889
|
catch {
|
|
1773
1890
|
// Boundary is intentionally retryable via tool_scan_ledger.
|
|
1774
1891
|
}
|
|
1775
1892
|
}
|
|
1893
|
+
// Every batch appends FTS segments (session_text always, tool_call_text for the
|
|
1894
|
+
// harnesses indexed above). Pay a bounded slice of the merge here so the
|
|
1895
|
+
// scan path keeps its own index healthy instead of leaving all compaction to
|
|
1896
|
+
// the manual `agents sessions optimize` (RUSH-2208). Threshold-gated, so a
|
|
1897
|
+
// small index costs two counts and nothing else.
|
|
1898
|
+
maintainSessionSearchIndex(db);
|
|
1776
1899
|
}
|
|
1777
1900
|
/**
|
|
1778
1901
|
* Sync labels for a set of sessions. For each id in the map, if the stored
|
|
@@ -2112,6 +2235,51 @@ function buildSessionWhere(options) {
|
|
|
2112
2235
|
const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
|
2113
2236
|
return { clause, params };
|
|
2114
2237
|
}
|
|
2238
|
+
/**
|
|
2239
|
+
* Resolve which of the given file paths no longer exist, batching the check
|
|
2240
|
+
* per directory instead of one `fs.existsSync` stat syscall per file.
|
|
2241
|
+
* Transcript trees put many sessions in the same directory (one Claude
|
|
2242
|
+
* `~/.claude/projects/<slug>/` holds every session for that project), so
|
|
2243
|
+
* `readdirSync` once per directory and a Set membership test collapses what
|
|
2244
|
+
* used to be N stat syscalls into (number of distinct directories) readdir
|
|
2245
|
+
* syscalls — the same existence answer, far fewer syscalls on a large index
|
|
2246
|
+
* (RUSH-2211). Falls back to per-file existsSync only when the directory
|
|
2247
|
+
* itself can't be listed (permissions, race with a concurrent delete).
|
|
2248
|
+
*/
|
|
2249
|
+
function findMissingFilePaths(filePaths) {
|
|
2250
|
+
const byDir = new Map();
|
|
2251
|
+
for (const p of filePaths) {
|
|
2252
|
+
const dir = path.dirname(p);
|
|
2253
|
+
let basenames = byDir.get(dir);
|
|
2254
|
+
if (!basenames) {
|
|
2255
|
+
basenames = new Set();
|
|
2256
|
+
byDir.set(dir, basenames);
|
|
2257
|
+
}
|
|
2258
|
+
basenames.add(path.basename(p));
|
|
2259
|
+
}
|
|
2260
|
+
const missing = new Set();
|
|
2261
|
+
for (const [dir, basenames] of byDir) {
|
|
2262
|
+
let entries;
|
|
2263
|
+
try {
|
|
2264
|
+
entries = new Set(fs.readdirSync(dir));
|
|
2265
|
+
}
|
|
2266
|
+
catch {
|
|
2267
|
+
// Directory itself is gone (or unreadable) — every file in it is missing.
|
|
2268
|
+
// Also covers the race where readdir loses to a concurrent delete: fall
|
|
2269
|
+
// back to a direct stat rather than assuming existence.
|
|
2270
|
+
for (const base of basenames) {
|
|
2271
|
+
if (!fs.existsSync(path.join(dir, base)))
|
|
2272
|
+
missing.add(path.join(dir, base));
|
|
2273
|
+
}
|
|
2274
|
+
continue;
|
|
2275
|
+
}
|
|
2276
|
+
for (const base of basenames) {
|
|
2277
|
+
if (!entries.has(base))
|
|
2278
|
+
missing.add(path.join(dir, base));
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
return missing;
|
|
2282
|
+
}
|
|
2115
2283
|
/** Query sessions from the database, applying filters and ordering by last-activity descending (default). */
|
|
2116
2284
|
export function querySessions(options = {}) {
|
|
2117
2285
|
const db = getDB();
|
|
@@ -2124,11 +2292,18 @@ export function querySessions(options = {}) {
|
|
|
2124
2292
|
: '';
|
|
2125
2293
|
// NULLs last so unpriced / duration-less rows never crowd out real data when
|
|
2126
2294
|
// sorting by cost or duration. timestamp is never null (NOT NULL column).
|
|
2295
|
+
// Default sort is the bare `last_activity` column, not `IFNULL(last_activity,
|
|
2296
|
+
// timestamp)` — the v35 migration backfills every row so last_activity is
|
|
2297
|
+
// never NULL, and every upsert path (resolveLastActivity) keeps it that way
|
|
2298
|
+
// going forward. Wrapping the column in IFNULL() defeats
|
|
2299
|
+
// idx_sessions_last_activity (SQLite can't use an index on an expression that
|
|
2300
|
+
// isn't the bare column); the bare column lets the planner walk the index
|
|
2301
|
+
// instead of sorting the whole result set (RUSH-2211).
|
|
2127
2302
|
const orderClause = options.sortBy === 'cost'
|
|
2128
2303
|
? 'ORDER BY cost_usd IS NULL, cost_usd DESC, timestamp DESC'
|
|
2129
2304
|
: options.sortBy === 'duration'
|
|
2130
2305
|
? 'ORDER BY duration_ms IS NULL, duration_ms DESC, timestamp DESC'
|
|
2131
|
-
: 'ORDER BY
|
|
2306
|
+
: 'ORDER BY last_activity DESC, timestamp DESC';
|
|
2132
2307
|
const sql = `SELECT * FROM sessions ${clause} ${orderClause} ${limitClause}`;
|
|
2133
2308
|
const rows = db.prepare(sql).all(...params);
|
|
2134
2309
|
if (options.skipExistenceCheck) {
|
|
@@ -2141,7 +2316,8 @@ export function querySessions(options = {}) {
|
|
|
2141
2316
|
// surfacing in the Factory UI if any code path forgets to rewrite (#136).
|
|
2142
2317
|
// Synthetic rows (OpenClaw channels/cron — see scanOpenClawIncremental) carry
|
|
2143
2318
|
// an empty file_path and are exempt; they're keyed by CLI output, not files.
|
|
2144
|
-
const
|
|
2319
|
+
const missingPaths = findMissingFilePaths(rows.map(r => r.file_path).filter((p) => !!p));
|
|
2320
|
+
const missing = rows.filter(r => r.file_path && missingPaths.has(r.file_path));
|
|
2145
2321
|
if (missing.length > 0) {
|
|
2146
2322
|
const purge = db.transaction(() => {
|
|
2147
2323
|
for (const row of missing)
|
|
@@ -2622,6 +2798,20 @@ export function buildFtsQuery(input) {
|
|
|
2622
2798
|
const expr = terms.map(t => `${t}*`).join(' OR ');
|
|
2623
2799
|
return { expr, terms };
|
|
2624
2800
|
}
|
|
2801
|
+
/**
|
|
2802
|
+
* Build a `label:(...)` FTS5 column-filter MATCH expression for the label
|
|
2803
|
+
* tier. Unlike `buildFtsQuery` (2-char floor, tuned for full-content search),
|
|
2804
|
+
* this allows 1-char terms: label search is the interactive type-ahead path —
|
|
2805
|
+
* the query grows one keystroke at a time, so a single character has to be
|
|
2806
|
+
* indexable too. Terms are filtered to `[a-z0-9]` before being embedded in the
|
|
2807
|
+
* expression string, so there's no FTS5 syntax injection from user input.
|
|
2808
|
+
*/
|
|
2809
|
+
function buildLabelFtsQuery(input) {
|
|
2810
|
+
const terms = input.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length >= 1);
|
|
2811
|
+
if (terms.length === 0)
|
|
2812
|
+
return '';
|
|
2813
|
+
return `label:(${terms.map(t => `${t}*`).join(' OR ')})`;
|
|
2814
|
+
}
|
|
2625
2815
|
/**
|
|
2626
2816
|
* Label-first search. Sessions whose custom label substring-matches the query
|
|
2627
2817
|
* always rank ahead of FTS5 hits — this gives predictable behavior when a user
|
|
@@ -2649,10 +2839,34 @@ export function ftsSearch(input, limit = 200) {
|
|
|
2649
2839
|
// its `label` — set by an agent title / `/rename`, or seeded at launch from
|
|
2650
2840
|
// `agents run --name`. Typing it resolves the session ahead of any FTS content
|
|
2651
2841
|
// hit.
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2842
|
+
//
|
|
2843
|
+
// Candidates come from the FTS5 `label` column, not a raw `LOWER(label) LIKE
|
|
2844
|
+
// '%q%'` scan of `sessions`: a leading wildcard can't use any index, so on a
|
|
2845
|
+
// large session table that was a full-table scan on every keystroke of
|
|
2846
|
+
// interactive search (RUSH-2211). `session_text.label` is kept 1:1 with
|
|
2847
|
+
// `sessions.label` by every upsert path (storedFtsLabel), so this is the
|
|
2848
|
+
// same data, indexed. Token-prefix matching seeks the FTS index instead of
|
|
2849
|
+
// scanning every row, at the cost of only matching at token boundaries — a
|
|
2850
|
+
// substring inside a single token (e.g. "ckf" inside "quickfix") no longer
|
|
2851
|
+
// matches, since FTS5 only indexes prefixes of whole tokens, not arbitrary
|
|
2852
|
+
// interior slices. (A slice spanning a token boundary, like "ix-b" inside
|
|
2853
|
+
// "fix-bug", still matches: "ix-b" tokenizes to "ix" + "b", and "b" is a
|
|
2854
|
+
// valid prefix of the "bug" token.) That's the accepted trade-off for an
|
|
2855
|
+
// indexable interactive path; the exact/prefix/contains scoring below still
|
|
2856
|
+
// runs in JS over the FTS candidate set, so ranking among real matches is
|
|
2857
|
+
// unchanged. Only a query with no indexable token (rare — e.g.
|
|
2858
|
+
// punctuation-only input) falls back to the direct scan rather than
|
|
2859
|
+
// silently dropping the tier.
|
|
2860
|
+
const labelMatchExpr = buildLabelFtsQuery(input);
|
|
2861
|
+
const labelRows = labelMatchExpr
|
|
2862
|
+
? db.prepare(`
|
|
2863
|
+
SELECT session_id AS id, label FROM session_text
|
|
2864
|
+
WHERE session_text MATCH ?
|
|
2865
|
+
`).all(labelMatchExpr)
|
|
2866
|
+
: db.prepare(`
|
|
2867
|
+
SELECT id, label FROM sessions
|
|
2868
|
+
WHERE label IS NOT NULL AND LOWER(label) LIKE ?
|
|
2869
|
+
`).all(`%${lower}%`);
|
|
2656
2870
|
let hasExactLabelMatch = false;
|
|
2657
2871
|
for (const row of labelRows) {
|
|
2658
2872
|
// Score the label by match quality (exact > prefix > contains).
|
|
@@ -690,6 +690,7 @@ export declare function __resetCodexScanBranchCountsForTest(): void;
|
|
|
690
690
|
export declare function readCursorMeta(filePath: string, currentVersion?: string): {
|
|
691
691
|
meta: SessionMeta;
|
|
692
692
|
content: string;
|
|
693
|
+
events: SessionEvent[];
|
|
693
694
|
} | null;
|
|
694
695
|
/** Parse a single Kimi session state.json file to extract session metadata. */
|
|
695
696
|
export declare function readKimiMeta(filePath: string, priorRow?: {
|
|
@@ -877,6 +877,7 @@ async function scanRoutineArchivesIncremental(agent, onProgress) {
|
|
|
877
877
|
meta: result.meta,
|
|
878
878
|
content: result.content,
|
|
879
879
|
scan,
|
|
880
|
+
events: result.events,
|
|
880
881
|
toolCalls: result.toolCalls,
|
|
881
882
|
toolIndexMode: result.toolIndexMode,
|
|
882
883
|
});
|
|
@@ -1756,7 +1757,7 @@ async function scanAntigravityIncremental(onProgress) {
|
|
|
1756
1757
|
const result = readAntigravityMeta(filePath, currentVersion);
|
|
1757
1758
|
if (result && !seen.has(result.meta.id)) {
|
|
1758
1759
|
seen.add(result.meta.id);
|
|
1759
|
-
entries.push({ meta: result.meta, content: result.content, scan });
|
|
1760
|
+
entries.push({ meta: result.meta, content: result.content, scan, events: result.events });
|
|
1760
1761
|
}
|
|
1761
1762
|
else {
|
|
1762
1763
|
touched.push({ filePath, scan });
|
|
@@ -1802,23 +1803,28 @@ function readAntigravityMeta(filePath, currentVersion) {
|
|
|
1802
1803
|
topic: topic ? topic.slice(0, 120) : undefined,
|
|
1803
1804
|
messageCount: events.length,
|
|
1804
1805
|
};
|
|
1805
|
-
return { meta, content: contentParts.join('\n') };
|
|
1806
|
+
return { meta, content: contentParts.join('\n'), events };
|
|
1806
1807
|
}
|
|
1807
1808
|
// ---------------------------------------------------------------------------
|
|
1808
1809
|
// OpenCode
|
|
1809
1810
|
// ---------------------------------------------------------------------------
|
|
1810
1811
|
const OPENCODE_DB = path.join(HOME, '.local', 'share', 'opencode', 'opencode.db');
|
|
1811
1812
|
let cachedOpenCodeAccount;
|
|
1812
|
-
/**
|
|
1813
|
-
|
|
1813
|
+
/**
|
|
1814
|
+
* Query the active OpenCode account email from its SQLite database.
|
|
1815
|
+
*
|
|
1816
|
+
* Takes the scan's already-open handle so a scan opens `opencode.db` exactly
|
|
1817
|
+
* once (RUSH-2210); opens its own only when called without one.
|
|
1818
|
+
*/
|
|
1819
|
+
function getOpenCodeAccount(handle) {
|
|
1814
1820
|
if (cachedOpenCodeAccount !== undefined)
|
|
1815
1821
|
return cachedOpenCodeAccount || undefined;
|
|
1816
1822
|
// Read through the node/bun SQLite wrapper (not the `sqlite3` CLI) so this
|
|
1817
1823
|
// works on every OS — the CLI is absent on Windows.
|
|
1818
|
-
let
|
|
1824
|
+
let owned;
|
|
1819
1825
|
try {
|
|
1820
|
-
|
|
1821
|
-
|
|
1826
|
+
const db = handle ?? (fs.existsSync(OPENCODE_DB) ? (owned = new Database(OPENCODE_DB)) : undefined);
|
|
1827
|
+
if (db) {
|
|
1822
1828
|
const row = db
|
|
1823
1829
|
.prepare('SELECT email FROM control_account WHERE active=1 LIMIT 1;')
|
|
1824
1830
|
.get();
|
|
@@ -1832,13 +1838,63 @@ async function getOpenCodeAccount() {
|
|
|
1832
1838
|
catch { /* DB not accessible, sqlite module unavailable, or query failed */ }
|
|
1833
1839
|
finally {
|
|
1834
1840
|
try {
|
|
1835
|
-
|
|
1841
|
+
owned?.close();
|
|
1836
1842
|
}
|
|
1837
1843
|
catch { /* best-effort close */ }
|
|
1838
1844
|
}
|
|
1839
1845
|
cachedOpenCodeAccount = '';
|
|
1840
1846
|
return undefined;
|
|
1841
1847
|
}
|
|
1848
|
+
/**
|
|
1849
|
+
* The per-session ledger stamp for an OpenCode row: the newest write time across
|
|
1850
|
+
* the session's own row, its messages, and its parts, paired with the total byte
|
|
1851
|
+
* length of that session's message + part payloads.
|
|
1852
|
+
*
|
|
1853
|
+
* OpenCode keeps every session in ONE shared SQLite file, so that file's
|
|
1854
|
+
* mtime/size changes whenever *any* session is written. Stamping each session
|
|
1855
|
+
* with the whole-DB stat therefore invalidated every indexed session on every
|
|
1856
|
+
* scan: up to `LIMIT 1000` sessions were re-emitted into `upsertSessionsBatch`,
|
|
1857
|
+
* and its enrichment step re-opened `opencode.db` once per re-emitted entry via
|
|
1858
|
+
* `parseSession` (RUSH-2210).
|
|
1859
|
+
*
|
|
1860
|
+
* `s.time_updated` alone is NOT that session's change signal, which is the trap
|
|
1861
|
+
* this shape exists to avoid. On a real database, parts land long after the
|
|
1862
|
+
* session row was last touched — e.g. `ses_3955202dfffe…` carried
|
|
1863
|
+
* `time_updated = 1771316403087` with its newest part at `1771331512162`, over
|
|
1864
|
+
* four hours later. So the stamp maxes `time_updated` with the newest message
|
|
1865
|
+
* and part times, and pairs it with a byte total that also moves when an
|
|
1866
|
+
* existing part's `data` is rewritten in place (a streaming turn) without any
|
|
1867
|
+
* new row or timestamp.
|
|
1868
|
+
*
|
|
1869
|
+
* The byte total is a REAL size, not a repurposed counter, because
|
|
1870
|
+
* `sessions.file_size` is read back as bytes elsewhere: `ensureToolIndex` uses
|
|
1871
|
+
* it as the tool-backfill byte budget and `toolCallsForBackfill` as the 16 MiB
|
|
1872
|
+
* in-memory parser cap (`tool-index.ts`). A message/part byte total is the
|
|
1873
|
+
* honest cost of parsing that session — strictly better than the whole-DB size
|
|
1874
|
+
* this column used to hold for every OpenCode row.
|
|
1875
|
+
*
|
|
1876
|
+
* The size is `LENGTH(CAST(data AS BLOB))`, not `LENGTH(data)`: SQLite's
|
|
1877
|
+
* `LENGTH()` on a TEXT column counts CHARACTERS, so a CJK/emoji-heavy transcript
|
|
1878
|
+
* would under-report its real size by up to 4x — and this number is a byte
|
|
1879
|
+
* budget downstream.
|
|
1880
|
+
*
|
|
1881
|
+
* A degenerate row — nothing but zeros/NaN for every time AND no payload at all
|
|
1882
|
+
* — falls back to the whole-DB stat, keeping the pre-RUSH-2210 always-rescan
|
|
1883
|
+
* behavior for exactly those rows rather than pinning them to a stamp that never
|
|
1884
|
+
* changes.
|
|
1885
|
+
*/
|
|
1886
|
+
function openCodeSessionStamp(times, bytes, dbScan) {
|
|
1887
|
+
// A positive time, not merely a finite one: the SQL COALESCEs the aggregates
|
|
1888
|
+
// to 0, so `Number.isFinite` alone would accept a row that told us nothing and
|
|
1889
|
+
// this branch could never fire.
|
|
1890
|
+
const known = [times.timeUpdated, times.lastMessageAt, times.lastPartAt, times.timeCreated]
|
|
1891
|
+
.filter(t => Number.isFinite(t) && t > 0);
|
|
1892
|
+
const size = (Number.isFinite(bytes.messageBytes) ? bytes.messageBytes : 0)
|
|
1893
|
+
+ (Number.isFinite(bytes.partBytes) ? bytes.partBytes : 0);
|
|
1894
|
+
if (known.length === 0 && size === 0)
|
|
1895
|
+
return dbScan;
|
|
1896
|
+
return { fileMtimeMs: known.length === 0 ? 0 : Math.floor(Math.max(...known)), fileSize: size };
|
|
1897
|
+
}
|
|
1842
1898
|
/** Scan OpenCode sessions from its SQLite database when the DB file has changed. */
|
|
1843
1899
|
async function scanOpenCodeIncremental() {
|
|
1844
1900
|
if (!fs.existsSync(OPENCODE_DB))
|
|
@@ -1846,8 +1902,9 @@ async function scanOpenCodeIncremental() {
|
|
|
1846
1902
|
const stat = safeStatSync(OPENCODE_DB);
|
|
1847
1903
|
if (!stat)
|
|
1848
1904
|
return;
|
|
1849
|
-
// OpenCode is one big DB
|
|
1850
|
-
//
|
|
1905
|
+
// OpenCode is one big DB. Its mtime/size is the cheap "did ANYTHING change"
|
|
1906
|
+
// short-circuit for the whole harness — not the per-session stamp, which each
|
|
1907
|
+
// row carries itself (see openCodeSessionStamp, RUSH-2210).
|
|
1851
1908
|
const currentScan = {
|
|
1852
1909
|
fileMtimeMs: Math.floor(stat.mtimeMs),
|
|
1853
1910
|
fileSize: stat.size,
|
|
@@ -1856,7 +1913,6 @@ async function scanOpenCodeIncremental() {
|
|
|
1856
1913
|
if (prev && prev.fileMtimeMs === currentScan.fileMtimeMs && prev.fileSize === currentScan.fileSize) {
|
|
1857
1914
|
return;
|
|
1858
1915
|
}
|
|
1859
|
-
const account = await getOpenCodeAccount();
|
|
1860
1916
|
const currentVersion = await getCurrentAgentVersion('opencode');
|
|
1861
1917
|
// Read through the node/bun SQLite wrapper (not the `sqlite3` CLI) so this
|
|
1862
1918
|
// works on every OS — the CLI is absent on Windows.
|
|
@@ -1871,14 +1927,28 @@ async function scanOpenCodeIncremental() {
|
|
|
1871
1927
|
s.time_created AS time_created,
|
|
1872
1928
|
s.time_updated AS time_updated,
|
|
1873
1929
|
COALESCE(stats.message_count, 0) AS message_count,
|
|
1930
|
+
COALESCE(stats.last_message_at, 0) AS last_message_at,
|
|
1931
|
+
COALESCE(stats.message_bytes, 0) AS message_bytes,
|
|
1932
|
+
COALESCE(parts.last_part_at, 0) AS last_part_at,
|
|
1933
|
+
COALESCE(parts.part_bytes, 0) AS part_bytes,
|
|
1874
1934
|
stats.token_count AS token_count,
|
|
1875
1935
|
stats.output_tokens AS output_tokens,
|
|
1876
1936
|
COALESCE(stats.has_token_data, 0) AS has_token_data
|
|
1877
1937
|
FROM session s
|
|
1938
|
+
LEFT JOIN (
|
|
1939
|
+
SELECT
|
|
1940
|
+
session_id,
|
|
1941
|
+
MAX(time_created) AS last_part_at,
|
|
1942
|
+
SUM(LENGTH(CAST(data AS BLOB))) AS part_bytes
|
|
1943
|
+
FROM part
|
|
1944
|
+
GROUP BY session_id
|
|
1945
|
+
) parts ON parts.session_id = s.id
|
|
1878
1946
|
LEFT JOIN (
|
|
1879
1947
|
SELECT
|
|
1880
1948
|
session_id,
|
|
1881
1949
|
COUNT(*) AS message_count,
|
|
1950
|
+
MAX(time_created) AS last_message_at,
|
|
1951
|
+
SUM(LENGTH(CAST(data AS BLOB))) AS message_bytes,
|
|
1882
1952
|
SUM(
|
|
1883
1953
|
COALESCE(json_extract(data, '$.tokens.input'), 0) +
|
|
1884
1954
|
COALESCE(json_extract(data, '$.tokens.output'), 0) +
|
|
@@ -1896,16 +1966,42 @@ async function scanOpenCodeIncremental() {
|
|
|
1896
1966
|
LIMIT 1000;
|
|
1897
1967
|
`.replace(/\n/g, ' ');
|
|
1898
1968
|
db = new Database(OPENCODE_DB);
|
|
1969
|
+
const account = getOpenCodeAccount(db);
|
|
1899
1970
|
const rows = db.prepare(query).all();
|
|
1900
|
-
|
|
1901
|
-
|
|
1971
|
+
// Two passes. First derive each row's identity + per-session stamp, then
|
|
1972
|
+
// bulk-load the prior stamps in ONE query and keep only the rows that
|
|
1973
|
+
// actually changed. Skipped rows never reach upsertSessionsBatch, so they
|
|
1974
|
+
// never pay its per-entry `parseSession` re-open of this same DB.
|
|
1975
|
+
const asInt = (v) => typeof v === 'number' ? v : parseInt(String(v), 10);
|
|
1976
|
+
const candidates = rows.flatMap(row => {
|
|
1902
1977
|
const id = typeof row.id === 'string' ? row.id : '';
|
|
1903
1978
|
if (!id)
|
|
1904
|
-
|
|
1979
|
+
return [];
|
|
1980
|
+
const filePath = `${OPENCODE_DB}#${id}`;
|
|
1981
|
+
return [{
|
|
1982
|
+
row,
|
|
1983
|
+
id,
|
|
1984
|
+
filePath,
|
|
1985
|
+
scan: openCodeSessionStamp({
|
|
1986
|
+
timeUpdated: asInt(row.time_updated),
|
|
1987
|
+
timeCreated: asInt(row.time_created),
|
|
1988
|
+
lastMessageAt: asInt(row.last_message_at),
|
|
1989
|
+
lastPartAt: asInt(row.last_part_at),
|
|
1990
|
+
}, { messageBytes: asInt(row.message_bytes), partBytes: asInt(row.part_bytes) }, currentScan),
|
|
1991
|
+
}];
|
|
1992
|
+
});
|
|
1993
|
+
const priorStamps = getScanStampsForPaths(candidates.map(c => c.filePath));
|
|
1994
|
+
const changed = candidates.filter(c => {
|
|
1995
|
+
const prevStamp = priorStamps.get(c.filePath);
|
|
1996
|
+
return !prevStamp
|
|
1997
|
+
|| prevStamp.fileMtimeMs !== c.scan.fileMtimeMs
|
|
1998
|
+
|| prevStamp.fileSize !== c.scan.fileSize;
|
|
1999
|
+
});
|
|
2000
|
+
const entries = [];
|
|
2001
|
+
for (const { row, id, filePath, scan } of changed) {
|
|
1905
2002
|
const title = typeof row.title === 'string' ? row.title : '';
|
|
1906
2003
|
const directory = typeof row.directory === 'string' ? row.directory : '';
|
|
1907
2004
|
const version = typeof row.version === 'string' ? row.version : '';
|
|
1908
|
-
const asInt = (v) => typeof v === 'number' ? v : parseInt(String(v), 10);
|
|
1909
2005
|
const timeCreated = asInt(row.time_created);
|
|
1910
2006
|
const timeUpdated = asInt(row.time_updated);
|
|
1911
2007
|
const messageCount = asInt(row.message_count);
|
|
@@ -1926,7 +2022,7 @@ async function scanOpenCodeIncremental() {
|
|
|
1926
2022
|
lastActivity,
|
|
1927
2023
|
project: directory ? path.basename(directory) : undefined,
|
|
1928
2024
|
cwd: directory ? normalizeCwd(directory) : undefined,
|
|
1929
|
-
filePath
|
|
2025
|
+
filePath,
|
|
1930
2026
|
version: resolveSessionVersion('opencode', OPENCODE_DB, version || undefined, currentVersion),
|
|
1931
2027
|
account,
|
|
1932
2028
|
topic,
|
|
@@ -1934,7 +2030,7 @@ async function scanOpenCodeIncremental() {
|
|
|
1934
2030
|
tokenCount: hasTokenData && !Number.isNaN(tokenCount) ? tokenCount : undefined,
|
|
1935
2031
|
outputTokens: hasTokenData && !Number.isNaN(outputTokens) ? outputTokens : undefined,
|
|
1936
2032
|
};
|
|
1937
|
-
entries.push({ meta, content: topic || '', scan
|
|
2033
|
+
entries.push({ meta, content: topic || '', scan });
|
|
1938
2034
|
}
|
|
1939
2035
|
upsertSessionsBatch(entries);
|
|
1940
2036
|
// Stamp the OpenCode DB itself so we can short-circuit on the next run.
|
|
@@ -3902,7 +3998,7 @@ async function scanCursorIncremental(onProgress) {
|
|
|
3902
3998
|
const result = readCursorMeta(filePath, currentVersion);
|
|
3903
3999
|
if (result && !seen.has(result.meta.id)) {
|
|
3904
4000
|
seen.add(result.meta.id);
|
|
3905
|
-
entries.push({ meta: result.meta, content: result.content, scan });
|
|
4001
|
+
entries.push({ meta: result.meta, content: result.content, scan, events: result.events });
|
|
3906
4002
|
}
|
|
3907
4003
|
else {
|
|
3908
4004
|
touched.push({ filePath, scan });
|
|
@@ -3998,7 +4094,7 @@ export function readCursorMeta(filePath, currentVersion) {
|
|
|
3998
4094
|
messageCount: events.filter((event) => event.type === 'message').length,
|
|
3999
4095
|
todos: extractTodoProgressFromEvents(events),
|
|
4000
4096
|
};
|
|
4001
|
-
return { meta, content: userTexts.join('\n') };
|
|
4097
|
+
return { meta, content: userTexts.join('\n'), events };
|
|
4002
4098
|
}
|
|
4003
4099
|
// ---------------------------------------------------------------------------
|
|
4004
4100
|
// Kimi
|
|
@@ -71,6 +71,10 @@ export interface InsightFacets {
|
|
|
71
71
|
assistantTurns: number;
|
|
72
72
|
toolCount: number;
|
|
73
73
|
errorCount: number;
|
|
74
|
+
/** Deterministic evidence buckets used by the actions-forward report. */
|
|
75
|
+
frictionSignals: Record<string, number>;
|
|
76
|
+
correctionSignals: Record<string, number>;
|
|
77
|
+
automationSignals: Record<string, number>;
|
|
74
78
|
}
|
|
75
79
|
/**
|
|
76
80
|
* Compute every behavioural facet of one session from its parsed events.
|
|
@@ -124,3 +128,17 @@ export declare function topEntries(counts: Record<string, number>, limit: number
|
|
|
124
128
|
name: string;
|
|
125
129
|
count: number;
|
|
126
130
|
}>;
|
|
131
|
+
export type InsightActionCategory = 'rule' | 'skill' | 'automation' | 'product';
|
|
132
|
+
export interface InsightAction {
|
|
133
|
+
priority: 'high' | 'medium' | 'low';
|
|
134
|
+
category: InsightActionCategory;
|
|
135
|
+
action: string;
|
|
136
|
+
evidenceCount: number;
|
|
137
|
+
sampleSessionIds: string[];
|
|
138
|
+
}
|
|
139
|
+
export interface SessionFacetEvidence {
|
|
140
|
+
id: string;
|
|
141
|
+
facets: InsightFacets;
|
|
142
|
+
}
|
|
143
|
+
/** Build a stable, evidence-backed action list without exposing transcript text. */
|
|
144
|
+
export declare function buildInsightActions(sessions: SessionFacetEvidence[]): InsightAction[];
|