context-mode 1.0.98 → 1.0.100

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 (56) 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 +9 -7
  6. package/build/adapters/claude-code-base.js +4 -4
  7. package/build/adapters/codex/index.js +23 -1
  8. package/build/adapters/qwen-code/index.d.ts +1 -1
  9. package/build/adapters/qwen-code/index.js +110 -4
  10. package/build/cli.js +2 -0
  11. package/build/opencode-plugin.js +1 -1
  12. package/build/pi-extension.js +1 -1
  13. package/build/search/auto-memory.d.ts +29 -0
  14. package/build/search/auto-memory.js +121 -0
  15. package/build/search/unified.d.ts +41 -0
  16. package/build/search/unified.js +89 -0
  17. package/build/server.js +88 -40
  18. package/build/session/analytics.js +1 -1
  19. package/build/session/db.d.ts +17 -0
  20. package/build/session/db.js +28 -0
  21. package/build/session/extract.d.ts +4 -0
  22. package/build/session/extract.js +232 -1
  23. package/build/session/snapshot.js +31 -0
  24. package/build/store.js +118 -8
  25. package/build/types.d.ts +1 -0
  26. package/cli.bundle.mjs +260 -125
  27. package/configs/claude-code/CLAUDE.md +21 -1
  28. package/configs/codex/AGENTS.md +23 -2
  29. package/configs/codex/hooks.json +14 -0
  30. package/configs/cursor/context-mode.mdc +18 -1
  31. package/configs/gemini-cli/GEMINI.md +22 -1
  32. package/configs/jetbrains-copilot/copilot-instructions.md +22 -1
  33. package/configs/kilo/AGENTS.md +19 -2
  34. package/configs/kiro/KIRO.md +18 -1
  35. package/configs/openclaw/AGENTS.md +22 -2
  36. package/configs/opencode/AGENTS.md +18 -1
  37. package/configs/pi/AGENTS.md +18 -1
  38. package/configs/qwen-code/QWEN.md +38 -18
  39. package/configs/vscode-copilot/copilot-instructions.md +22 -1
  40. package/hooks/auto-injection.mjs +76 -0
  41. package/hooks/codex/stop.mjs +43 -0
  42. package/hooks/codex/userpromptsubmit.mjs +75 -0
  43. package/hooks/core/mcp-ready.mjs +7 -1
  44. package/hooks/posttooluse.mjs +50 -1
  45. package/hooks/precompact.mjs +9 -0
  46. package/hooks/pretooluse.mjs +27 -0
  47. package/hooks/routing-block.mjs +7 -1
  48. package/hooks/session-db.bundle.mjs +19 -13
  49. package/hooks/session-extract.bundle.mjs +2 -2
  50. package/hooks/session-snapshot.bundle.mjs +18 -17
  51. package/hooks/sessionstart.mjs +17 -0
  52. package/hooks/userpromptsubmit.mjs +1 -1
  53. package/openclaw.plugin.json +1 -1
  54. package/package.json +1 -1
  55. package/server.bundle.mjs +228 -93
  56. package/skills/context-mode-ops/agent-teams.md +1 -1
package/build/server.js CHANGED
@@ -16,7 +16,8 @@ import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readTo
16
16
  import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime, } from "./runtime.js";
17
17
  import { classifyNonZeroExit } from "./exit-classify.js";
18
18
  import { startLifecycleGuard } from "./lifecycle.js";
19
- import { getWorktreeSuffix } from "./session/db.js";
19
+ import { getWorktreeSuffix, SessionDB } from "./session/db.js";
20
+ import { searchAllSources } from "./search/unified.js";
20
21
  import { loadDatabase } from "./db-base.js";
21
22
  import { AnalyticsEngine, formatReport } from "./session/analytics.js";
22
23
  const __pkg_dir = dirname(fileURLToPath(import.meta.url));
@@ -58,7 +59,7 @@ const executor = new PolyglotExecutor({
58
59
  projectRoot: process.env.CLAUDE_PROJECT_DIR,
59
60
  });
60
61
  // ─────────────────────────────────────────────────────────
61
- // FS read tracking preload for batch_execute
62
+ // FS read tracking preload for ctx_batch_execute
62
63
  // ─────────────────────────────────────────────────────────
63
64
  // NODE_OPTIONS is denied by the executor's #buildSafeEnv (security).
64
65
  // Instead, we inject it as an inline shell env prefix in each batch command.
