context-mode 1.0.151 → 1.0.152

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 (106) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/mcp.json +5 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.openclaw-plugin/openclaw.plugin.json +16 -1
  6. package/.openclaw-plugin/package.json +1 -1
  7. package/README.md +89 -3
  8. package/build/adapters/claude-code/hooks.js +2 -2
  9. package/build/adapters/claude-code/index.js +14 -13
  10. package/build/adapters/client-map.js +3 -0
  11. package/build/adapters/detect.js +13 -1
  12. package/build/adapters/gemini-cli/hooks.d.ts +10 -0
  13. package/build/adapters/gemini-cli/hooks.js +12 -2
  14. package/build/adapters/gemini-cli/index.d.ts +21 -1
  15. package/build/adapters/gemini-cli/index.js +37 -1
  16. package/build/adapters/kimi/config.d.ts +8 -0
  17. package/build/adapters/kimi/config.js +8 -0
  18. package/build/adapters/kimi/hooks.d.ts +28 -0
  19. package/build/adapters/kimi/hooks.js +34 -0
  20. package/build/adapters/kimi/index.d.ts +66 -0
  21. package/build/adapters/kimi/index.js +537 -0
  22. package/build/adapters/kimi/paths.d.ts +1 -0
  23. package/build/adapters/kimi/paths.js +12 -0
  24. package/build/adapters/kiro/hooks.js +2 -2
  25. package/build/adapters/openclaw/plugin.d.ts +14 -13
  26. package/build/adapters/openclaw/plugin.js +140 -40
  27. package/build/adapters/opencode/plugin.js +4 -3
  28. package/build/adapters/opencode/zod3tov4.js +8 -8
  29. package/build/adapters/pi/extension.js +9 -24
  30. package/build/adapters/pi/mcp-bridge.js +37 -0
  31. package/build/adapters/qwen-code/index.js +7 -7
  32. package/build/adapters/types.d.ts +39 -2
  33. package/build/adapters/types.js +55 -2
  34. package/build/cli.js +433 -25
  35. package/build/executor.js +6 -3
  36. package/build/runtime.d.ts +81 -1
  37. package/build/runtime.js +195 -9
  38. package/build/search/ctx-search-schema.d.ts +90 -0
  39. package/build/search/ctx-search-schema.js +135 -0
  40. package/build/search/unified.d.ts +12 -0
  41. package/build/search/unified.js +17 -2
  42. package/build/server.d.ts +2 -1
  43. package/build/server.js +378 -97
  44. package/build/session/analytics.d.ts +36 -13
  45. package/build/session/analytics.js +123 -26
  46. package/build/session/db.d.ts +24 -0
  47. package/build/session/db.js +41 -0
  48. package/build/session/extract.js +30 -0
  49. package/build/session/snapshot.js +24 -0
  50. package/build/store.d.ts +12 -1
  51. package/build/store.js +72 -20
  52. package/build/types.d.ts +7 -0
  53. package/build/util/project-dir.d.ts +19 -16
  54. package/build/util/project-dir.js +80 -45
  55. package/cli.bundle.mjs +371 -320
  56. package/configs/kimi/hooks.json +54 -0
  57. package/configs/pi/AGENTS.md +3 -85
  58. package/hooks/cache-heal-utils.mjs +148 -0
  59. package/hooks/core/formatters.mjs +26 -0
  60. package/hooks/core/routing.mjs +9 -1
  61. package/hooks/core/stdin.mjs +74 -3
  62. package/hooks/core/tool-naming.mjs +1 -0
  63. package/hooks/heal-partial-install.mjs +712 -0
  64. package/hooks/kimi/platform.mjs +1 -0
  65. package/hooks/kimi/posttooluse.mjs +72 -0
  66. package/hooks/kimi/precompact.mjs +80 -0
  67. package/hooks/kimi/pretooluse.mjs +42 -0
  68. package/hooks/kimi/sessionend.mjs +61 -0
  69. package/hooks/kimi/sessionstart.mjs +113 -0
  70. package/hooks/kimi/stop.mjs +61 -0
  71. package/hooks/kimi/userpromptsubmit.mjs +90 -0
  72. package/hooks/normalize-hooks.mjs +66 -12
  73. package/hooks/routing-block.mjs +8 -2
  74. package/hooks/security.bundle.mjs +1 -1
  75. package/hooks/session-db.bundle.mjs +6 -4
  76. package/hooks/session-extract.bundle.mjs +2 -2
  77. package/hooks/session-helpers.mjs +93 -3
  78. package/hooks/session-snapshot.bundle.mjs +20 -19
  79. package/hooks/sessionstart.mjs +64 -0
  80. package/insight/server.mjs +15 -3
  81. package/openclaw.plugin.json +16 -1
  82. package/package.json +1 -1
  83. package/scripts/heal-installed-plugins.mjs +31 -10
  84. package/scripts/postinstall.mjs +10 -0
  85. package/server.bundle.mjs +206 -157
  86. package/skills/ctx-index/SKILL.md +46 -0
  87. package/skills/ctx-search/SKILL.md +35 -0
  88. package/start.mjs +84 -11
  89. package/build/cache-heal.d.ts +0 -48
  90. package/build/cache-heal.js +0 -150
  91. package/build/concurrency/runPool.d.ts +0 -36
  92. package/build/concurrency/runPool.js +0 -51
  93. package/build/openclaw/mcp-tools.d.ts +0 -54
  94. package/build/openclaw/mcp-tools.js +0 -198
  95. package/build/openclaw/workspace-router.d.ts +0 -29
  96. package/build/openclaw/workspace-router.js +0 -64
  97. package/build/openclaw-plugin.d.ts +0 -130
  98. package/build/openclaw-plugin.js +0 -626
  99. package/build/opencode-plugin.d.ts +0 -122
  100. package/build/opencode-plugin.js +0 -375
  101. package/build/pi-extension.d.ts +0 -14
  102. package/build/pi-extension.js +0 -451
  103. package/build/routing-block.d.ts +0 -8
  104. package/build/routing-block.js +0 -86
  105. package/build/tool-naming.d.ts +0 -4
  106. package/build/tool-naming.js +0 -24
