context-mode 1.0.107 → 1.0.109

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.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  4. package/.openclaw-plugin/package.json +1 -1
  5. package/README.md +22 -18
  6. package/build/adapters/claude-code/index.js +26 -9
  7. package/build/adapters/opencode/index.js +5 -5
  8. package/build/cli.js +92 -12
  9. package/build/server.js +45 -3
  10. package/build/session/analytics.d.ts +7 -0
  11. package/build/session/analytics.js +75 -15
  12. package/build/session/db.d.ts +3 -1
  13. package/build/session/persist-tool-calls.d.ts +54 -0
  14. package/build/session/persist-tool-calls.js +105 -0
  15. package/build/session/project-attribution.d.ts +1 -1
  16. package/cli.bundle.mjs +123 -122
  17. package/hooks/ensure-deps.mjs +28 -12
  18. package/hooks/posttooluse.mjs +90 -80
  19. package/hooks/precompact.mjs +56 -46
  20. package/hooks/pretooluse.mjs +161 -167
  21. package/hooks/routing-block.mjs +2 -2
  22. package/hooks/run-hook.mjs +82 -0
  23. package/hooks/session-db.bundle.mjs +2 -2
  24. package/hooks/sessionstart.mjs +187 -155
  25. package/hooks/userpromptsubmit.mjs +69 -58
  26. package/openclaw.plugin.json +1 -1
  27. package/package.json +2 -1
  28. package/scripts/heal-better-sqlite3.mjs +108 -0
  29. package/scripts/postinstall.mjs +27 -0
  30. package/server.bundle.mjs +88 -88
  31. package/skills/UPSTREAM-CREDITS.md +51 -0
  32. package/skills/context-mode-ops/SKILL.md +147 -0
  33. package/skills/diagnose/SKILL.md +122 -0
  34. package/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
  35. package/skills/grill-me/SKILL.md +15 -0
  36. package/skills/grill-with-docs/ADR-FORMAT.md +47 -0
  37. package/skills/grill-with-docs/CONTEXT-FORMAT.md +77 -0
  38. package/skills/grill-with-docs/SKILL.md +93 -0
  39. package/skills/improve-codebase-architecture/DEEPENING.md +37 -0
  40. package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
  41. package/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
  42. package/skills/improve-codebase-architecture/SKILL.md +76 -0
  43. package/skills/tdd/SKILL.md +114 -0
  44. package/skills/tdd/deep-modules.md +33 -0
  45. package/skills/tdd/interface-design.md +31 -0
  46. package/skills/tdd/mocking.md +59 -0
  47. package/skills/tdd/refactoring.md +10 -0
  48. package/skills/tdd/tests.md +61 -0