@@ -515,7 +516,7 @@ export function formatBatchQueryResults(store, queries, source, maxOutput = 80 *
515
516
  let outputSize = 0;
516
517
  for (const query of queries) {
517
518
  if (outputSize > maxOutput) {
518
- sections.push(`## ${query}\n(output cap reached — use search(queries: ["${query}"]) for details)\n`);
519
+ sections.push(`## ${query}\n(output cap reached — use ctx_search(queries: ["${query}"]) for details)\n`);
519
520
  continue;
520
521
  }
521
522
  const results = store.searchWithFallback(query, 3, source, undefined, "exact");
@@ -577,7 +578,7 @@ server.registerTool("ctx_execute", {
577
578
  .optional()
578
579
  .describe("What you're looking for in the output. When provided and output is large (>5KB), " +
579
580
  "indexes output into knowledge base and returns section titles + previews — not full content. " +
580
- "Use search(queries: [...]) to retrieve specific sections. Example: 'failing tests', 'HTTP 500 errors'." +
581
+ "Use ctx_search(queries: [...]) to retrieve specific sections. Example: 'failing tests', 'HTTP 500 errors'." +
581
582
  "\n\nTIP: Use specific technical terms, not just concepts. Check 'Searchable terms' in the response for available vocabulary."),
582
583
  }),
583
584
  }, async ({ language, code, timeout, background, intent }) => {
@@ -779,7 +780,7 @@ function indexStdout(stdout, source) {
779
780
  content: [
780
781
  {
781
782
  type: "text",
782
- text: `Indexed ${indexed.totalChunks} sections (${indexed.codeChunks} with code) from: ${indexed.label}\nUse search(queries: ["..."]) to query this content. Use source: "${indexed.label}" to scope results.`,
783
+ text: `Indexed ${indexed.totalChunks} sections (${indexed.codeChunks} with code) from: ${indexed.label}\nUse ctx_search(queries: ["..."]) to query this content. Use source: "${indexed.label}" to scope results.`,
783
784
  },
784
785
  ],
785
786
  };
@@ -792,7 +793,7 @@ const LARGE_OUTPUT_THRESHOLD = 102_400; // 100KB — auto-index into FTS5, retur
792
793
  function intentSearch(stdout, intent, source, maxResults = 5) {
793
794
  const totalLines = stdout.split("\n").length;
794
795
  const totalBytes = Buffer.byteLength(stdout);
795
- // Index into the PERSISTENT store so user can search() later
796
+ // Index into the PERSISTENT store so user can ctx_search() later
796
797
  const persistent = getStore();
797
798
  const indexed = persistent.indexPlainText(stdout, source);
798
799
  // Search the persistent store directly (porter → trigram → fuzzy)
@@ -809,7 +810,7 @@ function intentSearch(stdout, intent, source, maxResults = 5) {
809
810
  lines.push(`Searchable terms: ${distinctiveTerms.join(", ")}`);
810
811
  }
811
812
  lines.push("");
812
- lines.push("Use search() to explore the indexed content.");
813
+ lines.push("Use ctx_search(queries: [...]) to explore the indexed content.");
813
814
  return lines.join("\n");
814
815
  }
815
816
  // Return ONLY titles + first-line previews — not full content
@@ -827,7 +828,7 @@ function intentSearch(stdout, intent, source, maxResults = 5) {
827
828
  lines.push(`Searchable terms: ${distinctiveTerms.join(", ")}`);
828
829
  }
829
830
  lines.push("");
830
- lines.push("Use search(queries: [...]) to retrieve full content of any section.");
831
+ lines.push("Use ctx_search(queries: [...]) to retrieve full content of any section.");
831
832
  return lines.join("\n");
832
833
  }
833
834
  // ─────────────────────────────────────────────────────────
@@ -977,9 +978,9 @@ server.registerTool("ctx_index", {
977
978
  "- Skill prompts and instructions that are too large for context\n" +
978
979
  "- README files, migration guides, changelog entries\n" +
979
980
  "- Any content with code examples you may need to reference precisely\n\n" +
980
- "After indexing, use 'search' to retrieve specific sections on-demand.\n" +
981
+ "After indexing, use 'ctx_search' to retrieve specific sections on-demand.\n" +
981
982
  "When `path` is provided, a content hash is stored for automatic stale detection in search results.\n" +
982
- "Do NOT use for: log files, test output, CSV, build output — use 'execute_file' for those.",
983
+ "Do NOT use for: log files, test output, CSV, build output — use 'ctx_execute_file' for those.",
983
984
  inputSchema: z.object({
984
985
  content: z
985
986
  .string()
@@ -1023,7 +1024,7 @@ server.registerTool("ctx_index", {
1023
1024
  content: [
1024
1025
  {
1025
1026
  type: "text",
1026
- text: `Indexed ${result.totalChunks} sections (${result.codeChunks} with code) from: ${result.label}\nUse search(queries: ["..."]) to query this content. Use source: "${result.label}" to scope results.`,
1027
+ text: `Indexed ${result.totalChunks} sections (${result.codeChunks} with code) from: ${result.label}\nUse ctx_search(queries: ["..."]) to query this content. Use source: "${result.label}" to scope results.`,
1027
1028
  },
1028
1029
  ],
1029
1030
  });
@@ -1081,6 +1082,7 @@ server.registerTool("ctx_search", {
1081
1082
  "Pass ALL search questions as queries array in ONE call. " +
1082
1083
  "File-backed sources are auto-refreshed when the source file changes.\n\n" +
1083
1084
  "TIPS: 2-4 specific terms per query. Use 'source' to scope results.\n\n" +
1085
+ "SESSION STATE: If skills, roles, or decisions were set earlier in this conversation, they are still active. Do not discard or contradict them.\n\n" +
1084
1086
  "When reporting results — terse like caveman. Technical substance exact. Only fluff die. Pattern: [thing] [action] [reason]. [next step].",
1085
1087
  inputSchema: z.object({
1086
1088
  queries: z.preprocess(coerceJsonArray, z
@@ -1100,13 +1102,20 @@ server.registerTool("ctx_search", {
1100
1102
  .enum(["code", "prose"])
1101
1103
  .optional()
1102
1104
  .describe("Filter results by content type: 'code' or 'prose'."),
1105
+ sort: z
1106
+ .enum(["relevance", "timeline"])
1107
+ .optional()
1108
+ .default("relevance")
1109
+ .describe("Sort mode. 'relevance' (default): BM25 ranked, current session only. " +
1110
+ "'timeline': chronological across current session, prior sessions, and auto-memory."),
1103
1111
  }),
1104
1112
  }, async (params) => {
1105
1113
  try {
1106
1114
  const store = getStore();
1115
+ const sort = params.sort || "relevance";
1107
1116
  // Guard: redirect when the index is empty — ctx_search is a follow-up
1108
- // tool that requires prior indexing. Guide the model to the right tool.
1109
- if (store.getStats().chunks === 0) {
1117
+ // tool that requires prior indexing. Skip for timeline mode (SessionDB/auto-memory may have data).
1118
+ if (sort !== "timeline" && store.getStats().chunks === 0) {
1110
1119
  return trackResponse("ctx_search", {
1111
1120
  content: [{
1112
1121
  type: "text",
@@ -1151,7 +1160,7 @@ server.registerTool("ctx_search", {
1151
1160
  type: "text",
1152
1161
  text: `BLOCKED: ${searchCallCount} search calls in ${Math.round((now - searchWindowStart) / 1000)}s. ` +
1153
1162
  "You're flooding context. STOP making individual search calls. " +
1154
- "Use batch_execute(commands, queries) for your next research step.",
1163
+ "Use ctx_batch_execute(commands, queries) for your next research step.",
1155
1164
  }],
1156
1165
  isError: true,
1157
1166
  });
@@ -1163,26 +1172,65 @@ server.registerTool("ctx_search", {
1163
1172
  const MAX_TOTAL = 40 * 1024; // 40KB total cap
1164
1173
  let totalSize = 0;
1165
1174
  const sections = [];
1166
- for (const q of queryList) {
1167
- if (totalSize > MAX_TOTAL) {
1168
- sections.push(`## ${q}\n(output cap reached)\n`);
1169
- continue;
1175
+ // Open SessionDB once before the loop (Blocker 4: avoid open/close per query)
1176
+ let timelineDB = null;
1177
+ if (sort === "timeline") {
1178
+ try {
1179
+ const sessionsDir = getSessionDir();
1180
+ const dbFile = join(sessionsDir, `${hashProjectDir()}.db`);
1181
+ if (existsSync(dbFile)) {
1182
+ timelineDB = new SessionDB({ dbPath: dbFile });
1183
+ }
1170
1184
  }
1171
- const results = store.searchWithFallback(q, effectiveLimit, source, contentType);
1172
- if (results.length === 0) {
1173
- sections.push(`## ${q}\nNo results found.`);
1174
- continue;
1185
+ catch { /* SessionDB unavailable search ContentStore + auto-memory only */ }
1186
+ }
1187
+ const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
1188
+ try {
1189
+ for (const q of queryList) {
1190
+ if (totalSize > MAX_TOTAL) {
1191
+ sections.push(`## ${q}\n(output cap reached)\n`);
1192
+ continue;
1193
+ }
1194
+ let results;
1195
+ if (sort === "timeline") {
1196
+ results = searchAllSources({
1197
+ query: q,
1198
+ limit: effectiveLimit,
1199
+ store,
1200
+ sort,
1201
+ source,
1202
+ contentType,
1203
+ sessionDB: timelineDB,
1204
+ projectDir: getProjectDir(),
1205
+ configDir,
1206
+ });
1207
+ }
1208
+ else {
1209
+ results = store.searchWithFallback(q, effectiveLimit, source, contentType);
1210
+ }
1211
+ if (results.length === 0) {
1212
+ sections.push(`## ${q}\nNo results found.`);
1213
+ continue;
1214
+ }
1215
+ const formatted = results
1216
+ .map((r, i) => {
1217
+ const origin = r.origin || "current-session";
1218
+ const ts = r.timestamp ? r.timestamp.slice(0, 16).replace("T", " ") : "";
1219
+ const header = `--- [${origin}${ts ? " | " + ts : ""} | ${r.source}] ---`;
1220
+ const heading = `### ${r.title}`;
1221
+ const snippet = extractSnippet(r.content, q, 1500, r.highlighted);
1222
+ return `${header}\n${heading}\n\n${snippet}`;
1223
+ })
1224
+ .join("\n\n");
1225
+ sections.push(`## ${q}\n\n${formatted}`);
1226
+ totalSize += formatted.length;
1175
1227
  }
1176
- const formatted = results
1177
- .map((r, i) => {
1178
- const header = `--- [${r.source}] ---`;
1179
- const heading = `### ${r.title}`;
1180
- const snippet = extractSnippet(r.content, q, 1500, r.highlighted);
1181
- return `${header}\n${heading}\n\n${snippet}`;
1182
- })
1183
- .join("\n\n");
1184
- sections.push(`## ${q}\n\n${formatted}`);
1185
- totalSize += formatted.length;
1228
+ }
1229
+ finally {
1230
+ try {
1231
+ timelineDB?.close();
1232
+ }
1233
+ catch { }
1186
1234
  }
1187
1235
  let output = sections.join("\n\n---\n\n");
1188
1236
  // Report auto-refreshed stale sources
@@ -1193,7 +1241,7 @@ server.registerTool("ctx_search", {
1193
1241
  if (searchCallCount >= SEARCH_MAX_RESULTS_AFTER) {
1194
1242
  output += `\n\n⚠ search call #${searchCallCount}/${SEARCH_BLOCK_AFTER} in this window. ` +
1195
1243
  `Results limited to ${effectiveLimit}/query. ` +
1196
- `Batch queries: search(queries: ["q1","q2","q3"]) or use batch_execute.`;
1244
+ `Batch queries: ctx_search(queries: ["q1","q2","q3"]) or use ctx_batch_execute.`;
1197
1245
  }
1198
1246
  if (output.trim().length === 0) {
1199
1247
  const sources = store.listSources();
@@ -1296,7 +1344,7 @@ main();
1296
1344
  server.registerTool("ctx_fetch_and_index", {
1297
1345
  title: "Fetch & Index URL",
1298
1346
  description: "Fetches URL content, converts HTML to markdown, indexes into searchable knowledge base, " +
1299
- "and returns a ~3KB preview. Full content stays in sandbox — use search() for deeper lookups.\n\n" +
1347
+ "and returns a ~3KB preview. Full content stays in sandbox — use ctx_search() for deeper lookups.\n\n" +
1300
1348
  "Better than WebFetch: preview is immediate, full content is searchable, raw HTML never enters context.\n\n" +
1301
1349
  "Content-type aware: HTML is converted to markdown, JSON is chunked by key paths, plain text is indexed directly.\n\n" +
1302
1350
  "When reporting results — terse like caveman. Technical substance exact. Only fluff die. Pattern: [thing] [action] [reason]. [next step].",
@@ -1332,7 +1380,7 @@ server.registerTool("ctx_fetch_and_index", {
1332
1380
  return trackResponse("ctx_fetch_and_index", {
1333
1381
  content: [{
1334
1382
  type: "text",
1335
- text: `Cached: **${meta.label}** — ${meta.chunkCount} sections, indexed ${ageStr} (fresh, TTL: 24h).\nTo refresh: call ctx_fetch_and_index again with \`force: true\`.\n\nYou MUST call search() to answer questions about this content — this cached response contains no content.\nUse: search(queries: [...], source: "${meta.label}")`,
1383
+ text: `Cached: **${meta.label}** — ${meta.chunkCount} sections, indexed ${ageStr} (fresh, TTL: 24h).\nTo refresh: call ctx_fetch_and_index again with \`force: true\`.\n\nYou MUST call ctx_search() to answer questions about this content — this cached response contains no content.\nUse: ctx_search(queries: [...], source: "${meta.label}")`,
1336
1384
  }],
1337
1385
  });
1338
1386
  }
@@ -1406,12 +1454,12 @@ server.registerTool("ctx_fetch_and_index", {
1406
1454
  // Build preview — first ~3KB of markdown for immediate use
1407
1455
  const PREVIEW_LIMIT = 3072;
1408
1456
  const preview = markdown.length > PREVIEW_LIMIT
1409
- ? markdown.slice(0, PREVIEW_LIMIT) + "\n\n…[truncated — use search() for full content]"
1457
+ ? markdown.slice(0, PREVIEW_LIMIT) + "\n\n…[truncated — use ctx_search() for full content]"
1410
1458
  : markdown;
1411
1459
  const totalKB = (Buffer.byteLength(markdown) / 1024).toFixed(1);
1412
1460
  const text = [
1413
1461
  `Fetched and indexed **${indexed.totalChunks} sections** (${totalKB}KB) from: ${indexed.label}`,
1414
- `Full content indexed in sandbox — use search(queries: [...], source: "${indexed.label}") for specific lookups.`,
1462
+ `Full content indexed in sandbox — use ctx_search(queries: [...], source: "${indexed.label}") for specific lookups.`,
1415
1463
  "",
1416
1464
  "---",
1417
1465
  "",
@@ -1445,8 +1493,8 @@ server.registerTool("ctx_batch_execute", {
1445
1493
  title: "Batch Execute & Search",
1446
1494
  description: "Execute multiple commands in ONE call, auto-index all output, and search with multiple queries. " +
1447
1495
  "Returns search results directly — no follow-up calls needed.\n\n" +
1448
- "THIS IS THE PRIMARY TOOL. Use this instead of multiple execute() calls.\n\n" +
1449
- "One batch_execute call replaces 30+ execute calls + 10+ search calls.\n" +
1496
+ "THIS IS THE PRIMARY TOOL. Use this instead of multiple ctx_execute() calls.\n\n" +
1497
+ "One ctx_batch_execute call replaces 30+ ctx_execute calls + 10+ ctx_search calls.\n" +
1450
1498
  "Provide all commands to run and all queries to search — everything happens in one round trip.\n\n" +
1451
1499
  "THINK IN CODE: When commands produce data you need to analyze, add processing commands that filter and summarize. Don't pull raw output into context — let the sandbox do the work.\n\n" +
1452
1500
  "When reporting results — terse like caveman. Technical substance exact. Only fluff die. Pattern: [thing] [action] [reason]. [next step].",
@@ -1560,7 +1608,7 @@ server.registerTool("ctx_batch_execute", {
1560
1608
  sectionTitles.push(s.title);
1561
1609
  }
1562
1610
  // Run all search queries — source scoped only.
1563
- // Cross-source search remains available via explicit search().
1611
+ // Cross-source search remains available via explicit ctx_search().
1564
1612
  const queryResults = formatBatchQueryResults(store, queries, source);
1565
1613
  // Get searchable terms for edge cases where follow-up is needed
1566
1614
  const distinctiveTerms = store.getDistinctiveTerms
@@ -182,7 +182,7 @@ export class AnalyticsEngine {
182
182
  if (row.category === "file") {
183
183
  display = row.data.split("/").pop() || row.data;
184
184
  }
185
- else if (row.category === "prompt") {
185
+ else if (row.category === "prompt" || row.category === "user-prompt") {
186
186
  display = display.length > 50 ? display.slice(0, 47) + "..." : display;
187
187
  }
188
188
  if (display.length > 40)
@@ -92,6 +92,23 @@ export declare class SessionDB extends SQLiteBase {
92
92
  * Return the most recently attributed project dir for a session.
93
93
  */
94
94
  getLatestAttributedProjectDir(sessionId: string): string | null;
95
+ /**
96
+ * Search events by text query scoped to a project directory.
97
+ *
98
+ * Performs a case-insensitive LIKE search across the `data` and `category`
99
+ * columns. An optional `source` parameter filters by exact category match.
100
+ * Returns results ordered by monotonic id (chronological).
101
+ *
102
+ * Best-effort: returns empty array on any error.
103
+ */
104
+ searchEvents(query: string, limit: number, projectDir: string, source?: string): Array<{
105
+ id: number;
106
+ session_id: string;
107
+ category: string;
108
+ type: string;
109
+ data: string;
110
+ created_at: string;
111
+ }>;
95
112
  /**
96
113
  * Ensure a session metadata entry exists. Idempotent (INSERT OR IGNORE).
97
114
  * `projectDir` is the session origin directory, not per-event attribution.
@@ -76,6 +76,7 @@ const S = {
76
76
  deleteMeta: "deleteMeta",
77
77
  deleteResume: "deleteResume",
78
78
  getOldSessions: "getOldSessions",
79
+ searchEvents: "searchEvents",
79
80
  };
80
81
  // ─────────────────────────────────────────────────────────
81
82
  // SessionDB
@@ -225,6 +226,14 @@ export class SessionDB extends SQLiteBase {
225
226
  p(S.deleteEvents, `DELETE FROM session_events WHERE session_id = ?`);
226
227
  p(S.deleteMeta, `DELETE FROM session_meta WHERE session_id = ?`);
227
228
  p(S.deleteResume, `DELETE FROM session_resume WHERE session_id = ?`);
229
+ // ── Search ──
230
+ p(S.searchEvents, `SELECT id, session_id, category, type, data, created_at
231
+ FROM session_events
232
+ WHERE project_dir = ?
233
+ AND (data LIKE '%' || ? || '%' ESCAPE '\\' OR category LIKE '%' || ? || '%' ESCAPE '\\')
234
+ AND (? IS NULL OR category = ?)
235
+ ORDER BY id ASC
236
+ LIMIT ?`);
228
237
  // ── Cleanup ──
229
238
  p(S.getOldSessions, `SELECT session_id FROM session_meta WHERE started_at < datetime('now', ? || ' days')`);
230
239
  }
@@ -310,6 +319,25 @@ export class SessionDB extends SQLiteBase {
310
319
  const row = this.stmt(S.getLatestAttributedProject).get(sessionId);
311
320
  return row?.project_dir || null;
312
321
  }
322
+ /**
323
+ * Search events by text query scoped to a project directory.
324
+ *
325
+ * Performs a case-insensitive LIKE search across the `data` and `category`
326
+ * columns. An optional `source` parameter filters by exact category match.
327
+ * Returns results ordered by monotonic id (chronological).
328
+ *
329
+ * Best-effort: returns empty array on any error.
330
+ */
331
+ searchEvents(query, limit, projectDir, source) {
332
+ try {
333
+ const escapedQuery = query.replace(/[%_]/g, (char) => "\\" + char);
334
+ const sourceParam = source ?? null;
335
+ return this.stmt(S.searchEvents).all(projectDir, escapedQuery, escapedQuery, sourceParam, sourceParam, limit);
336
+ }
337
+ catch {
338
+ return [];
339
+ }
340
+ }
313
341
  // ═══════════════════════════════════════════
314
342
  // Meta
315
343
  // ═══════════════════════════════════════════
@@ -35,6 +35,10 @@ export interface HookInput {
35
35
  isError?: boolean;
36
36
  };
37
37
  }
38
+ /** Reset error-resolution state (for testing). */
39
+ export declare function resetErrorResolutionState(): void;
40
+ /** Reset iteration-loop state (for testing). */
41
+ export declare function resetIterationLoopState(): void;
38
42
  /**
39
43
  * Extract session events from a PostToolUse hook input.
40
44
  *
@@ -335,9 +335,36 @@ function extractSkill(input) {
335
335
  type: "skill",
336
336
  category: "skill",
337
337
  data: safeString(skillName),
338
- priority: 3,
338
+ priority: 2,
339
339
  }];
340
340
  }
341
+ /**
342
+ * Category 16: constraint
343
+ * Constraints discovered through error events — tool failures reveal
344
+ * platform/environment limitations worth remembering.
345
+ */
346
+ function extractConstraint(input) {
347
+ // Only fire on error events — constraints are discovered through failures
348
+ if (!input.tool_response?.includes("Error") && !input.tool_output?.isError)
349
+ return [];
350
+ const response = String(input.tool_response || "");
351
+ const patterns = [/not supported/i, /cannot/i, /does not support/i, /FAIL/i, /refused/i, /permission denied/i, /incompatible/i];
352
+ for (const pattern of patterns) {
353
+ const match = response.match(pattern);
354
+ if (match) {
355
+ // Extract context around the match
356
+ const idx = response.toLowerCase().indexOf(match[0].toLowerCase());
357
+ const context = response.slice(Math.max(0, idx - 50), Math.min(response.length, idx + 200)).trim();
358
+ return [{
359
+ type: "constraint_discovered",
360
+ category: "constraint",
361
+ data: safeString(context),
362
+ priority: 2,
363
+ }];
364
+ }
365
+ }
366
+ return [];
367
+ }
341
368
  /**
342
369
  * Category 9: subagent
343
370
  * Agent tool calls — tracks both launch and completion.
@@ -410,6 +437,67 @@ function extractDecision(input) {
410
437
  priority: 2,
411
438
  }];
412
439
  }
440
+ /**
441
+ * Category 22: agent-finding
442
+ * When the Agent tool completes (subagent returns), capture a structured
443
+ * summary of its findings (first 500 chars of tool_response).
444
+ */
445
+ function extractAgentFinding(input) {
446
+ if (input.tool_name !== "Agent")
447
+ return [];
448
+ if (!input.tool_response || input.tool_response.length === 0)
449
+ return [];
450
+ const summary = input.tool_response.length > 500
451
+ ? input.tool_response.slice(0, 500)
452
+ : input.tool_response;
453
+ return [{
454
+ type: "agent_finding",
455
+ category: "agent-finding",
456
+ data: safeString(summary),
457
+ priority: 2,
458
+ }];
459
+ }
460
+ /**
461
+ * Category 24: external-ref
462
+ * Scan tool_input and tool_response for external URLs, GitHub issues, and PRs.
463
+ * Deduplicates found refs and skips internal URLs (localhost, 127.0.0.1).
464
+ */
465
+ function extractExternalRef(input) {
466
+ const haystack = [
467
+ safeStringAny(input.tool_input),
468
+ safeString(input.tool_response),
469
+ ].join(" ");
470
+ if (haystack.length === 0)
471
+ return [];
472
+ const refs = new Set();
473
+ // URLs — skip localhost / 127.0.0.1
474
+ const urlMatches = haystack.match(/https?:\/\/[^\s)]+/g);
475
+ if (urlMatches) {
476
+ for (let url of urlMatches) {
477
+ // Strip trailing punctuation that gets captured from JSON/prose
478
+ url = url.replace(/["'})\],;.]+$/, "");
479
+ if (!/localhost|127\.0\.0\.1/i.test(url)) {
480
+ refs.add(url);
481
+ }
482
+ }
483
+ }
484
+ // Full GitHub issue/PR URLs are already captured above.
485
+ // Shorthand GitHub issue refs: #123 (only bare, not inside a URL)
486
+ const issueMatches = haystack.match(/(?<!\w)#(\d+)/g);
487
+ if (issueMatches) {
488
+ for (const m of issueMatches) {
489
+ refs.add(m);
490
+ }
491
+ }
492
+ if (refs.size === 0)
493
+ return [];
494
+ return [{
495
+ type: "external_ref",
496
+ category: "external-ref",
497
+ data: safeString(Array.from(refs).join(", ")),
498
+ priority: 3,
499
+ }];
500
+ }
413
501
  /**
414
502
  * Category 8: env (worktree)
415
503
  * EnterWorktree tool — tracks worktree creation.
@@ -490,6 +578,52 @@ function extractIntent(message) {
490
578
  priority: 4,
491
579
  }];
492
580
  }
581
+ /**
582
+ * Category 25: blocked-on
583
+ * Detect when work is blocked on something, or when a blocker is resolved.
584
+ */
585
+ const BLOCKER_PATTERNS = [
586
+ /\bblocked on\b/i,
587
+ /\bwaiting for\b/i,
588
+ /\bneed\s+\S+\s+before\b/i,
589
+ /\bcan'?t proceed until\b/i,
590
+ /\bdepends on\b/i,
591
+ /\bblocked\b/i,
592
+ // Turkish patterns
593
+ /\bbekliyor\b/i,
594
+ /\bbekliyorum\b/i,
595
+ ];
596
+ const BLOCKER_RESOLVED_PATTERNS = [
597
+ /\bunblocked\b/i,
598
+ /\bresolved\b/i,
599
+ /\bgot the\s+\S+/i,
600
+ /\bis ready now\b/i,
601
+ /\bcan proceed\b/i,
602
+ ];
603
+ function extractBlocker(message) {
604
+ const events = [];
605
+ // Check resolution first — if both match, resolution takes priority
606
+ const isResolved = BLOCKER_RESOLVED_PATTERNS.some(p => p.test(message));
607
+ if (isResolved) {
608
+ events.push({
609
+ type: "blocker_resolved",
610
+ category: "blocked-on",
611
+ data: safeString(message),
612
+ priority: 2,
613
+ });
614
+ return events;
615
+ }
616
+ const isBlocked = BLOCKER_PATTERNS.some(p => p.test(message));
617
+ if (isBlocked) {
618
+ events.push({
619
+ type: "blocker",
620
+ category: "blocked-on",
621
+ data: safeString(message),
622
+ priority: 2,
623
+ });
624
+ }
625
+ return events;
626
+ }
493
627
  /**
494
628
  * Category 12: data
495
629
  * Large user-pasted data references (message > 1KB).
@@ -504,6 +638,96 @@ function extractData(message) {
504
638
  priority: 4,
505
639
  }];
506
640
  }
641
+ // ── Cross-event stateful extractors ───────────────────────────────────────
642
+ /**
643
+ * Category 23: error-resolution
644
+ * Detects when an error is followed by a successful fix (cross-event state).
645
+ */
646
+ let lastError = null;
647
+ function extractErrorResolution(input) {
648
+ const { tool_name, tool_response, tool_output } = input;
649
+ const response = String(tool_response ?? "");
650
+ const isErrorFlag = tool_output?.isError === true;
651
+ const isBashError = tool_name === "Bash" &&
652
+ /exit code [1-9]|error:|Error:|FAIL|failed/i.test(response);
653
+ // If this call is an error, store it and return
654
+ if (isBashError || isErrorFlag) {
655
+ lastError = { tool: tool_name, error: response.slice(0, 200), callsSince: 0 };
656
+ return [];
657
+ }
658
+ // No pending error → nothing to resolve
659
+ if (!lastError)
660
+ return [];
661
+ // Increment staleness counter
662
+ lastError.callsSince++;
663
+ // Timeout: clear after 10 calls without resolution
664
+ if (lastError.callsSince > 10) {
665
+ lastError = null;
666
+ return [];
667
+ }
668
+ // Check if this is a resolution: same tool, or Edit/Write after a Read error
669
+ const sameTool = tool_name === lastError.tool;
670
+ const editAfterReadError = lastError.tool === "Read" && (tool_name === "Edit" || tool_name === "Write");
671
+ if (sameTool || editAfterReadError) {
672
+ const event = {
673
+ type: "error_resolved",
674
+ category: "error-resolution",
675
+ data: safeString(`Error in ${lastError.tool}: ${lastError.error} → Fixed`),
676
+ priority: 2,
677
+ };
678
+ lastError = null;
679
+ return [event];
680
+ }
681
+ return [];
682
+ }
683
+ /** Reset error-resolution state (for testing). */
684
+ export function resetErrorResolutionState() {
685
+ lastError = null;
686
+ }
687
+ /**
688
+ * Category 26: iteration-loop
689
+ * Detects when the same tool is called repeatedly with similar input (stuck loop).
690
+ */
691
+ const callHistory = [];
692
+ function simpleHash(str) {
693
+ return `${str.length}:${str.slice(0, 20)}`;
694
+ }
695
+ function extractIterationLoop(input) {
696
+ const { tool_name, tool_input } = input;
697
+ const inputHash = simpleHash(JSON.stringify(tool_input).slice(0, 200));
698
+ callHistory.push({ tool: tool_name, inputHash });
699
+ // Keep history bounded
700
+ if (callHistory.length > 50) {
701
+ callHistory.splice(0, callHistory.length - 50);
702
+ }
703
+ // Check last N entries for repeated pattern (minimum 3)
704
+ if (callHistory.length < 3)
705
+ return [];
706
+ let count = 0;
707
+ for (let i = callHistory.length - 1; i >= 0; i--) {
708
+ if (callHistory[i].tool === tool_name && callHistory[i].inputHash === inputHash) {
709
+ count++;
710
+ }
711
+ else {
712
+ break;
713
+ }
714
+ }
715
+ if (count >= 3) {
716
+ // Reset the matching tail to avoid duplicate emissions
717
+ callHistory.splice(callHistory.length - count);
718
+ return [{
719
+ type: "retry_detected",
720
+ category: "iteration-loop",
721
+ data: safeString(`${tool_name} called ${count} times with similar input`),
722
+ priority: 2,
723
+ }];
724
+ }
725
+ return [];
726
+ }
727
+ /** Reset iteration-loop state (for testing). */
728
+ export function resetIterationLoopState() {
729
+ callHistory.length = 0;
730
+ }
507
731
  // ── Public API ─────────────────────────────────────────────────────────────
508
732
  /**
509
733
  * Extract session events from a PostToolUse hook input.
@@ -528,7 +752,13 @@ export function extractEvents(input) {
528
752
  events.push(...extractSubagent(input));
529
753
  events.push(...extractMcp(input));
530
754
  events.push(...extractDecision(input));
755
+ events.push(...extractConstraint(input));
531
756
  events.push(...extractWorktree(input));
757
+ events.push(...extractAgentFinding(input));
758
+ events.push(...extractExternalRef(input));
759
+ // Cross-event stateful extractors
760
+ events.push(...extractErrorResolution(input));
761
+ events.push(...extractIterationLoop(input));
532
762
  return events;
533
763
  }
534
764
  catch {
@@ -548,6 +778,7 @@ export function extractUserEvents(message) {
548
778
  events.push(...extractUserDecision(message));
549
779
  events.push(...extractRole(message));
550
780
  events.push(...extractIntent(message));
781
+ events.push(...extractBlocker(message));
551
782
  events.push(...extractData(message));
552
783
  return events;
553
784
  }