@@ -109,8 +109,19 @@ export interface RuntimeStats {
109
109
  calls: Record<string, number>;
110
110
  sessionStart: number;
111
111
  cacheHits: number;
112
+ cacheMisses?: number;
112
113
  cacheBytesSaved: number;
113
114
  }
115
+ /**
116
+ * Index observability snapshot — point-in-time view of the persistent
117
+ * content store. Optional input to `formatReport` so callers that don't
118
+ * have store access (or don't want the extra DB hit) can omit it.
119
+ */
120
+ export interface IndexState {
121
+ totalChunks: number;
122
+ totalSources: number;
123
+ lastIndexedAt?: string;
124
+ }
114
125
  /** Unified report combining runtime stats, DB analytics, and continuity data. */
115
126
  export interface FullReport {
116
127
  /** Runtime context savings (passed in, not from DB) */
@@ -133,6 +144,8 @@ export interface FullReport {
133
144
  };
134
145
  cache?: {
135
146
  hits: number;
147
+ misses: number;
148
+ hit_rate: number;
136
149
  bytes_saved: number;
137
150
  ttl_hours_left: number;
138
151
  total_with_cache: number;
@@ -595,7 +608,7 @@ export declare function detectLocaleAndTz(): {
595
608
  * the section disappears cleanly on a fresh install.
596
609
  *
597
610
  * Math constants:
598
- * Opus 4 = $15.00 per 1M input tokens (matches OPUS_INPUT_PRICE_PER_TOKEN)
611
+ * Opus 4 = $15.00 per 1M input tokens (fallback when PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN not set)
599
612
  * Sonnet 4 = $3.00 per 1M input tokens
600
613
  * GPT-4o = $2.50 per 1M input tokens
601
614
  * Gemini 2 = $1.25 per 1M input tokens
@@ -654,20 +667,24 @@ export declare function renderHorizontalTimeline(days: TimelineDay[], locale: st
654
667
  * line ("started …") without an extra timestamp-validity check.
655
668
  */
656
669
  export declare function formatLocalDateTime(ms: number, locale: string, tz: string): string;
657
- /** Opus 4 input price: $15 per 1M tokens. */
658
- export declare const OPUS_INPUT_PRICE_PER_TOKEN: number;
659
- /** Convert a token count to a USD string at the Opus input rate. */
660
- export declare function tokensToUsd(tokens: number): string;
661
670
  /**
662
- * Render a FullReport as a visual savings dashboard designed for screenshotting.
663
- *
664
- * Design principles:
665
- * - Before/After comparison bar is the HERO — one glance = "wow"
666
- * - "tokens saved" is the number people share
667
- * - Per-tool breakdown shows what each tool SAVED, sorted by impact
668
- * - Project memory: category bars showing persistent data across sessions
669
- * - No: Pct column, category tables, tips, jargon
671
+ * Per-token USD rate resolves on every call.
672
+ * Dynamic when PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN is set, Opus 4 input
673
+ * ($15 per 1M tokens) otherwise.
674
+ */
675
+ export declare function pricePerToken(): number;
676
+ /**
677
+ * Back-compat alias for the original Opus-rate const (PR #401 architect
678
+ * P1.1 single source of truth). Kept as a literal so any third-party
679
+ * consumer importing the named constant still resolves to the same
680
+ * fallback rate. New code should call pricePerToken() to pick up the
681
+ * dynamic Pi env override.
682
+ *
683
+ * @deprecated Use pricePerToken() to honor PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN.
670
684
  */
685
+ export declare const OPUS_INPUT_PRICE_PER_TOKEN: number;
686
+ /** Convert a token count to a USD string at the current per-token rate. */
687
+ export declare function tokensToUsd(tokens: number): string;
671
688
  export declare function formatReport(report: FullReport, version?: string, latestVersion?: string | null, opts?: {
672
689
  lifetime?: LifetimeStats;
673
690
  mcpUsage?: McpToolUsageRow[];
@@ -700,6 +717,12 @@ export declare function formatReport(report: FullReport, version?: string, lates
700
717
  * single-adapter renderer output unchanged.
701
718
  */
702
719
  multiAdapter?: MultiAdapterLifetimeStats;
720
+ /**
721
+ * Point-in-time snapshot of the persistent content store. Optional —
722
+ * callers that don't have store access can omit it and the renderer
723
+ * skips the observability section gracefully.
724
+ */
725
+ indexState?: IndexState;
703
726
  /**
704
727
  * 5-section narrative renderer overrides. Defaults to ambient
705
728
  * `process.cwd()` + `Date.now()` + `detectLocaleAndTz()` for production
@@ -45,7 +45,8 @@ export const categoryLabels = {
45
45
  // Configuration & intent
46
46
  rule: "Project rules (CLAUDE.md)",
47
47
  prompt: "Your requests saved",
48
- intent: "Session goal",
48
+ intent: "Session intent",
49
+ goal: "Session goal",
49
50
  role: "Behavior rules",
50
51
  constraint: "Constraints you set",
51
52
  // Tools & delegation
@@ -198,7 +199,8 @@ export class AnalyticsEngine {
198
199
  let median = null;
199
200
  let max = null;
200
201
  if (b.concurrencies.length > 0) {
201
- const sorted = [...b.concurrencies].sort((a, c) => a - c);
202
+ b.concurrencies.sort((a, c) => a - c);
203
+ const sorted = b.concurrencies;
202
204
  const mid = Math.floor(sorted.length / 2);
203
205
  median = sorted.length % 2 === 0
204
206
  ? (sorted[mid - 1] + sorted[mid]) / 2
@@ -252,12 +254,21 @@ export class AnalyticsEngine {
252
254
  const uptimeMin = (uptimeMs / 60_000).toFixed(1);
253
255
  // ── Cache ──
254
256
  let cache;
255
- if (runtimeStats.cacheHits > 0 || runtimeStats.cacheBytesSaved > 0) {
257
+ const cacheMisses = runtimeStats.cacheMisses ?? 0;
258
+ if (runtimeStats.cacheHits > 0 || runtimeStats.cacheBytesSaved > 0 || cacheMisses > 0) {
256
259
  const totalWithCache = totalProcessed + runtimeStats.cacheBytesSaved;
257
260
  const totalSavingsRatio = totalWithCache / Math.max(totalBytesReturned, 1);
258
261
  const ttlHoursLeft = Math.max(0, 24 - Math.floor((Date.now() - runtimeStats.sessionStart) / (60 * 60 * 1000)));
262
+ // hit_rate is the nominal cache effectiveness — the metric ctx_stats
263
+ // historically inferred-only by diffing tokens_saved snapshots. When
264
+ // there is no activity we report 0 instead of NaN/undefined so the
265
+ // renderer stays JSON-safe.
266
+ const totalLookups = runtimeStats.cacheHits + cacheMisses;
267
+ const hitRate = totalLookups > 0 ? runtimeStats.cacheHits / totalLookups : 0;
259
268
  cache = {
260
269
  hits: runtimeStats.cacheHits,
270
+ misses: cacheMisses,
271
+ hit_rate: hitRate,
261
272
  bytes_saved: runtimeStats.cacheBytesSaved,
262
273
  ttl_hours_left: ttlHoursLeft,
263
274
  total_with_cache: totalWithCache,
@@ -1382,7 +1393,7 @@ function shortPath(abs) {
1382
1393
  * the section disappears cleanly on a fresh install.
1383
1394
  *
1384
1395
  * Math constants:
1385
- * Opus 4 = $15.00 per 1M input tokens (matches OPUS_INPUT_PRICE_PER_TOKEN)
1396
+ * Opus 4 = $15.00 per 1M input tokens (fallback when PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN not set)
1386
1397
  * Sonnet 4 = $3.00 per 1M input tokens
1387
1398
  * GPT-4o = $2.50 per 1M input tokens
1388
1399
  * Gemini 2 = $1.25 per 1M input tokens
@@ -1395,38 +1406,53 @@ function shortPath(abs) {
1395
1406
  export function renderCostExample(lifetimeBytes, lifetimeTokens, lifetimeDays) {
1396
1407
  if (!Number.isFinite(lifetimeTokens) || lifetimeTokens <= 0)
1397
1408
  return [];
1398
- const opusUsd = (lifetimeTokens * 15) / 1_000_000;
1409
+ const lifetimeUsd = lifetimeTokens * pricePerToken();
1399
1410
  const usdStr = (n, dp = 2) => n.toFixed(dp);
1400
1411
  // Comparison units — kept locally so they're easy to tune without touching
1401
1412
  // the renderer logic. Cursor Pro & Claude Max are public list prices; the
1402
1413
  // weekend constant is an intentional approximation calibrated to make
1403
1414
  // $1399.73 → "19 weekends" line up with the demo target.
1404
- const cursorMonths = Math.round(opusUsd / 20);
1405
- const claudeMaxMonths = (opusUsd / 200).toFixed(1);
1406
- const weekendCount = Math.round(opusUsd / 73.67);
1407
- const teamUsd = Math.round(opusUsd * 10);
1415
+ const cursorMonths = Math.round(lifetimeUsd / 20);
1416
+ const claudeMaxMonths = (lifetimeUsd / 200).toFixed(1);
1417
+ const weekendCount = Math.round(lifetimeUsd / 73.67);
1418
+ const teamUsd = Math.round(lifetimeUsd * 10);
1408
1419
  const teamYearUsd = lifetimeDays > 0
1409
- ? Math.round((opusUsd * 10) / lifetimeDays * 365)
1420
+ ? Math.round((lifetimeUsd * 10) / lifetimeDays * 365)
1410
1421
  : 0;
1411
1422
  // Alternate-model scale row — same token count, different per-1M rates.
1412
- const sonnetUsd = ((lifetimeTokens * 3.0) / 1_000_000).toFixed(2);
1413
- const gpt4oUsd = ((lifetimeTokens * 2.5) / 1_000_000).toFixed(2);
1414
- const geminiUsd = ((lifetimeTokens * 1.25) / 1_000_000).toFixed(2);
1415
- const haikuUsd = ((lifetimeTokens * 0.8) / 1_000_000).toFixed(2);
1423
+ // (Kept for internal reference but unreachable per Mert directive.)
1424
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1425
+ const _sonnetUsd = ((lifetimeTokens * 3.0) / 1_000_000).toFixed(2);
1426
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1427
+ const _gpt4oUsd = ((lifetimeTokens * 2.5) / 1_000_000).toFixed(2);
1428
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1429
+ const _geminiUsd = ((lifetimeTokens * 1.25) / 1_000_000).toFixed(2);
1430
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1431
+ const _haikuUsd = ((lifetimeTokens * 0.8) / 1_000_000).toFixed(2);
1432
+ const usingDynamicPrice = process.env.PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN !== undefined;
1433
+ const modelId = process.env.PI_CONTEXT_MODE_MODEL_ID;
1416
1434
  // Mert: "daha marketing ve business value e vermeli, math hesaplamalari ile
1417
- // kalabalik yapma" — collapse the old 4-block render (5 prose lines + 3
1418
- // comparison lines + 2 team lines + scaling table + disclaimer) into ONE
1419
- // headline number, ONE relatable comparison, ONE team-scale callout. Drop
1420
- // the alternate-model scaling row (engineer-curiosity, not value framing).
1435
+ // kalabalik yapma" — collapse the old 4-block render into ONE headline
1436
+ // number, ONE relatable comparison, ONE team-scale callout.
1421
1437
  const out = [];
1422
- out.push(` $${usdStr(opusUsd)} of Opus 4 tokens your team didn't burn.`);
1438
+ if (usingDynamicPrice && modelId) {
1439
+ out.push(` $${usdStr(lifetimeUsd)} of ${modelId} tokens your team didn't burn.`);
1440
+ }
1441
+ else if (usingDynamicPrice) {
1442
+ out.push(` $${usdStr(lifetimeUsd)} of tokens your team didn't burn.`);
1443
+ }
1444
+ else {
1445
+ out.push(` $${usdStr(lifetimeUsd)} of Opus 4 tokens your team didn't burn.`);
1446
+ }
1423
1447
  out.push(` context-mode kept ${kb(lifetimeBytes)} out of context — that's ${cursorMonths} months of Cursor Pro paid for itself.`);
1424
1448
  if (teamUsd > 0 && teamYearUsd > 0) {
1425
1449
  out.push("");
1426
1450
  out.push(` Scale across a 10-dev team and that's ~$${teamYearUsd.toLocaleString("en-US")}/year saved.`);
1427
1451
  }
1428
- out.push("");
1429
- out.push(` (Opus rates shown for context. On cheaper models the dollar number drops; the savings ratio holds.)`);
1452
+ if (!usingDynamicPrice) {
1453
+ out.push("");
1454
+ out.push(` (Opus rates shown for context. On cheaper models the dollar number drops; the savings ratio holds.)`);
1455
+ }
1430
1456
  return out;
1431
1457
  }
1432
1458
  /**
@@ -1829,12 +1855,46 @@ function fmtNum(n) {
1829
1855
  // ─────────────────────────────────────────────────────────
1830
1856
  // Pricing (Bug #6) — Anthropic Opus input rate
1831
1857
  // ─────────────────────────────────────────────────────────
1832
- /** Opus 4 input price: $15 per 1M tokens. */
1858
+ // ── Pricing (Bug #6) per-token USD rate ─────────────────
1859
+ // Reads PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN when set by a Pi host;
1860
+ // falls back to the Opus 4 input rate ($15/1M) for all other adapters.
1861
+ //
1862
+ // IMPORTANT: this is a FUNCTION, not a const. Pi sets the env var
1863
+ // AFTER the MCP server has been imported (the bridge spawns the server
1864
+ // child, then the child reads its own env on every render). A
1865
+ // module-load-time const would freeze to the fallback because
1866
+ // process.env.PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN is unset at
1867
+ // import time. Resolving on every call keeps the dynamic-pricing
1868
+ // contract honest — the env var works without an MCP restart.
1869
+ // (Reverted module-load const semantics, PR #741 follow-up.)
1870
+ /**
1871
+ * Per-token USD rate — resolves on every call.
1872
+ * Dynamic when PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN is set, Opus 4 input
1873
+ * ($15 per 1M tokens) otherwise.
1874
+ */
1875
+ export function pricePerToken() {
1876
+ const env = process.env.PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN;
1877
+ if (env !== undefined && env !== "") {
1878
+ const parsed = Number(env);
1879
+ if (Number.isFinite(parsed) && parsed > 0)
1880
+ return parsed;
1881
+ }
1882
+ return 15 / 1_000_000; // Opus 4 input fallback
1883
+ }
1884
+ /**
1885
+ * Back-compat alias for the original Opus-rate const (PR #401 architect
1886
+ * P1.1 — single source of truth). Kept as a literal so any third-party
1887
+ * consumer importing the named constant still resolves to the same
1888
+ * fallback rate. New code should call pricePerToken() to pick up the
1889
+ * dynamic Pi env override.
1890
+ *
1891
+ * @deprecated Use pricePerToken() to honor PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN.
1892
+ */
1833
1893
  export const OPUS_INPUT_PRICE_PER_TOKEN = 15 / 1_000_000;
1834
- /** Convert a token count to a USD string at the Opus input rate. */
1894
+ /** Convert a token count to a USD string at the current per-token rate. */
1835
1895
  export function tokensToUsd(tokens) {
1836
1896
  const safe = Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
1837
- return `$${(safe * OPUS_INPUT_PRICE_PER_TOKEN).toFixed(2)}`;
1897
+ return `$${(safe * pricePerToken()).toFixed(2)}`;
1838
1898
  }
1839
1899
  /**
1840
1900
  * Build a proportional bar using █ chars, scaled to a fixed width.
@@ -2043,8 +2103,10 @@ function renderConversation(c, conversationUsd, contribPct) {
2043
2103
  function renderMultiAdapter(multiAdapter) {
2044
2104
  if (!multiAdapter)
2045
2105
  return [];
2046
- const real = multiAdapter.perAdapter.filter((a) => a.isReal);
2047
- const skipped = multiAdapter.perAdapter.filter((a) => !a.isReal);
2106
+ const real = [];
2107
+ const skipped = [];
2108
+ for (const a of multiAdapter.perAdapter)
2109
+ (a.isReal ? real : skipped).push(a);
2048
2110
  if (real.length === 0 && skipped.length === 0)
2049
2111
  return [];
2050
2112
  const out = [];
@@ -2090,6 +2152,34 @@ function renderMultiAdapter(multiAdapter) {
2090
2152
  * - Project memory: category bars showing persistent data across sessions
2091
2153
  * - No: Pct column, category tables, tips, jargon
2092
2154
  */
2155
+ /**
2156
+ * Render the machine-readable Observability block (cache + index state).
2157
+ *
2158
+ * Returns an empty array when no observability data is available, so callers
2159
+ * can `lines.push(...renderObservabilityBlock(...))` unconditionally and the
2160
+ * section is omitted when the kit has nothing to report.
2161
+ *
2162
+ * Shared between the narrative path (early-returns when conversation.events > 0)
2163
+ * and the legacy path so the block surfaces in both, matching the contract
2164
+ * that the handler always passes `indexState` regardless of code path.
2165
+ */
2166
+ function renderObservabilityBlock(report, indexState) {
2167
+ const obs = [];
2168
+ if (report.cache) {
2169
+ const hitRatePct = (report.cache.hit_rate * 100).toFixed(1);
2170
+ obs.push(`cache.hit_rate: ${hitRatePct}% (${report.cache.hits} hits / ${report.cache.misses} misses)`);
2171
+ }
2172
+ if (indexState) {
2173
+ obs.push(`index.total_chunks: ${indexState.totalChunks}`);
2174
+ obs.push(`index.total_sources: ${indexState.totalSources}`);
2175
+ if (indexState.lastIndexedAt) {
2176
+ obs.push(`index.last_indexed_at: ${indexState.lastIndexedAt}`);
2177
+ }
2178
+ }
2179
+ if (obs.length === 0)
2180
+ return [];
2181
+ return ["", "## Observability", ...obs];
2182
+ }
2093
2183
  export function formatReport(report, version, latestVersion, opts) {
2094
2184
  const lines = [];
2095
2185
  const duration = formatDuration(report.session.uptime_min);
@@ -2157,6 +2247,9 @@ export function formatReport(report, version, latestVersion, opts) {
2157
2247
  conversation, lifetime, multiAdapter, realBytes,
2158
2248
  cwd, locale, tz, now, version, latestVersion,
2159
2249
  }));
2250
+ // Append Observability so cache.hit_rate / index.* surface in the
2251
+ // narrative path too (handler passes indexState regardless of path).
2252
+ lines.push(...renderObservabilityBlock(report, opts?.indexState));
2160
2253
  return lines.join("\n");
2161
2254
  }
2162
2255
  // ── Compute real savings ──
@@ -2261,6 +2354,10 @@ export function formatReport(report, version, latestVersion, opts) {
2261
2354
  lines.push(...renderAutoMemory(lifetime));
2262
2355
  // ── Bottom line — business value framing (Bug #8) ──
2263
2356
  lines.push(...renderBottomLine(tokensSaved, lifetime));
2357
+ // ── Observability — machine-readable cache + index state ──
2358
+ // Rendered via shared helper so the narrative path (above, early-return
2359
+ // when conversation.events > 0) emits the same block.
2360
+ lines.push(...renderObservabilityBlock(report, opts?.indexState));
2264
2361
  // ── Footer ──
2265
2362
  lines.push("");
2266
2363
  const versionStr = version ? `v${version}` : "context-mode";
@@ -319,6 +319,18 @@ export declare class SessionDB extends SQLiteBase {
319
319
  data: string;
320
320
  created_at: string;
321
321
  }>;
322
+ /**
323
+ * Return the distinct list of session ids whose events were attributed
324
+ * to a given `project_dir`. Powers the ctx_search `project:` filter
325
+ * (#737) via the 2-step IN-clause strategy — ATTACH DATABASE is avoided
326
+ * because SQLite's WAL + ATTACH combination has known correctness
327
+ * trade-offs flagged in the upstream docs.
328
+ *
329
+ * Backed by the `idx_session_events_project(session_id, project_dir)`
330
+ * composite index, so 1000-session lookups complete in single-digit
331
+ * milliseconds. Best-effort: returns `[]` on any error.
332
+ */
333
+ getSessionIdsForProject(projectDir: string): string[];
322
334
  /**
323
335
  * Ensure a session metadata entry exists. Idempotent (INSERT OR IGNORE).
324
336
  * `projectDir` is the session origin directory, not per-event attribution.
@@ -393,5 +405,17 @@ export declare class SessionDB extends SQLiteBase {
393
405
  * Remove sessions older than maxAgeDays. Returns the count of deleted sessions.
394
406
  */
395
407
  cleanupOldSessions(maxAgeDays?: number): number;
408
+ /**
409
+ * Delete event rows whose session_id has no matching session_meta row.
410
+ *
411
+ * Orphaned events accumulate when meta rows were aged out by an older
412
+ * version of `cleanupOldSessions` but the matching events were left
413
+ * behind (or when callers wrote events without a meta upsert). The Kimi
414
+ * Code sessionstart hook calls this on every startup as a self-healing
415
+ * step; surfacing it as a SessionDB method keeps the SQL definition in
416
+ * one place instead of letting hook scripts reach through to
417
+ * `db.db.exec(...)` and re-encode schema knowledge in mjs files.
418
+ */
419
+ pruneOrphanedEvents(): number;
396
420
  }
397
421
  export {};
@@ -976,6 +976,30 @@ export class SessionDB extends SQLiteBase {
976
976
  return [];
977
977
  }
978
978
  }
979
+ /**
980
+ * Return the distinct list of session ids whose events were attributed
981
+ * to a given `project_dir`. Powers the ctx_search `project:` filter
982
+ * (#737) via the 2-step IN-clause strategy — ATTACH DATABASE is avoided
983
+ * because SQLite's WAL + ATTACH combination has known correctness
984
+ * trade-offs flagged in the upstream docs.
985
+ *
986
+ * Backed by the `idx_session_events_project(session_id, project_dir)`
987
+ * composite index, so 1000-session lookups complete in single-digit
988
+ * milliseconds. Best-effort: returns `[]` on any error.
989
+ */
990
+ getSessionIdsForProject(projectDir) {
991
+ try {
992
+ const rows = this.db
993
+ .prepare(`SELECT DISTINCT session_id
994
+ FROM session_events
995
+ WHERE project_dir = ?`)
996
+ .all(projectDir);
997
+ return rows.map((r) => r.session_id);
998
+ }
999
+ catch {
1000
+ return [];
1001
+ }
1002
+ }
979
1003
  // ═══════════════════════════════════════════
980
1004
  // Meta
981
1005
  // ═══════════════════════════════════════════
@@ -1127,4 +1151,21 @@ export class SessionDB extends SQLiteBase {
1127
1151
  }
1128
1152
  return oldSessions.length;
1129
1153
  }
1154
+ /**
1155
+ * Delete event rows whose session_id has no matching session_meta row.
1156
+ *
1157
+ * Orphaned events accumulate when meta rows were aged out by an older
1158
+ * version of `cleanupOldSessions` but the matching events were left
1159
+ * behind (or when callers wrote events without a meta upsert). The Kimi
1160
+ * Code sessionstart hook calls this on every startup as a self-healing
1161
+ * step; surfacing it as a SessionDB method keeps the SQL definition in
1162
+ * one place instead of letting hook scripts reach through to
1163
+ * `db.db.exec(...)` and re-encode schema knowledge in mjs files.
1164
+ */
1165
+ pruneOrphanedEvents() {
1166
+ const result = this.db
1167
+ .prepare(`DELETE FROM session_events WHERE session_id NOT IN (SELECT session_id FROM session_meta)`)
1168
+ .run();
1169
+ return Number(result.changes ?? 0);
1170
+ }
1130
1171
  }
@@ -932,6 +932,35 @@ function extractIntent(message) {
932
932
  priority: 4,
933
933
  }];
934
934
  }