@@ -332,6 +332,7 @@ export function getLifetimeStats(opts) {
332
332
  ?? join(homedir(), ".claude", "projects");
333
333
  let totalEvents = 0;
334
334
  let totalSessions = 0;
335
+ const categoryCounts = {};
335
336
  // ── SessionDB aggregation ──
336
337
  if (existsSync(sessionsDir)) {
337
338
  let dbFiles = [];
@@ -358,6 +359,20 @@ export function getLifetimeStats(opts) {
358
359
  const ss = sdb.prepare("SELECT COUNT(*) AS cnt FROM session_meta").get();
359
360
  totalEvents += ev?.cnt ?? 0;
360
361
  totalSessions += ss?.cnt ?? 0;
362
+ // Per-category aggregation across every sidecar so the
363
+ // Persistent memory bars stay populated even when the
364
+ // current project's local DB is fresh / empty.
365
+ try {
366
+ const catRows = sdb.prepare("SELECT category, COUNT(*) AS cnt FROM session_events GROUP BY category").all();
367
+ for (const row of catRows) {
368
+ if (!row.category)
369
+ continue;
370
+ categoryCounts[row.category] = (categoryCounts[row.category] ?? 0) + (row.cnt ?? 0);
371
+ }
372
+ }
373
+ catch {
374
+ // older schema / no category column — ignore
375
+ }
361
376
  }
362
377
  finally {
363
378
  sdb.close();
@@ -414,6 +429,7 @@ export function getLifetimeStats(opts) {
414
429
  autoMemoryCount,
415
430
  autoMemoryProjects,
416
431
  autoMemoryByPrefix,
432
+ categoryCounts,
417
433
  };
418
434
  }
419
435
  // ─────────────────────────────────────────────────────────
@@ -477,21 +493,51 @@ function dataBar(bytes, maxBytes, width = 40) {
477
493
  * actual remaining count (Bug #5 — was hardcoded "9 more").
478
494
  */
479
495
  function renderProjectMemory(pm, opts) {
480
- if (pm.total_events === 0 && (opts?.lifetime?.totalEvents ?? 0) === 0)
496
+ const sessionTokensSaved = opts?.sessionTokensSaved ?? 0;
497
+ // Render when EITHER disk has data OR current session has earnings.
498
+ if (pm.total_events === 0 &&
499
+ (opts?.lifetime?.totalEvents ?? 0) === 0 &&
500
+ sessionTokensSaved === 0) {
481
501
  return [];
502
+ }
482
503
  const topN = opts?.topN ?? 2;
483
504
  const out = [];
484
505
  out.push("");
485
506
  out.push("Persistent memory ✓ preserved across compact, restart & upgrade");
486
- // Lifetime line (Bug #3) collapses to project-only when lifetime missing.
507
+ // Lifetime line — disk-aggregated lifetime PLUS current session's in-memory
508
+ // savings. Two separate accounting pipelines (server bytes vs hook events)
509
+ // get unified at the render edge so the user always sees a monotonic total
510
+ // (lifetime ≥ session). Without this, fresh users / pre-b8e11bf sidecars /
511
+ // not-yet-flushed events show $0 lifetime even when the session earned $X.
487
512
  const lifeEvents = opts?.lifetime?.totalEvents ?? pm.total_events;
488
513
  const lifeSessions = opts?.lifetime?.totalSessions ?? pm.session_count;
489
- const sessionLabel = lifeSessions === 1 ? "1 session" : `${fmtNum(lifeSessions)} sessions`;
490
- // Estimate lifetime savings: ~1KB per event ~256 tokens/event at Opus rates.
491
- const lifetimeTokens = lifeEvents * 256;
514
+ // Current session counts as 1 when no prior session has been recorded yet.
515
+ const effectiveSessions = lifeSessions === 0 && sessionTokensSaved > 0 ? 1 : lifeSessions;
516
+ const sessionLabel = effectiveSessions === 1 ? "1 session" : `${fmtNum(effectiveSessions)} sessions`;
517
+ // Estimate lifetime savings: ~1KB per event → ~256 tokens/event at Opus rates,
518
+ // plus current session's already-tracked token savings (in-memory).
519
+ const lifetimeTokens = lifeEvents * 256 + sessionTokensSaved;
492
520
  out.push(` ${fmtNum(lifeEvents)} events · ${sessionLabel} · ~${tokensToUsd(lifetimeTokens)} saved lifetime`);
493
521
  out.push("");
494
- const cats = pm.by_category;
522
+ // Prefer lifetime categoryCounts (aggregated across every SessionDB) so
523
+ // the bar block matches the lifetime header above. Falls back to the
524
+ // project-local pm.by_category when lifetime data is absent (tests, older
525
+ // callers) or when no sidecar has any events yet.
526
+ const lifetimeCats = opts?.lifetime?.categoryCounts;
527
+ let cats;
528
+ if (lifetimeCats && Object.keys(lifetimeCats).length > 0) {
529
+ cats = Object.entries(lifetimeCats)
530
+ .filter(([, c]) => c > 0)
531
+ .map(([category, count]) => ({
532
+ category,
533
+ count,
534
+ label: categoryLabels[category] || category,
535
+ }))
536
+ .sort((a, b) => b.count - a.count);
537
+ }
538
+ else {
539
+ cats = pm.by_category;
540
+ }
495
541
  const visible = cats.slice(0, topN);
496
542
  const maxCount = visible.length > 0 ? visible[0].count : 1;
497
543
  for (const cat of visible) {
@@ -517,8 +563,11 @@ function renderAutoMemory(lifetime) {
517
563
  const entries = Object.entries(lifetime.autoMemoryByPrefix)
518
564
  .sort((a, b) => b[1] - a[1])
519
565
  .slice(0, 6);
566
+ // Top entry sets the bar scale so the visual stays proportional even when
567
+ // the absolute counts are tiny. Entries are pre-sorted desc.
568
+ const maxCount = entries.length > 0 ? entries[0][1] : 1;
520
569
  for (const [prefix, count] of entries) {
521
- out.push(` ${prefix.padEnd(12)} ${String(count).padStart(2)}`);
570
+ out.push(` ${prefix.padEnd(12)} ${String(count).padStart(2)} ${dataBar(count, maxCount, 20)}`);
522
571
  }
523
572
  return out;
524
573
  }
@@ -526,8 +575,11 @@ function renderAutoMemory(lifetime) {
526
575
  function renderBottomLine(sessionTokensSaved, lifetime) {
527
576
  const out = [];
528
577
  const sessionUsd = tokensToUsd(sessionTokensSaved);
529
- // Lifetime estimate: ~1KB/event ÷ 4 bytes/token = 256 tokens/event.
530
- const lifetimeTokens = (lifetime?.totalEvents ?? 0) * 256;
578
+ // Lifetime = disk-aggregated events × 256 tokens + current session's
579
+ // in-memory token savings. Two pipelines unified at the render edge so
580
+ // lifetime ≥ session always (never the surprising "$X session · $0 lifetime"
581
+ // a fresh user sees pre-flush).
582
+ const lifetimeTokens = (lifetime?.totalEvents ?? 0) * 256 + sessionTokensSaved;
531
583
  const lifetimeUsd = tokensToUsd(lifetimeTokens);
532
584
  out.push("");
533
585
  out.push("─".repeat(65));
@@ -572,7 +624,7 @@ export function formatReport(report, version, latestVersion, opts) {
572
624
  lines.push(`${kb(totalReturned)} entered context | 0 tokens saved`);
573
625
  }
574
626
  // Project memory + auto-memory + bottom line
575
- lines.push(...renderProjectMemory(report.projectMemory, { lifetime }));
627
+ lines.push(...renderProjectMemory(report.projectMemory, { lifetime, sessionTokensSaved: 0 }));
576
628
  lines.push(...renderAutoMemory(lifetime));
577
629
  lines.push(...renderBottomLine(0, lifetime));
578
630
  // Footer
@@ -627,15 +679,23 @@ export function formatReport(report, version, latestVersion, opts) {
627
679
  lines.push(` ${name.padEnd(22)} ${String(t.calls).padStart(4)} calls ${kb(t.estimatedSaved).padStart(8)} saved`);
628
680
  }
629
681
  }
630
- // ── MCP concurrency usage (only when batch tools recorded a concurrency) ──
682
+ // ── Parallel I/O value-forward framing for concurrent batch tools.
683
+ // Suppressed when no tool ran with max_concurrency > 1 (don't claim
684
+ // parallelism we didn't deliver). Internal mcp__*__ namespace stripped
685
+ // for user-facing readability.
631
686
  if (mcpUsage && mcpUsage.length > 0) {
632
- const concurrent = mcpUsage.filter((u) => u.median_concurrency != null);
633
- for (const u of concurrent) {
634
- lines.push(`MCP concurrency usage: ${u.tool_name} median=${u.median_concurrency} max=${u.max_concurrency} (${u.calls} calls)`);
687
+ const concurrent = mcpUsage.filter((u) => u.median_concurrency != null && (u.max_concurrency ?? 1) > 1);
688
+ if (concurrent.length > 0) {
689
+ lines.push("");
690
+ lines.push("Parallel I/O ✓ one call did the work of many — faster runs, lower bill, same answer.");
691
+ for (const u of concurrent) {
692
+ const name = u.tool_name.replace(/^mcp__.*?__/, "");
693
+ lines.push(` ${name.padEnd(22)} ${u.calls} batches · ${u.median_concurrency} typical, ${u.max_concurrency} peak`);
694
+ }
635
695
  }
636
696
  }
637
697
  // ── Project memory — persistent across sessions (Bug #3 + #5) ──
638
- lines.push(...renderProjectMemory(report.projectMemory, { lifetime }));
698
+ lines.push(...renderProjectMemory(report.projectMemory, { lifetime, sessionTokensSaved: tokensSaved }));
639
699
  // ── Auto-memory — Claude Code's preference learnings (Bug #4) ──
640
700
  lines.push(...renderAutoMemory(lifetime));
641
701
  // ── Bottom line — business value framing (Bug #8) ──
@@ -77,7 +77,9 @@ export declare class SessionDB extends SQLiteBase {
77
77
  * Eviction: if session exceeds MAX_EVENTS_PER_SESSION, evicts the
78
78
  * lowest-priority (then oldest) event.
79
79
  */
80
- insertEvent(sessionId: string, event: SessionEvent, sourceHook?: string, attribution?: Partial<ProjectAttribution>): void;
80
+ insertEvent(sessionId: string, event: Omit<SessionEvent, "data_hash"> & {
81
+ data_hash?: string;
82
+ }, sourceHook?: string, attribution?: Partial<ProjectAttribution>): void;
81
83
  /**
82
84
  * Bulk-insert N events in a SINGLE transaction.
83
85
  *
@@ -0,0 +1,54 @@
1
+ /**
2
+ * persist-tool-calls — runtime glue between MCP server's in-memory
3
+ * `sessionStats` and the on-disk `tool_calls` SessionDB table.
4
+ *
5
+ * Why this module exists
6
+ * ──────────────────────
7
+ * Commit 4742160 (May 2 16:58) added the SessionDB write path so the
8
+ * statusline counters survived `npm update -g context-mode` and
9
+ * `claude --continue`. Commit b392c2f (May 2 21:43) — the concurrency
10
+ * refactor — silently dropped that wiring as collateral. Same-session
11
+ * `/ctx-upgrade` flips the statusline back to `0 calls / $0.00`
12
+ * because the new PID starts with an empty `sessionStats` and never
13
+ * looks at the table the old PID was writing to.
14
+ *
15
+ * This module re-introduces the write path AND adds the read-side
16
+ * restore that 4742160 never shipped — both pure helpers so the
17
+ * server.ts wiring is a one-liner and the unit tests don't need to
18
+ * boot the MCP server.
19
+ */
20
+ /**
21
+ * Shape returned by {@link restoreSessionStats}. Subset of the in-memory
22
+ * `sessionStats` object the MCP server keeps — only the fields that can
23
+ * be recovered from SessionDB.
24
+ */
25
+ export interface RestoredSessionStats {
26
+ /** Per-tool call counts. */
27
+ calls: Record<string, number>;
28
+ /** Per-tool returned bytes. */
29
+ bytesReturned: Record<string, number>;
30
+ /**
31
+ * Epoch-ms for `session_meta.started_at` of the latest session, so the
32
+ * statusline `uptime_ms` reflects the original session start instead of
33
+ * resetting to `Date.now()` on every PID change.
34
+ */
35
+ sessionStart: number;
36
+ }
37
+ /**
38
+ * Increment the persistent tool-call counter for `toolName` under whatever
39
+ * session_id `session_meta` currently treats as the most recent. This is
40
+ * called from {@link trackResponse} on every tool response and must be
41
+ * cheap, non-throwing, and best-effort — a stats failure must never break
42
+ * the MCP tool call.
43
+ */
44
+ export declare function persistToolCallCounter(sessionDbPath: string, toolName: string, bytes: number): void;
45
+ /**
46
+ * Read the latest session's tool-call totals back out of SessionDB so the
47
+ * MCP server can hydrate its in-memory `sessionStats` on startup. Returns
48
+ * `null` when the DB is missing or empty so the caller can keep the
49
+ * default zero-state without branching twice.
50
+ *
51
+ * Used during MCP server boot (BEFORE the heartbeat fires) so the
52
+ * statusline doesn't briefly flash `0 calls / $0.00` after upgrade.
53
+ */
54
+ export declare function restoreSessionStats(sessionDbPath: string): RestoredSessionStats | null;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * persist-tool-calls — runtime glue between MCP server's in-memory
3
+ * `sessionStats` and the on-disk `tool_calls` SessionDB table.
4
+ *
5
+ * Why this module exists
6
+ * ──────────────────────
7
+ * Commit 4742160 (May 2 16:58) added the SessionDB write path so the
8
+ * statusline counters survived `npm update -g context-mode` and
9
+ * `claude --continue`. Commit b392c2f (May 2 21:43) — the concurrency
10
+ * refactor — silently dropped that wiring as collateral. Same-session
11
+ * `/ctx-upgrade` flips the statusline back to `0 calls / $0.00`
12
+ * because the new PID starts with an empty `sessionStats` and never
13
+ * looks at the table the old PID was writing to.
14
+ *
15
+ * This module re-introduces the write path AND adds the read-side
16
+ * restore that 4742160 never shipped — both pure helpers so the
17
+ * server.ts wiring is a one-liner and the unit tests don't need to
18
+ * boot the MCP server.
19
+ */
20
+ import { existsSync } from "node:fs";
21
+ import { SessionDB } from "./db.js";
22
+ /**
23
+ * Increment the persistent tool-call counter for `toolName` under whatever
24
+ * session_id `session_meta` currently treats as the most recent. This is
25
+ * called from {@link trackResponse} on every tool response and must be
26
+ * cheap, non-throwing, and best-effort — a stats failure must never break
27
+ * the MCP tool call.
28
+ */
29
+ export function persistToolCallCounter(sessionDbPath, toolName, bytes) {
30
+ try {
31
+ if (!existsSync(sessionDbPath))
32
+ return;
33
+ const sdb = new SessionDB({ dbPath: sessionDbPath });
34
+ try {
35
+ const sid = sdb.getLatestSessionId();
36
+ if (!sid)
37
+ return;
38
+ sdb.incrementToolCall(sid, toolName, bytes);
39
+ }
40
+ finally {
41
+ sdb.close();
42
+ }
43
+ }
44
+ catch {
45
+ // Best-effort: counter must never throw and break the parent tool call.
46
+ }
47
+ }
48
+ /**
49
+ * Read the latest session's tool-call totals back out of SessionDB so the
50
+ * MCP server can hydrate its in-memory `sessionStats` on startup. Returns
51
+ * `null` when the DB is missing or empty so the caller can keep the
52
+ * default zero-state without branching twice.
53
+ *
54
+ * Used during MCP server boot (BEFORE the heartbeat fires) so the
55
+ * statusline doesn't briefly flash `0 calls / $0.00` after upgrade.
56
+ */
57
+ export function restoreSessionStats(sessionDbPath) {
58
+ try {
59
+ if (!existsSync(sessionDbPath))
60
+ return null;
61
+ const sdb = new SessionDB({ dbPath: sessionDbPath });
62
+ try {
63
+ const sid = sdb.getLatestSessionId();
64
+ if (!sid)
65
+ return null;
66
+ const stats = sdb.getToolCallStats(sid);
67
+ const calls = {};
68
+ const bytesReturned = {};
69
+ for (const [tool, row] of Object.entries(stats.byTool)) {
70
+ calls[tool] = row.calls;
71
+ bytesReturned[tool] = row.bytesReturned;
72
+ }
73
+ // started_at is "YYYY-MM-DD HH:MM:SS" in UTC (SQLite datetime() default);
74
+ // append "Z" so Date.parse interprets it as UTC, matching how the
75
+ // session was actually persisted.
76
+ let sessionStart = Date.now();
77
+ try {
78
+ const meta = sdb.getSessionStats(sid);
79
+ if (meta?.started_at) {
80
+ const parsed = Date.parse(`${meta.started_at}Z`);
81
+ if (Number.isFinite(parsed) && parsed > 0)
82
+ sessionStart = parsed;
83
+ }
84
+ }
85
+ catch {
86
+ // best-effort — keep `Date.now()` fallback
87
+ }
88
+ // Skip empty restores so callers can `if (restored)` and not stomp
89
+ // their already-zero default with another zero.
90
+ if (Object.keys(calls).length === 0 &&
91
+ Object.keys(bytesReturned).length === 0) {
92
+ // Still useful to return sessionStart so uptime_ms doesn't reset
93
+ // even when no tool calls were made — but only if we found a session.
94
+ return { calls, bytesReturned, sessionStart };
95
+ }
96
+ return { calls, bytesReturned, sessionStart };
97
+ }
98
+ finally {
99
+ sdb.close();
100
+ }
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
@@ -36,7 +36,7 @@ export declare const ATTRIBUTION_CONFIDENCE: {
36
36
  /** Fallback: session_origin without path signal */
37
37
  readonly FALLBACK_SESSION_ORIGIN: 0.35;
38
38
  };
39
- export type AttributionSource = "event_path" | "cwd_event" | "input_cwd" | "workspace_root" | "last_seen" | "session_origin" | "unknown";
39
+ export type AttributionSource = "event_path" | "cwd_event" | "input_cwd" | "workspace_root" | "last_seen" | "session_origin" | "env" | "test" | "unknown";
40
40
  export interface ProjectAttribution {
41
41
  projectDir: string;
42
42
  source: AttributionSource;