context-mode 1.0.146 → 1.0.148
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +26 -23
- package/bin/statusline.mjs +22 -9
- package/build/adapters/base.d.ts +9 -4
- package/build/adapters/base.js +16 -7
- package/build/adapters/codex/index.d.ts +8 -1
- package/build/adapters/codex/index.js +43 -6
- package/build/adapters/openclaw/index.d.ts +11 -2
- package/build/adapters/openclaw/index.js +12 -3
- package/build/adapters/pi/mcp-bridge.d.ts +8 -0
- package/build/adapters/pi/mcp-bridge.js +118 -15
- package/build/adapters/types.d.ts +11 -2
- package/build/cli.d.ts +2 -0
- package/build/cli.js +87 -20
- package/build/search/auto-memory.d.ts +6 -1
- package/build/search/auto-memory.js +11 -2
- package/build/server.js +346 -106
- package/build/session/analytics.d.ts +19 -0
- package/build/session/analytics.js +71 -21
- package/build/session/db.d.ts +81 -0
- package/build/session/db.js +282 -20
- package/build/session/extract.js +16 -0
- package/build/truncate.d.ts +15 -0
- package/build/truncate.js +28 -0
- package/cli.bundle.mjs +435 -350
- package/hooks/core/routing.mjs +4 -4
- package/hooks/routing-block.mjs +18 -23
- package/hooks/session-db.bundle.mjs +21 -19
- package/hooks/session-extract.bundle.mjs +2 -2
- package/hooks/session-helpers.mjs +13 -2
- package/hooks/session-snapshot.bundle.mjs +7 -7
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -2
- package/server.bundle.mjs +383 -300
|
@@ -427,6 +427,25 @@ export declare function getRealBytesStats(opts: {
|
|
|
427
427
|
sessionId?: string;
|
|
428
428
|
sessionsDir?: string;
|
|
429
429
|
worktreeHash?: string;
|
|
430
|
+
/**
|
|
431
|
+
* v1.0.148 follow-up (Bug E+F): when set, the function aggregates across
|
|
432
|
+
* EVERY session whose `session_meta.project_dir` matches this value, not
|
|
433
|
+
* just one session_id. Resolves the per-conversation under-attribution:
|
|
434
|
+
* one Claude Code conversation typically spans many session_ids (resume
|
|
435
|
+
* cycles, /compact rebirths, PID sub-process sessions spawned by
|
|
436
|
+
* ctx_execute), so a single-session_id filter loses the sandbox-burst
|
|
437
|
+
* bytes_avoided that all live under the conversation's cwd.
|
|
438
|
+
*
|
|
439
|
+
* Uses a META subquery (`session_id IN (SELECT session_id FROM
|
|
440
|
+
* session_meta WHERE project_dir = ?)`), then sums ALL events for
|
|
441
|
+
* matching sessions regardless of their event-level project_dir
|
|
442
|
+
* (sandbox-burst events write `project_dir = ''` even when the
|
|
443
|
+
* META row carries the parent cwd — see Bug F).
|
|
444
|
+
*
|
|
445
|
+
* Mutually exclusive with `sessionId`. When both are set, `sessionId`
|
|
446
|
+
* wins for back-compat.
|
|
447
|
+
*/
|
|
448
|
+
projectDir?: string;
|
|
430
449
|
/**
|
|
431
450
|
* v1.0.133 Slice 3: when set alongside `sessionId`, the function joins
|
|
432
451
|
* the FTS5 content DB at this path and folds chunk bytes into
|
|
@@ -13,6 +13,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
|
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { join, sep } from "node:path";
|
|
15
15
|
import { loadDatabase as loadDatabaseImpl } from "../db-base.js";
|
|
16
|
+
import { ensureSessionEventsSchema } from "./db.js";
|
|
16
17
|
import { resolveClaudeConfigDir } from "../util/claude-config.js";
|
|
17
18
|
function semverNewer(a, b) {
|
|
18
19
|
const pa = a.split(".").map(Number);
|
|
@@ -857,6 +858,15 @@ export function getRealBytesStats(opts) {
|
|
|
857
858
|
// don't need to type-narrow per row.
|
|
858
859
|
for (const file of dbFiles) {
|
|
859
860
|
const dbPath = join(sessionsDir, file);
|
|
861
|
+
// v1.0.148 hotfix: historical DBs were created with pre-v1.0.130
|
|
862
|
+
// schema (no bytes_avoided / bytes_returned / project_dir columns).
|
|
863
|
+
// The SELECT below references those columns, so without an in-place
|
|
864
|
+
// migration the prepare() throws and the surrounding catch silently
|
|
865
|
+
// skips the WHOLE DB — losing even the LENGTH(data) signal. Run the
|
|
866
|
+
// shared migration helper before opening readonly. Idempotent: a
|
|
867
|
+
// PRAGMA check inside the helper short-circuits when the DB is
|
|
868
|
+
// already current, so post-first-read calls are cheap.
|
|
869
|
+
ensureSessionEventsSchema(dbPath, DatabaseCtor);
|
|
860
870
|
try {
|
|
861
871
|
const sdb = new DatabaseCtor(dbPath, { readonly: true });
|
|
862
872
|
try {
|
|
@@ -878,6 +888,36 @@ export function getRealBytesStats(opts) {
|
|
|
878
888
|
}
|
|
879
889
|
catch { /* old schema */ }
|
|
880
890
|
}
|
|
891
|
+
else if (opts.projectDir) {
|
|
892
|
+
// Bug E+F: META-scoped aggregation. Take every session_id whose
|
|
893
|
+
// session_meta.project_dir matches, then sum ALL of those
|
|
894
|
+
// sessions' events regardless of the events' own project_dir
|
|
895
|
+
// (sandbox-burst PID sessions write empty event-level project_dir
|
|
896
|
+
// even when their META carries the parent cwd).
|
|
897
|
+
const row = sdb.prepare(`SELECT
|
|
898
|
+
COALESCE(SUM(LENGTH(data)), 0) AS data_bytes,
|
|
899
|
+
COALESCE(SUM(bytes_avoided), 0) AS bytes_avoided,
|
|
900
|
+
COALESCE(SUM(bytes_returned), 0) AS bytes_returned
|
|
901
|
+
FROM session_events
|
|
902
|
+
WHERE session_id IN (
|
|
903
|
+
SELECT session_id FROM session_meta WHERE project_dir = ?
|
|
904
|
+
)`).get(opts.projectDir);
|
|
905
|
+
if (row) {
|
|
906
|
+
eventDataBytes += Number(row.data_bytes ?? 0);
|
|
907
|
+
bytesAvoided += Number(row.bytes_avoided ?? 0);
|
|
908
|
+
bytesReturned += Number(row.bytes_returned ?? 0);
|
|
909
|
+
}
|
|
910
|
+
try {
|
|
911
|
+
const snap = sdb.prepare(`SELECT COALESCE(SUM(LENGTH(snapshot)), 0) AS bytes
|
|
912
|
+
FROM session_resume
|
|
913
|
+
WHERE session_id IN (
|
|
914
|
+
SELECT session_id FROM session_meta WHERE project_dir = ?
|
|
915
|
+
)`).get(opts.projectDir);
|
|
916
|
+
if (snap?.bytes)
|
|
917
|
+
snapshotBytes += Number(snap.bytes);
|
|
918
|
+
}
|
|
919
|
+
catch { /* old schema */ }
|
|
920
|
+
}
|
|
881
921
|
else {
|
|
882
922
|
const row = sdb.prepare(`SELECT
|
|
883
923
|
COALESCE(SUM(LENGTH(data)), 0) AS data_bytes,
|
|
@@ -1504,34 +1544,44 @@ function renderNarrative5Section(args) {
|
|
|
1504
1544
|
out.push(` Without that, you'd be re-explaining everything to a blank model right now.`);
|
|
1505
1545
|
}
|
|
1506
1546
|
out.push("");
|
|
1507
|
-
// Without/With bars —
|
|
1547
|
+
// Without/With bars — strict compression (v1.0.148, Bug G / ADR-0004).
|
|
1508
1548
|
//
|
|
1509
|
-
// Honest definitions
|
|
1510
|
-
// Without = bytes the model WOULD have re-seen
|
|
1511
|
-
//
|
|
1549
|
+
// Honest definitions:
|
|
1550
|
+
// Without = bytes the model WOULD have re-seen if context-mode
|
|
1551
|
+
// had not diverted them
|
|
1552
|
+
// = bytesAvoided + bytesReturned
|
|
1512
1553
|
// With = bytes the model ACTUALLY re-saw after context-mode
|
|
1513
|
-
// =
|
|
1554
|
+
// = max(1, bytesReturned)
|
|
1514
1555
|
//
|
|
1515
|
-
// Why eventDataBytes
|
|
1516
|
-
// `eventDataBytes` is the raw payload
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1519
|
-
//
|
|
1520
|
-
//
|
|
1521
|
-
//
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
1556
|
+
// Why eventDataBytes is excluded from this ratio:
|
|
1557
|
+
// `eventDataBytes` is the raw hook payload (tool args, prompt
|
|
1558
|
+
// body) we captured for the knowledge base. Those bytes are
|
|
1559
|
+
// analytics infrastructure — they NEVER enter the model context
|
|
1560
|
+
// window. Including them on either side (as v1.0.134 SLICE B did
|
|
1561
|
+
// to dodge a degenerate 100% bar) misrepresents context cost.
|
|
1562
|
+
// SLICE B was an incidental fix that crushed the displayed
|
|
1563
|
+
// percentage from ~95% (the true compression ratio) to ~56% on
|
|
1564
|
+
// live conversations. eventDataBytes is rendered in Section 2
|
|
1565
|
+
// (captures count), not in this Section 1 Without/With bar.
|
|
1524
1566
|
//
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
1567
|
+
// Empty-state branch:
|
|
1568
|
+
// If neither bytesAvoided nor bytesReturned has been measured yet
|
|
1569
|
+
// (early in a session, schema-migration recovery in progress, or
|
|
1570
|
+
// tool-heavy work that hasn't re-hit the index), we do NOT draw
|
|
1571
|
+
// a degenerate 0% / 100% bar. We emit one honest hint line and
|
|
1572
|
+
// skip the bar — honesty over decoration.
|
|
1528
1573
|
const realConv = realBytes?.conversation;
|
|
1529
1574
|
const measuredAvoided = realConv?.bytesAvoided ?? 0;
|
|
1530
1575
|
const measuredReturned = realConv?.bytesReturned ?? 0;
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1576
|
+
if (measuredAvoided + measuredReturned === 0) {
|
|
1577
|
+
// No measurable redirect activity yet — captures may exist, but
|
|
1578
|
+
// nothing has been diverted from the model context window.
|
|
1579
|
+
out.push(" No measurable redirect activity captured yet — bars will appear once context-mode diverts its first payload.");
|
|
1580
|
+
out.push("");
|
|
1581
|
+
}
|
|
1582
|
+
else {
|
|
1583
|
+
const convBytesWithout = measuredAvoided + measuredReturned;
|
|
1584
|
+
const convBytesWith = Math.max(1, measuredReturned);
|
|
1535
1585
|
const convTokensWithout = Math.max(1, Math.floor(convBytesWithout / 4));
|
|
1536
1586
|
const convTokensWith = Math.max(1, Math.floor(convBytesWith / 4));
|
|
1537
1587
|
const withoutBar = dataBar(convTokensWithout, convTokensWithout, 32);
|
package/build/session/db.d.ts
CHANGED
|
@@ -8,6 +8,42 @@
|
|
|
8
8
|
import { SQLiteBase } from "../db-base.js";
|
|
9
9
|
import type { SessionEvent } from "../types.js";
|
|
10
10
|
import type { ProjectAttribution } from "./project-attribution.js";
|
|
11
|
+
declare const STORAGE_ROOT_ENV: "CONTEXT_MODE_DIR";
|
|
12
|
+
export type StorageDirectoryKind = "session" | "content" | "stats";
|
|
13
|
+
export type StorageOverrideEnvVar = typeof STORAGE_ROOT_ENV;
|
|
14
|
+
export type StorageDirectorySource = "default" | "override";
|
|
15
|
+
export type IgnoredStorageOverrideReason = "empty";
|
|
16
|
+
export interface ResolvedStorageDir {
|
|
17
|
+
kind: StorageDirectoryKind;
|
|
18
|
+
path: string;
|
|
19
|
+
envVar: StorageOverrideEnvVar | null;
|
|
20
|
+
source: StorageDirectorySource;
|
|
21
|
+
ignoredEnvVar?: StorageOverrideEnvVar;
|
|
22
|
+
ignoredReason?: IgnoredStorageOverrideReason;
|
|
23
|
+
}
|
|
24
|
+
export declare class StorageDirectoryError extends Error {
|
|
25
|
+
readonly kind: StorageDirectoryKind;
|
|
26
|
+
readonly path: string;
|
|
27
|
+
readonly overrideEnvVar: StorageOverrideEnvVar;
|
|
28
|
+
readonly ignoredEnvVar?: StorageOverrideEnvVar;
|
|
29
|
+
readonly ignoredReason?: IgnoredStorageOverrideReason;
|
|
30
|
+
constructor(kind: StorageDirectoryKind, path: string, overrideEnvVar?: StorageOverrideEnvVar, cause?: unknown, message?: string, metadata?: Pick<ResolvedStorageDir, "ignoredEnvVar" | "ignoredReason">);
|
|
31
|
+
}
|
|
32
|
+
export interface DefaultSessionDirOptions {
|
|
33
|
+
configDir: string;
|
|
34
|
+
configDirEnv?: string;
|
|
35
|
+
legacySessionDirEnv?: string;
|
|
36
|
+
onLegacySessionDir?: (envVar: string, dir: string) => void;
|
|
37
|
+
env?: NodeJS.ProcessEnv;
|
|
38
|
+
}
|
|
39
|
+
export declare function resolveDefaultSessionDir(opts: DefaultSessionDirOptions): string;
|
|
40
|
+
export declare function resolveSessionStorageDir(getDefaultDir: () => string): ResolvedStorageDir;
|
|
41
|
+
export declare function resolveContentStorageDir(getSessionDir: () => string): ResolvedStorageDir;
|
|
42
|
+
export declare function resolveStatsStorageDir(getDefaultSessionDir: () => string): ResolvedStorageDir;
|
|
43
|
+
export declare function formatStorageDirectoryError(err: StorageDirectoryError): string;
|
|
44
|
+
export declare function describeStorageDirectorySource(dir: ResolvedStorageDir): string;
|
|
45
|
+
export declare function clearStorageDirectoryCheckCacheForTests(): void;
|
|
46
|
+
export declare function ensureWritableStorageDir(dir: ResolvedStorageDir): string;
|
|
11
47
|
export declare function normalizeWorktreePath(path: string): string;
|
|
12
48
|
export declare function getWorktreeSuffix(projectDir?: string): string;
|
|
13
49
|
export declare function _resetWorktreeSuffixCacheForTests(): void;
|
|
@@ -143,6 +179,50 @@ export interface ToolCallStats {
|
|
|
143
179
|
bytesReturned: number;
|
|
144
180
|
}>;
|
|
145
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Apply any missing post-v1.0.130 `session_events` columns to an already-
|
|
184
|
+
* open writable database handle. Idempotent — each ALTER is guarded by a
|
|
185
|
+
* PRAGMA table_xinfo check, and the project_dir index is created only
|
|
186
|
+
* when a migration actually ran. Returns true if any column was added.
|
|
187
|
+
*
|
|
188
|
+
* Used by both the SessionDB constructor (for the active DB) and the
|
|
189
|
+
* analytics aggregator (for the 100+ historical DBs that never get
|
|
190
|
+
* opened through SessionDB). ADR-0001 compatible: no EXCLUSIVE pragma,
|
|
191
|
+
* no acquireDbLock — relies on the SQLite busy_timeout + WAL semantics
|
|
192
|
+
* already provided by SQLiteBase.
|
|
193
|
+
*/
|
|
194
|
+
export declare function applyMissingSessionEventsColumns(db: {
|
|
195
|
+
pragma: (q: string) => Array<{
|
|
196
|
+
name: string;
|
|
197
|
+
}>;
|
|
198
|
+
exec: (sql: string) => void;
|
|
199
|
+
}): boolean;
|
|
200
|
+
/**
|
|
201
|
+
* Open a session DB file briefly, run any missing schema migrations,
|
|
202
|
+
* and close. Best-effort: missing tables, file-locks, corrupt files,
|
|
203
|
+
* and any DatabaseCtor error are swallowed silently — the caller
|
|
204
|
+
* (analytics aggregator) handles the readonly query that follows and
|
|
205
|
+
* will skip the DB if it remains unreadable.
|
|
206
|
+
*
|
|
207
|
+
* Lazy migration entry point for the analytics aggregator, which would
|
|
208
|
+
* otherwise read 100+ historical DBs with the old (pre-v1.0.130) schema
|
|
209
|
+
* and lose every signal (not just bytes_avoided) because the SELECT
|
|
210
|
+
* statement references columns that don't exist on legacy schemas.
|
|
211
|
+
*
|
|
212
|
+
* Two open/close cycles in the worst case (one readonly probe to detect
|
|
213
|
+
* legacy schema, one writable to migrate). For already-migrated DBs
|
|
214
|
+
* (the common case after first read), this opens writable once and
|
|
215
|
+
* exits without writing — cheaper than always-writable.
|
|
216
|
+
*/
|
|
217
|
+
export declare function ensureSessionEventsSchema(dbPath: string, DatabaseCtor: new (path: string, opts?: {
|
|
218
|
+
readonly?: boolean;
|
|
219
|
+
}) => {
|
|
220
|
+
pragma: (q: string) => Array<{
|
|
221
|
+
name: string;
|
|
222
|
+
}>;
|
|
223
|
+
exec: (sql: string) => void;
|
|
224
|
+
close: () => void;
|
|
225
|
+
}): void;
|
|
146
226
|
export declare class SessionDB extends SQLiteBase {
|
|
147
227
|
/**
|
|
148
228
|
* Cached prepared statements. Stored in a Map to avoid the JS private-field
|
|
@@ -314,3 +394,4 @@ export declare class SessionDB extends SQLiteBase {
|
|
|
314
394
|
*/
|
|
315
395
|
cleanupOldSessions(maxAgeDays?: number): number;
|
|
316
396
|
}
|
|
397
|
+
export {};
|
package/build/session/db.js
CHANGED
|
@@ -8,8 +8,203 @@
|
|
|
8
8
|
import { SQLiteBase, defaultDBPath } from "../db-base.js";
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
|
-
import { existsSync, realpathSync, renameSync } from "node:fs";
|
|
12
|
-
import {
|
|
11
|
+
import { accessSync, constants, existsSync, mkdirSync, realpathSync, renameSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
14
|
+
// ─────────────────────────────────────────────────────────
|
|
15
|
+
// Storage root resolution
|
|
16
|
+
// ─────────────────────────────────────────────────────────
|
|
17
|
+
//
|
|
18
|
+
// This lives beside the session DB path helpers because packaged hooks and the
|
|
19
|
+
// statusline already consume `hooks/session-db.bundle.mjs` as their no-build
|
|
20
|
+
// runtime bridge. Keeping the storage resolver here avoids adding a second
|
|
21
|
+
// generated hook bundle just to share CONTEXT_MODE_DIR behavior.
|
|
22
|
+
const STORAGE_ROOT_ENV = "CONTEXT_MODE_DIR";
|
|
23
|
+
const STORAGE_SESSIONS_SUBDIR = "sessions";
|
|
24
|
+
const STORAGE_CONTENT_SUBDIR = "content";
|
|
25
|
+
export class StorageDirectoryError extends Error {
|
|
26
|
+
kind;
|
|
27
|
+
path;
|
|
28
|
+
overrideEnvVar;
|
|
29
|
+
ignoredEnvVar;
|
|
30
|
+
ignoredReason;
|
|
31
|
+
constructor(kind, path, overrideEnvVar = STORAGE_ROOT_ENV, cause, message, metadata = {}) {
|
|
32
|
+
super(message ?? storageDirectoryErrorMessage(kind, path, metadata), { cause });
|
|
33
|
+
this.name = "StorageDirectoryError";
|
|
34
|
+
this.kind = kind;
|
|
35
|
+
this.path = path;
|
|
36
|
+
this.overrideEnvVar = overrideEnvVar;
|
|
37
|
+
this.ignoredEnvVar = metadata.ignoredEnvVar;
|
|
38
|
+
this.ignoredReason = metadata.ignoredReason;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const writableStorageCache = new Map();
|
|
42
|
+
export function resolveDefaultSessionDir(opts) {
|
|
43
|
+
const env = opts.env ?? process.env;
|
|
44
|
+
const legacyEnvVar = opts.legacySessionDirEnv;
|
|
45
|
+
const legacy = legacyEnvVar ? env[legacyEnvVar]?.trim() : undefined;
|
|
46
|
+
if (legacy && legacyEnvVar) {
|
|
47
|
+
opts.onLegacySessionDir?.(legacyEnvVar, legacy);
|
|
48
|
+
return legacy;
|
|
49
|
+
}
|
|
50
|
+
return join(resolveConfigDirForDefaultSession(opts.configDir, opts.configDirEnv, env), "context-mode", "sessions");
|
|
51
|
+
}
|
|
52
|
+
function resolveConfigDirForDefaultSession(configDir, configDirEnv, env) {
|
|
53
|
+
const envValue = configDirEnv ? env[configDirEnv] : undefined;
|
|
54
|
+
if (envValue && envValue.trim() !== "") {
|
|
55
|
+
return resolveConfigDirValue(envValue.trim());
|
|
56
|
+
}
|
|
57
|
+
return resolveConfigDirValue(configDir, homedir());
|
|
58
|
+
}
|
|
59
|
+
function resolveConfigDirValue(value, baseDir) {
|
|
60
|
+
if (value.startsWith("~"))
|
|
61
|
+
return resolve(homedir(), value.replace(/^~[/\\]?/, ""));
|
|
62
|
+
if (isAbsolute(value))
|
|
63
|
+
return resolve(value);
|
|
64
|
+
return baseDir ? resolve(baseDir, value) : resolve(value);
|
|
65
|
+
}
|
|
66
|
+
function invalidStorageOverride(kind, path, detail) {
|
|
67
|
+
return new StorageDirectoryError(kind, path, STORAGE_ROOT_ENV, undefined, [`Invalid ${STORAGE_ROOT_ENV} for context-mode ${kind} directory: ${detail}`, storageDirectoryHint()].join("\n"));
|
|
68
|
+
}
|
|
69
|
+
function storageOverrideRoot(kind) {
|
|
70
|
+
const raw = process.env[STORAGE_ROOT_ENV];
|
|
71
|
+
if (raw === undefined)
|
|
72
|
+
return { kind: "unset" };
|
|
73
|
+
const trimmed = raw.trim();
|
|
74
|
+
if (!trimmed) {
|
|
75
|
+
return { kind: "ignored-empty", ignoredEnvVar: STORAGE_ROOT_ENV, ignoredReason: "empty" };
|
|
76
|
+
}
|
|
77
|
+
if (!isAbsolute(trimmed)) {
|
|
78
|
+
throw invalidStorageOverride(kind, trimmed, `${STORAGE_ROOT_ENV} must be an absolute path.`);
|
|
79
|
+
}
|
|
80
|
+
return { kind: "override", root: resolve(trimmed) };
|
|
81
|
+
}
|
|
82
|
+
function ignoredStorageMetadata(root) {
|
|
83
|
+
return root.kind === "ignored-empty"
|
|
84
|
+
? { ignoredEnvVar: root.ignoredEnvVar, ignoredReason: root.ignoredReason }
|
|
85
|
+
: {};
|
|
86
|
+
}
|
|
87
|
+
function overrideStorageDir(kind, subdir) {
|
|
88
|
+
const root = storageOverrideRoot(kind);
|
|
89
|
+
if (root.kind !== "override")
|
|
90
|
+
return null;
|
|
91
|
+
return {
|
|
92
|
+
kind,
|
|
93
|
+
path: join(root.root, subdir),
|
|
94
|
+
envVar: STORAGE_ROOT_ENV,
|
|
95
|
+
source: "override",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function defaultStorageDir(kind, getDefaultDir, metadata) {
|
|
99
|
+
return {
|
|
100
|
+
kind,
|
|
101
|
+
path: resolve(getDefaultDir()),
|
|
102
|
+
envVar: null,
|
|
103
|
+
source: "default",
|
|
104
|
+
...metadata,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export function resolveSessionStorageDir(getDefaultDir) {
|
|
108
|
+
const root = storageOverrideRoot("session");
|
|
109
|
+
if (root.kind === "override") {
|
|
110
|
+
return {
|
|
111
|
+
kind: "session",
|
|
112
|
+
path: join(root.root, STORAGE_SESSIONS_SUBDIR),
|
|
113
|
+
envVar: STORAGE_ROOT_ENV,
|
|
114
|
+
source: "override",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return defaultStorageDir("session", getDefaultDir, ignoredStorageMetadata(root));
|
|
118
|
+
}
|
|
119
|
+
export function resolveContentStorageDir(getSessionDir) {
|
|
120
|
+
const override = overrideStorageDir("content", STORAGE_CONTENT_SUBDIR);
|
|
121
|
+
if (override)
|
|
122
|
+
return override;
|
|
123
|
+
const session = resolveSessionStorageDir(getSessionDir);
|
|
124
|
+
return {
|
|
125
|
+
kind: "content",
|
|
126
|
+
path: join(dirname(session.path), STORAGE_CONTENT_SUBDIR),
|
|
127
|
+
envVar: session.envVar,
|
|
128
|
+
source: session.source,
|
|
129
|
+
ignoredEnvVar: session.ignoredEnvVar,
|
|
130
|
+
ignoredReason: session.ignoredReason,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
export function resolveStatsStorageDir(getDefaultSessionDir) {
|
|
134
|
+
const override = overrideStorageDir("stats", STORAGE_SESSIONS_SUBDIR);
|
|
135
|
+
if (override)
|
|
136
|
+
return override;
|
|
137
|
+
const session = resolveSessionStorageDir(getDefaultSessionDir);
|
|
138
|
+
return {
|
|
139
|
+
kind: "stats",
|
|
140
|
+
path: session.path,
|
|
141
|
+
envVar: session.envVar,
|
|
142
|
+
source: session.source,
|
|
143
|
+
ignoredEnvVar: session.ignoredEnvVar,
|
|
144
|
+
ignoredReason: session.ignoredReason,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
export function formatStorageDirectoryError(err) {
|
|
148
|
+
return err.message;
|
|
149
|
+
}
|
|
150
|
+
export function describeStorageDirectorySource(dir) {
|
|
151
|
+
if (dir.source === "override" && dir.envVar)
|
|
152
|
+
return `via ${dir.envVar}`;
|
|
153
|
+
if (dir.ignoredEnvVar && dir.ignoredReason === "empty")
|
|
154
|
+
return `default; ignored empty ${dir.ignoredEnvVar}`;
|
|
155
|
+
return "default";
|
|
156
|
+
}
|
|
157
|
+
export function clearStorageDirectoryCheckCacheForTests() {
|
|
158
|
+
writableStorageCache.clear();
|
|
159
|
+
}
|
|
160
|
+
export function ensureWritableStorageDir(dir) {
|
|
161
|
+
const key = [
|
|
162
|
+
dir.kind,
|
|
163
|
+
dir.path,
|
|
164
|
+
dir.source,
|
|
165
|
+
dir.envVar ?? "",
|
|
166
|
+
dir.ignoredEnvVar ?? "",
|
|
167
|
+
dir.ignoredReason ?? "",
|
|
168
|
+
].join("\0");
|
|
169
|
+
const cached = writableStorageCache.get(key);
|
|
170
|
+
if (cached instanceof StorageDirectoryError)
|
|
171
|
+
throw cached;
|
|
172
|
+
if (cached === dir.path)
|
|
173
|
+
return cached;
|
|
174
|
+
try {
|
|
175
|
+
mkdirSync(dir.path, { recursive: true });
|
|
176
|
+
accessSync(dir.path, constants.W_OK);
|
|
177
|
+
writableStorageCache.set(key, dir.path);
|
|
178
|
+
return dir.path;
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
const storageErr = new StorageDirectoryError(dir.kind, pathFromStorageError(err) ?? dir.path, STORAGE_ROOT_ENV, err, undefined, { ignoredEnvVar: dir.ignoredEnvVar, ignoredReason: dir.ignoredReason });
|
|
182
|
+
writableStorageCache.set(key, storageErr);
|
|
183
|
+
throw storageErr;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function storageDirectoryErrorMessage(kind, path, metadata = {}) {
|
|
187
|
+
return [
|
|
188
|
+
`context-mode ${kind} directory is not writable: ${path}`,
|
|
189
|
+
ignoredStorageOverrideHint(metadata),
|
|
190
|
+
storageDirectoryHint(),
|
|
191
|
+
].filter(Boolean).join("\n");
|
|
192
|
+
}
|
|
193
|
+
function ignoredStorageOverrideHint(metadata) {
|
|
194
|
+
if (metadata.ignoredEnvVar && metadata.ignoredReason === "empty") {
|
|
195
|
+
return `Ignored empty ${metadata.ignoredEnvVar}; using adapter default.`;
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
function storageDirectoryHint() {
|
|
200
|
+
return `Set ${STORAGE_ROOT_ENV} to a writable absolute path.`;
|
|
201
|
+
}
|
|
202
|
+
function pathFromStorageError(err) {
|
|
203
|
+
if (!err || typeof err !== "object")
|
|
204
|
+
return null;
|
|
205
|
+
const path = err.path;
|
|
206
|
+
return typeof path === "string" && path.length > 0 ? path : null;
|
|
207
|
+
}
|
|
13
208
|
// ─────────────────────────────────────────────────────────
|
|
14
209
|
// Worktree isolation
|
|
15
210
|
// ─────────────────────────────────────────────────────────
|
|
@@ -298,6 +493,87 @@ const S = {
|
|
|
298
493
|
getEventBytesSummary: "getEventBytesSummary",
|
|
299
494
|
};
|
|
300
495
|
// ─────────────────────────────────────────────────────────
|
|
496
|
+
// Schema migration helpers (shared with the analytics aggregator)
|
|
497
|
+
// ─────────────────────────────────────────────────────────
|
|
498
|
+
/**
|
|
499
|
+
* Columns that the current `session_events` schema requires but earlier
|
|
500
|
+
* versions of context-mode did not write. Older DBs on disk are missing
|
|
501
|
+
* these — the analytics aggregator opens every DB it finds across all
|
|
502
|
+
* adapters, so without an in-place migration the SUM queries below fail
|
|
503
|
+
* the entire DB (the catch at the top of the read loop swallows the
|
|
504
|
+
* "no such column" error and the DB contributes zero to every column,
|
|
505
|
+
* not just the new ones). v1.0.148 hotfix.
|
|
506
|
+
*/
|
|
507
|
+
const SESSION_EVENTS_REQUIRED_COLUMNS = [
|
|
508
|
+
["project_dir", "TEXT NOT NULL DEFAULT ''"],
|
|
509
|
+
["attribution_source", "TEXT NOT NULL DEFAULT 'unknown'"],
|
|
510
|
+
["attribution_confidence", "REAL NOT NULL DEFAULT 0"],
|
|
511
|
+
["bytes_avoided", "INTEGER NOT NULL DEFAULT 0"],
|
|
512
|
+
["bytes_returned", "INTEGER NOT NULL DEFAULT 0"],
|
|
513
|
+
];
|
|
514
|
+
/**
|
|
515
|
+
* Apply any missing post-v1.0.130 `session_events` columns to an already-
|
|
516
|
+
* open writable database handle. Idempotent — each ALTER is guarded by a
|
|
517
|
+
* PRAGMA table_xinfo check, and the project_dir index is created only
|
|
518
|
+
* when a migration actually ran. Returns true if any column was added.
|
|
519
|
+
*
|
|
520
|
+
* Used by both the SessionDB constructor (for the active DB) and the
|
|
521
|
+
* analytics aggregator (for the 100+ historical DBs that never get
|
|
522
|
+
* opened through SessionDB). ADR-0001 compatible: no EXCLUSIVE pragma,
|
|
523
|
+
* no acquireDbLock — relies on the SQLite busy_timeout + WAL semantics
|
|
524
|
+
* already provided by SQLiteBase.
|
|
525
|
+
*/
|
|
526
|
+
export function applyMissingSessionEventsColumns(db) {
|
|
527
|
+
const colInfo = db.pragma("table_xinfo(session_events)");
|
|
528
|
+
const cols = new Set(colInfo.map((c) => c.name));
|
|
529
|
+
let changed = false;
|
|
530
|
+
for (const [name, spec] of SESSION_EVENTS_REQUIRED_COLUMNS) {
|
|
531
|
+
if (!cols.has(name)) {
|
|
532
|
+
db.exec(`ALTER TABLE session_events ADD COLUMN ${name} ${spec}`);
|
|
533
|
+
changed = true;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (changed) {
|
|
537
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_session_events_project ON session_events(session_id, project_dir)");
|
|
538
|
+
}
|
|
539
|
+
return changed;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Open a session DB file briefly, run any missing schema migrations,
|
|
543
|
+
* and close. Best-effort: missing tables, file-locks, corrupt files,
|
|
544
|
+
* and any DatabaseCtor error are swallowed silently — the caller
|
|
545
|
+
* (analytics aggregator) handles the readonly query that follows and
|
|
546
|
+
* will skip the DB if it remains unreadable.
|
|
547
|
+
*
|
|
548
|
+
* Lazy migration entry point for the analytics aggregator, which would
|
|
549
|
+
* otherwise read 100+ historical DBs with the old (pre-v1.0.130) schema
|
|
550
|
+
* and lose every signal (not just bytes_avoided) because the SELECT
|
|
551
|
+
* statement references columns that don't exist on legacy schemas.
|
|
552
|
+
*
|
|
553
|
+
* Two open/close cycles in the worst case (one readonly probe to detect
|
|
554
|
+
* legacy schema, one writable to migrate). For already-migrated DBs
|
|
555
|
+
* (the common case after first read), this opens writable once and
|
|
556
|
+
* exits without writing — cheaper than always-writable.
|
|
557
|
+
*/
|
|
558
|
+
export function ensureSessionEventsSchema(dbPath, DatabaseCtor) {
|
|
559
|
+
let db = null;
|
|
560
|
+
try {
|
|
561
|
+
db = new DatabaseCtor(dbPath);
|
|
562
|
+
applyMissingSessionEventsColumns(db);
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
// best-effort — missing table, file lock, corrupt DB, or DatabaseCtor
|
|
566
|
+
// load failure. The aggregator's existing skip-on-error handles the
|
|
567
|
+
// downstream readonly query.
|
|
568
|
+
}
|
|
569
|
+
finally {
|
|
570
|
+
try {
|
|
571
|
+
db?.close();
|
|
572
|
+
}
|
|
573
|
+
catch { /* ignore */ }
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// ─────────────────────────────────────────────────────────
|
|
301
577
|
// SessionDB
|
|
302
578
|
// ─────────────────────────────────────────────────────────
|
|
303
579
|
export class SessionDB extends SQLiteBase {
|
|
@@ -374,25 +650,11 @@ export class SessionDB extends SQLiteBase {
|
|
|
374
650
|
CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id);
|
|
375
651
|
`);
|
|
376
652
|
// Migration: add per-event attribution columns for existing DBs.
|
|
653
|
+
// Shared helper — the analytics aggregator (analytics.ts) runs the
|
|
654
|
+
// SAME migration against every historical DB it scans, so the column
|
|
655
|
+
// list lives in one place at the top of this module.
|
|
377
656
|
try {
|
|
378
|
-
|
|
379
|
-
const cols = new Set(colInfo.map((c) => c.name));
|
|
380
|
-
if (!cols.has("project_dir")) {
|
|
381
|
-
this.db.exec("ALTER TABLE session_events ADD COLUMN project_dir TEXT NOT NULL DEFAULT ''");
|
|
382
|
-
}
|
|
383
|
-
if (!cols.has("attribution_source")) {
|
|
384
|
-
this.db.exec("ALTER TABLE session_events ADD COLUMN attribution_source TEXT NOT NULL DEFAULT 'unknown'");
|
|
385
|
-
}
|
|
386
|
-
if (!cols.has("attribution_confidence")) {
|
|
387
|
-
this.db.exec("ALTER TABLE session_events ADD COLUMN attribution_confidence REAL NOT NULL DEFAULT 0");
|
|
388
|
-
}
|
|
389
|
-
if (!cols.has("bytes_avoided")) {
|
|
390
|
-
this.db.exec("ALTER TABLE session_events ADD COLUMN bytes_avoided INTEGER NOT NULL DEFAULT 0");
|
|
391
|
-
}
|
|
392
|
-
if (!cols.has("bytes_returned")) {
|
|
393
|
-
this.db.exec("ALTER TABLE session_events ADD COLUMN bytes_returned INTEGER NOT NULL DEFAULT 0");
|
|
394
|
-
}
|
|
395
|
-
this.db.exec("CREATE INDEX IF NOT EXISTS idx_session_events_project ON session_events(session_id, project_dir)");
|
|
657
|
+
applyMissingSessionEventsColumns(this.db);
|
|
396
658
|
}
|
|
397
659
|
catch {
|
|
398
660
|
// best-effort migration only
|
package/build/session/extract.js
CHANGED
|
@@ -19,6 +19,22 @@ function safeStringAny(value) {
|
|
|
19
19
|
}
|
|
20
20
|
function isToolError(input) {
|
|
21
21
|
const response = String(input.tool_response ?? "");
|
|
22
|
+
// PreToolUse rewrites curl/wget/inline-HTTP/WebFetch commands into
|
|
23
|
+
// echo "context-mode: <guidance text including 'retry', 'fails', 'error'>"
|
|
24
|
+
// The user-facing copy legitimately mentions failure modes ("retry if it
|
|
25
|
+
// fails with a transient DNS error"), but those words must NOT classify
|
|
26
|
+
// our OWN guidance message as a tool error or it gets captured into
|
|
27
|
+
// session_resume and surfaces as a fake error in the next chat.
|
|
28
|
+
// We check BOTH sides because:
|
|
29
|
+
// - real shell run → response starts with `context-mode:` (echo stdout)
|
|
30
|
+
// - test/captured-output path → response is the raw command itself
|
|
31
|
+
// (`echo "context-mode: …"`), so we also match the command shape
|
|
32
|
+
const command = String(input.tool_input?.command ?? "");
|
|
33
|
+
if (response.startsWith("context-mode:") ||
|
|
34
|
+
command.startsWith('echo "context-mode:') ||
|
|
35
|
+
command.startsWith("echo 'context-mode:")) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
22
38
|
const isErrorFlag = input.tool_output?.isError === true || input.tool_output?.is_error === true;
|
|
23
39
|
const isBashError = input.tool_name === "Bash" &&
|
|
24
40
|
/exit code [1-9]|error:|Error:|FAIL|failed/i.test(response);
|
package/build/truncate.d.ts
CHANGED
|
@@ -5,6 +5,21 @@
|
|
|
5
5
|
* SessionDB (snapshot building). They are extracted here so any
|
|
6
6
|
* consumer can import them without pulling in the full store or executor.
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* Return a prefix of `str` no longer than `maxChars` UTF-16 code units, with
|
|
10
|
+
* the cut backed off by one unit if it would split a surrogate pair. Mirrors
|
|
11
|
+
* the surrogate-safety semantics of the internal `byteSafePrefix`, but uses
|
|
12
|
+
* a character budget rather than a byte budget.
|
|
13
|
+
*
|
|
14
|
+
* Use this instead of bare `str.slice(0, n)` whenever the result is later
|
|
15
|
+
* JSON-encoded for an RFC 8259-strict consumer (e.g. tool_result payloads
|
|
16
|
+
* sent back to the host LLM API). `JSON.stringify` will emit a lone high
|
|
17
|
+
* surrogate as a literal `\uD8xx` escape, which is invalid JSON.
|
|
18
|
+
*
|
|
19
|
+
* @param str - Input string.
|
|
20
|
+
* @param maxChars - Hard cap in UTF-16 code units.
|
|
21
|
+
*/
|
|
22
|
+
export declare function charSafePrefix(str: string, maxChars: number): string;
|
|
8
23
|
/**
|
|
9
24
|
* Serialize a value to JSON, then truncate the result to `maxBytes` bytes.
|
|
10
25
|
* If truncation occurs, the string is cut at a UTF-8-safe boundary and
|
package/build/truncate.js
CHANGED
|
@@ -43,6 +43,34 @@ function byteSafePrefix(str, maxBytes) {
|
|
|
43
43
|
return str.slice(0, lo);
|
|
44
44
|
}
|
|
45
45
|
// ─────────────────────────────────────────────────────────
|
|
46
|
+
// Char-count prefix (UTF-16 surrogate-safe)
|
|
47
|
+
// ─────────────────────────────────────────────────────────
|
|
48
|
+
/**
|
|
49
|
+
* Return a prefix of `str` no longer than `maxChars` UTF-16 code units, with
|
|
50
|
+
* the cut backed off by one unit if it would split a surrogate pair. Mirrors
|
|
51
|
+
* the surrogate-safety semantics of the internal `byteSafePrefix`, but uses
|
|
52
|
+
* a character budget rather than a byte budget.
|
|
53
|
+
*
|
|
54
|
+
* Use this instead of bare `str.slice(0, n)` whenever the result is later
|
|
55
|
+
* JSON-encoded for an RFC 8259-strict consumer (e.g. tool_result payloads
|
|
56
|
+
* sent back to the host LLM API). `JSON.stringify` will emit a lone high
|
|
57
|
+
* surrogate as a literal `\uD8xx` escape, which is invalid JSON.
|
|
58
|
+
*
|
|
59
|
+
* @param str - Input string.
|
|
60
|
+
* @param maxChars - Hard cap in UTF-16 code units.
|
|
61
|
+
*/
|
|
62
|
+
export function charSafePrefix(str, maxChars) {
|
|
63
|
+
if (maxChars <= 0)
|
|
64
|
+
return "";
|
|
65
|
+
if (str.length <= maxChars)
|
|
66
|
+
return str;
|
|
67
|
+
let end = maxChars;
|
|
68
|
+
const code = str.charCodeAt(end - 1);
|
|
69
|
+
if (code >= 0xd800 && code <= 0xdbff)
|
|
70
|
+
end -= 1;
|
|
71
|
+
return str.slice(0, end);
|
|
72
|
+
}
|
|
73
|
+
// ─────────────────────────────────────────────────────────
|
|
46
74
|
// JSON truncation
|
|
47
75
|
// ─────────────────────────────────────────────────────────
|
|
48
76
|
/**
|