935
+ /**
936
+ * Category: session goal (objective).
937
+ *
938
+ * Captures the user's stated objective so it survives compaction and resume —
939
+ * unlike `intent`, which stores only the coarse mode (investigate/implement)
940
+ * and discards the goal text. Triggered by the `/goal <text>` command or an
941
+ * explicit `goal:` / `objective:` marker, so the FULL goal text is preserved
942
+ * (priority 4 = critical in the DB eviction contract) and restored at the top
943
+ * of the resume snapshot.
944
+ * Without this, a `/goal` directive is lost across compaction/resume.
945
+ */
946
+ const GOAL_DIRECTIVE_PATTERN = /^(?:\/goal\s+|(?:goal|objective)\s*:\s*)(.+)$/is;
947
+ function extractGoal(message) {
948
+ const trimmed = message.trim();
949
+ if (!trimmed)
950
+ return [];
951
+ const match = trimmed.match(GOAL_DIRECTIVE_PATTERN);
952
+ if (!match)
953
+ return [];
954
+ const goalText = match[1].trim();
955
+ if (!goalText)
956
+ return [];
957
+ return [{
958
+ type: "goal",
959
+ category: "goal",
960
+ data: safeString(goalText),
961
+ priority: 4,
962
+ }];
963
+ }
935
964
  /**
936
965
  * Category 25: blocked-on
937
966
  * Detect when work is blocked on something, or when a blocker is resolved.
@@ -1184,6 +1213,7 @@ export function extractUserEvents(message) {
1184
1213
  events.push(...extractUserDecision(message));
1185
1214
  events.push(...extractRole(message));
1186
1215
  events.push(...extractIntent(message));
1216
+ events.push(...extractGoal(message));
1187
1217
  events.push(...extractBlocker(message));
1188
1218
  events.push(...extractData(message));
1189
1219
  return events;
@@ -342,6 +342,22 @@ function buildIntentSection(intentEvents) {
342
342
  const lastIntent = intentEvents[intentEvents.length - 1];
343
343
  return ` <intent mode="${escapeXML(lastIntent.data)}"/>`;
344
344
  }
345
+ /**
346
+ * Restore the most recent stated session goal verbatim. Placed at the top of
347
+ * the snapshot (right after how_to_search) so the resuming LLM reads the
348
+ * active objective before anything else and keeps working toward it.
349
+ */
350
+ function buildGoalSection(goalEvents) {
351
+ if (goalEvents.length === 0)
352
+ return "";
353
+ const lastGoal = goalEvents[goalEvents.length - 1];
354
+ return [
355
+ ` <session_goal>`,
356
+ ` The active objective for this session. Keep working toward it until it is met; do not ask the user to restate it.`,
357
+ ` ${escapeXML(lastGoal.data)}`,
358
+ ` </session_goal>`,
359
+ ].join("\n");
360
+ }
345
361
  /**
346
362
  * Raw-prompt safety net (issue #535):
347
363
  * Always surface the most recent user prompts verbatim so the next LLM
@@ -404,6 +420,7 @@ export function buildResumeSnapshot(events, opts) {
404
420
  const gitEvents = [];
405
421
  const subagentEvents = [];
406
422
  const intentEvents = [];
423
+ const goalEvents = [];
407
424
  const skillEvents = [];
408
425
  const roleEvents = [];
409
426
  const userPromptEvents = [];
@@ -439,6 +456,9 @@ export function buildResumeSnapshot(events, opts) {
439
456
  case "intent":
440
457
  intentEvents.push(ev);
441
458
  break;
459
+ case "goal":
460
+ goalEvents.push(ev);
461
+ break;
442
462
  case "skill":
443
463
  skillEvents.push(ev);
444
464
  break;
@@ -459,6 +479,10 @@ export function buildResumeSnapshot(events, opts) {
459
479
  Do NOT ask the user to re-explain prior work. Search first.
460
480
  Do NOT invent your own queries — use the ones provided.
461
481
  </how_to_search>`);
482
+ // Session goal first — the objective the LLM must keep working toward.
483
+ const goal = buildGoalSection(goalEvents);
484
+ if (goal)
485
+ sections.push(goal);
462
486
  const files = buildFilesSection(fileEvents, searchTool);
463
487
  if (files)
464
488
  sections.push(files);
package/build/store.d.ts CHANGED
@@ -103,7 +103,7 @@ export declare class ContentStore {
103
103
  search(query: string, limit?: number, source?: string, mode?: "AND" | "OR", contentType?: "code" | "prose", sourceMatchMode?: SourceMatchMode): SearchResult[];
104
104
  searchTrigram(query: string, limit?: number, source?: string, mode?: "AND" | "OR", contentType?: "code" | "prose", sourceMatchMode?: SourceMatchMode): SearchResult[];
105
105
  fuzzyCorrect(query: string): string | null;
106
- searchWithFallback(query: string, limit?: number, source?: string, contentType?: "code" | "prose", sourceMatchMode?: SourceMatchMode): SearchResult[];
106
+ searchWithFallback(query: string, limit?: number, source?: string, contentType?: "code" | "prose", sourceMatchMode?: SourceMatchMode, sessionIdAllowSet?: Set<string>): SearchResult[];
107
107
  /** Number of sources auto-refreshed in the last searchWithFallback call. */
108
108
  lastRefreshCount: number;
109
109
  getSourceMeta(label: string): {
@@ -118,6 +118,17 @@ export declare class ContentStore {
118
118
  label: string;
119
119
  chunkCount: number;
120
120
  }>;
121
+ /**
122
+ * Aggregate snapshot of the persistent content store. Returns total
123
+ * chunk count, source count, and the most recent indexed_at timestamp.
124
+ * Used by ctx_stats so callers can see observability state in the same
125
+ * round trip instead of inferring it from snapshot diffs.
126
+ */
127
+ getIndexState(): {
128
+ totalChunks: number;
129
+ totalSources: number;
130
+ lastIndexedAt?: string;
131
+ };
121
132
  /**
122
133
  * Get all chunks for a given source by ID — bypasses FTS5 MATCH entirely.
123
134
  * Use this for inventory/listing where you need all sections, not search.