ai-hist 0.6.0 → 0.8.2

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 (58) hide show
  1. package/README.md +34 -254
  2. package/dist/cli.d.ts +3 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +248 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/cloud-client.d.ts +0 -1
  7. package/dist/cloud-client.d.ts.map +1 -1
  8. package/dist/cloud-client.js +0 -2
  9. package/dist/cloud-client.js.map +1 -1
  10. package/dist/index.d.ts +138 -309
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +258 -1191
  13. package/dist/index.js.map +1 -1
  14. package/dist/mcp-server.d.ts +0 -9
  15. package/dist/mcp-server.d.ts.map +1 -1
  16. package/dist/mcp-server.js +52 -790
  17. package/dist/mcp-server.js.map +1 -1
  18. package/package.json +14 -8
  19. package/dist/cloud-push.d.ts +0 -51
  20. package/dist/cloud-push.d.ts.map +0 -1
  21. package/dist/cloud-push.js +0 -113
  22. package/dist/cloud-push.js.map +0 -1
  23. package/dist/cloud-push.test.d.ts +0 -2
  24. package/dist/cloud-push.test.d.ts.map +0 -1
  25. package/dist/cloud-push.test.js +0 -82
  26. package/dist/cloud-push.test.js.map +0 -1
  27. package/dist/jsonl-sources.d.ts +0 -33
  28. package/dist/jsonl-sources.d.ts.map +0 -1
  29. package/dist/jsonl-sources.js +0 -351
  30. package/dist/jsonl-sources.js.map +0 -1
  31. package/dist/mcp-smoke.test.d.ts +0 -2
  32. package/dist/mcp-smoke.test.d.ts.map +0 -1
  33. package/dist/mcp-smoke.test.js +0 -574
  34. package/dist/mcp-smoke.test.js.map +0 -1
  35. package/dist/pair-client.d.ts +0 -41
  36. package/dist/pair-client.d.ts.map +0 -1
  37. package/dist/pair-client.js +0 -60
  38. package/dist/pair-client.js.map +0 -1
  39. package/dist/pair-client.test.d.ts +0 -2
  40. package/dist/pair-client.test.d.ts.map +0 -1
  41. package/dist/pair-client.test.js +0 -118
  42. package/dist/pair-client.test.js.map +0 -1
  43. package/dist/session-catalog.d.ts +0 -257
  44. package/dist/session-catalog.d.ts.map +0 -1
  45. package/dist/session-catalog.js +0 -340
  46. package/dist/session-catalog.js.map +0 -1
  47. package/dist/session-catalog.test.d.ts +0 -2
  48. package/dist/session-catalog.test.d.ts.map +0 -1
  49. package/dist/session-catalog.test.js +0 -710
  50. package/dist/session-catalog.test.js.map +0 -1
  51. package/dist/session-events.test.d.ts +0 -2
  52. package/dist/session-events.test.d.ts.map +0 -1
  53. package/dist/session-events.test.js +0 -177
  54. package/dist/session-events.test.js.map +0 -1
  55. package/dist/trajectory-sources.d.ts +0 -36
  56. package/dist/trajectory-sources.d.ts.map +0 -1
  57. package/dist/trajectory-sources.js +0 -201
  58. package/dist/trajectory-sources.js.map +0 -1
@@ -1,796 +1,58 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * ai-hist MCP server — exposes AI coding agent history as MCP tools.
4
- *
5
- * Covers Claude Code, Codex, Cursor, Grok, OpenCode, Agent Relay, and trajectories in a single index.
6
- * Uses the ai-hist SQLite database when present ($AI_HIST_DB or the
7
- * default ~/.local/share/ai-hist/ai-history.db), falling back to
8
- * scanning local JSONL source files directly so the server works without
9
- * running `ai-hist sync` first.
10
- */
11
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
- import { existsSync } from "node:fs";
14
- import { resolve } from "node:path";
15
- import { z } from "zod";
16
- import { defaultDbPath, openAiHist, resumeCommand, SESSION_CATALOG_CONTRACT_VERSION, } from "./index.js";
17
- import { formatPairWarnings, pairCheck } from "./pair-client.js";
18
- // ---------------------------------------------------------------------------
19
- // Lazy singleton — opens on first tool call and reuses across all calls.
20
- // ---------------------------------------------------------------------------
21
- let _histPromise = null;
22
- function normalizeProjectArg(value) {
23
- const trimmed = value?.trim();
24
- if (!trimmed)
25
- return undefined;
26
- return resolve(trimmed);
2
+ /** Thin MCP adapters over the public `ai-hist` TypeScript SDK. */
3
+ import { readFileSync } from 'node:fs';
4
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import { z } from 'zod';
7
+ import { discoverSessions, getSession, getSessionEventsPage, listSessionCatalogPage, recent, search, stats, sync, } from './index.js';
8
+ const READ = { readOnlyHint: true, idempotentHint: true, openWorldHint: false };
9
+ const WRITE = { readOnlyHint: false, idempotentHint: true, openWorldHint: false };
10
+ const SOURCE = z.enum(['claude', 'codex', 'cursor', 'grok', 'relay', 'trajectory', 'opencode']);
11
+ const CATALOG_SOURCE = z.enum(['claude', 'codex', 'cursor', 'grok', 'relay', 'opencode']);
12
+ const packageVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
13
+ const server = new McpServer({ name: 'ai-hist', version: packageVersion }, { capabilities: { tools: {} } });
14
+ function result(value) {
15
+ return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
27
16
  }
28
- function configuredProjectScope() {
29
- for (let i = 2; i < process.argv.length; i++) {
30
- const arg = process.argv[i];
31
- if (arg === "--project" || arg === "--project-path") {
32
- const next = process.argv[i + 1];
33
- if (next && !next.startsWith("-")) {
34
- const value = normalizeProjectArg(next);
35
- if (value)
36
- return value;
37
- i++;
38
- }
39
- continue;
40
- }
41
- for (const prefix of ["--project=", "--project-path="]) {
42
- if (arg.startsWith(prefix)) {
43
- const value = normalizeProjectArg(arg.slice(prefix.length));
44
- if (value)
45
- return value;
46
- }
47
- }
48
- }
49
- return undefined;
50
- }
51
- function getHist() {
52
- if (!_histPromise) {
53
- _histPromise = openAiHist({ fallback: "jsonl", projectScope: configuredProjectScope() }).catch((err) => {
54
- _histPromise = null;
55
- throw err;
56
- });
57
- }
58
- return _histPromise;
59
- }
60
- /**
61
- * The reader for a catalog read, or `null` when there is nothing to read.
62
- *
63
- * The session catalog lives only in the SQLite database: the JSONL fallback
64
- * scan builds prompt history and never writes a catalog row. So when no
65
- * database exists, opening the reader would walk every local provider file —
66
- * seconds of I/O — only to answer with an empty catalog. Short-circuit instead,
67
- * unless another tool has already paid for the reader, in which case reuse it.
68
- */
69
- async function getCatalogHist() {
70
- if (_histPromise)
71
- return getHist();
72
- if (!existsSync(defaultDbPath()))
73
- return null;
74
- return getHist();
75
- }
76
- function fmtEntry(e, maxChars = 300) {
77
- const dt = new Date(e.timestampMs).toISOString().slice(0, 16).replace("T", " ");
78
- const proj = e.project ? ` [${e.project}]` : "";
79
- const text = e.prompt.length > maxChars
80
- ? e.prompt.slice(0, maxChars).replace(/\n/g, " ") + "..."
81
- : e.prompt.replace(/\n/g, " ");
82
- return `#${e.id} ${dt} (${e.source})${proj}\n session_id: ${e.sessionId ?? "(none)"}\n ${text}`;
83
- }
84
- function fmtTrajectory(t, maxChars = 500) {
85
- const dt = new Date(t.timestampMs).toISOString().slice(0, 16).replace("T", " ");
86
- const title = t.task.title ?? "(untitled task)";
87
- const project = t.projectId ? ` [${t.projectId}]` : "";
88
- const persona = t.personaId ? ` persona: ${t.personaId}` : "";
89
- const decisions = t.decisions
90
- .map((d, index) => {
91
- const alternatives = d.alternatives.length > 0 ? ` alternatives: ${d.alternatives.join(", ")}` : "";
92
- return `${index + 1}. ${d.question}\n chosen: ${d.chosen}\n reasoning: ${d.reasoning}${alternatives}`;
93
- })
94
- .join("\n");
95
- const retroParts = [
96
- t.retrospective.summary ? `summary: ${t.retrospective.summary}` : "",
97
- t.retrospective.approach ? `approach: ${t.retrospective.approach}` : "",
98
- t.retrospective.learnings.length > 0 ? `learnings: ${t.retrospective.learnings.join("; ")}` : "",
99
- t.retrospective.confidence != null ? `confidence: ${String(t.retrospective.confidence)}` : "",
100
- ].filter(Boolean);
101
- const body = [
102
- `trajectory_id: ${t.id}`,
103
- `${dt}${project}${persona}`,
104
- `task: ${title}`,
105
- t.task.description ? `description: ${t.task.description}` : "",
106
- decisions ? `decisions:\n${decisions}` : "",
107
- retroParts.length > 0 ? `retrospective:\n${retroParts.join("\n")}` : "",
108
- ].filter(Boolean).join("\n");
109
- return body.length > maxChars ? body.slice(0, maxChars).replace(/\n/g, " ") + "..." : body;
110
- }
111
- const READ_ONLY = { readOnlyHint: true, idempotentHint: true, openWorldHint: false };
112
- const WRITE_LOCAL = { readOnlyHint: false, idempotentHint: true, openWorldHint: false };
113
- const REMOTE_READ_ONLY = { readOnlyHint: true, idempotentHint: true, openWorldHint: true };
114
- const SOURCE_SCHEMA = z.enum(["claude", "codex", "cursor", "grok", "relay", "trajectory", "opencode"]);
115
- // The session catalog holds provider sessions only: trajectories are derived
116
- // records and never appear in a session listing.
117
- const CATALOG_SOURCE_SCHEMA = z.enum(["claude", "codex", "cursor", "grok", "relay", "opencode"]);
118
- // ---------------------------------------------------------------------------
119
- // Server
120
- // ---------------------------------------------------------------------------
121
- const server = new McpServer({ name: "ai-hist", version: "1.0.0" }, { capabilities: { tools: {}, prompts: {} } });
122
- // ---------------------------------------------------------------------------
123
- // System prompt
124
- // ---------------------------------------------------------------------------
125
- server.prompt("agent-history-guide", "How to search and use your AI coding agent history via ai-hist", () => ({
126
- messages: [
127
- {
128
- role: "user",
129
- content: {
130
- type: "text",
131
- text: `You are connected to the ai-hist MCP server, which gives you access to a
132
- unified history of AI coding agent sessions across Claude Code, Codex, Cursor,
133
- Grok, OpenCode, Agent Relay, and trajectories — all searchable in a single index.
134
-
135
- ## Available tools
136
-
137
- - **search_history** — Full-text search across all prompts. Start here when looking
138
- for past work on a specific topic, bug, feature, or keyword. Supports boolean
139
- operators (AND, OR, NOT) and prefix wildcards (*).
140
-
141
- - **get_session** — Read the full conversation thread for a session_id. Use after
142
- search_history or recent_history to see the complete context of a session, and
143
- to get the exact CLI command to resume it.
144
-
145
- - **get_context** — Reconstruct what you were working on at a specific moment in
146
- time. Shows the full session the entry belongs to, plus nearby entries from other
147
- sessions within a configurable time window.
148
-
149
- - **recent_history** — Browse what was worked on recently across all agents, or
150
- filter by source and project.
151
-
152
- - **list_sessions** — List sessions from the local session catalog, newest first:
153
- source, session_id, cwd, git branch, first/last activity, first prompt, models,
154
- and discovery_state. Cache-only — it reads one indexed table and never opens a
155
- transcript, so prefer it over recent_history when the question is "which sessions
156
- exist?" rather than "which prompts mention X?".
157
-
158
- - **pack_evidence** — Assemble a concise, token-budget-aware summary of the most
159
- relevant past sessions before starting a new task. Each entry includes a resume
160
- command so you can continue where you left off.
161
-
162
- - **history_stats** — Get a quick overview of what is indexed: total counts by
163
- source, date range, and top projects.
164
-
165
- - **tag_session**, **untag_session**, **list_tags** — Organize sessions with
166
- logical tags so future searches can filter to a named workstream.
167
-
168
- - **search_trajectories** — Search compacted per-run decision trajectories: the
169
- WHY behind persona work, including decisions and retrospectives.
170
-
171
- - **why_for_task** — Return the best matching trajectory's decisions and
172
- retrospective for a task query.
173
-
174
- - **get_handoff** — Find sessions for a repo/branch and generate a warm-start
175
- handoff brief so you can continue work in a different CLI. Use this when you
176
- need to switch from Claude Code to Codex (or vice versa) because one is down
177
- or quota-exhausted. Ask: "where did codex leave off on branch feat/auth in
178
- /my-repo?" and this tool returns ranked candidates with resume commands and
179
- a ready-to-paste warm-start command for the target CLI.
180
-
181
- - **pair_check** — Ask relayhistory-cloud for advisory in-session warnings before
182
- a prompt, plan, or tool action. It sends only minimal current context and returns
183
- cited, scrubbed warnings from prior convergence events.
184
-
185
- ## Tips
186
-
187
- - If a tool returns "database not found", the user should run \`ai-hist sync\` to
188
- populate the database from their local history files.
189
- - session_id values from search results can be passed directly to get_session.
190
- - Use pack_evidence before starting a complex task to pull in relevant prior context.`,
191
- },
192
- },
193
- ],
194
- }));
195
- // ---------------------------------------------------------------------------
196
- // Tools
197
- // ---------------------------------------------------------------------------
198
- server.tool("pair_check", "Ask relayhistory-cloud Pair for advisory warnings before a prompt, plan, or tool action. " +
199
- "This shells to `ai-hist pair check --json`, which calls POST /v1/pair/check using stored " +
200
- "relayhistory auth. Send only minimal current context: repo/cwd, " +
201
- "task summary, files, pending tool/action, and a short recent prompt summary. The response cites " +
202
- "scrubbed convergence-event evidence and never blocks by itself.", {
203
- projectId: z.string().optional().describe("Canonical project id if known. Optional; server can infer from path/repo."),
204
- repoPath: z.string().optional().describe("Local repository path or normalized repository identity."),
205
- cwd: z.string().optional().describe("Current working directory for the session."),
206
- gitRemote: z.string().optional().describe("Git remote URL if known, used by the server to infer project identity."),
207
- task: z.string().optional().describe("Short current task or prompt summary. Do not send raw transcript."),
208
- files: z.array(z.string()).optional().default([]).describe("Files in scope or about to be read/edited."),
209
- tool: z.string().optional().describe("Pending tool name, for example Edit, Write, Bash, or apply_patch."),
210
- target: z.string().optional().describe("Primary pending target, such as a file path or command target."),
211
- action: z.string().optional().describe("Short action label, for example edit, write, read, run-tests, or plan."),
212
- recentPrompt: z.string().optional().describe("Short recent prompt summary. Keep it minimal; do not send full transcript."),
213
- limit: z.number().int().min(1).max(10).optional().default(5).describe("Maximum warnings to return."),
214
- }, REMOTE_READ_ONLY, async ({ projectId, repoPath, cwd, gitRemote, task, files, tool, target, action, recentPrompt, limit }) => {
17
+ async function call(operation) {
215
18
  try {
216
- const result = await pairCheck({
217
- context: { projectId, repoPath, cwd, gitRemote, task, files, tool, target, action, recentPrompt },
218
- limit,
219
- });
220
- const text = formatPairWarnings(result) || JSON.stringify({ decision: result.decision ?? "allow", warnings: [] }, null, 2);
221
- return { content: [{ type: "text", text }] };
222
- }
223
- catch (err) {
224
- return { content: [{ type: "text", text: `Error: ${String(err)}` }], isError: true };
225
- }
226
- });
227
- server.tool("search_history", "Search your AI coding agent history using full-text search across all prompts " +
228
- "from Claude Code, Codex, Cursor, Grok, OpenCode, Agent Relay, and trajectories. Returns matching entries with " +
229
- "source, project path, session ID, and timestamp ordered by most recent first. " +
230
- "Supports FTS5 boolean operators (AND, OR, NOT), leading - to exclude a term, and " +
231
- "trailing * for prefix matching. Use get_session with a returned session_id to read " +
232
- "the full conversation.", {
233
- query: z
234
- .string()
235
- .describe("Search query. Plain terms are matched literally. Use AND/OR/NOT (uppercase) for " +
236
- 'boolean, leading - to exclude, trailing * for prefix. ' +
237
- 'Examples: "authentication", "auth AND login", "deploy*", "refactor -test"'),
238
- source: SOURCE_SCHEMA
239
- .optional()
240
- .describe("Filter to a single agent source. Omit to search all sources."),
241
- project: z
242
- .string()
243
- .optional()
244
- .describe('Filter by project directory path. Substring match — use a partial path like "/myproject" or "src/api".'),
245
- tag: z.string().optional().describe("Filter to sessions tagged with this tag."),
246
- limit: z
247
- .number()
248
- .int()
249
- .min(1)
250
- .max(100)
251
- .optional()
252
- .default(20)
253
- .describe("Maximum number of results to return. Default: 20."),
254
- }, READ_ONLY, async ({ query, source, project, tag, limit }) => {
255
- try {
256
- const hist = await getHist();
257
- const results = hist.search(query, { source, project, tag, limit });
258
- if (results.length === 0)
259
- return { content: [{ type: "text", text: "No results found." }] };
260
- const lines = [`Found ${results.length} result(s):\n`];
261
- for (const e of results)
262
- lines.push(fmtEntry(e));
263
- return { content: [{ type: "text", text: lines.join("\n\n") }] };
264
- }
265
- catch (err) {
266
- return { content: [{ type: "text", text: `Error: ${String(err)}` }], isError: true };
267
- }
268
- });
269
- server.tool("search_trajectories", "Search compacted per-run decision trajectories: task title/description, decisions, " +
270
- "alternatives, reasoning, and retrospective summaries/learnings. Use this for the WHY " +
271
- "behind prior persona work.", {
272
- query: z.string().describe("Search query for decisions, task text, personaId, projectId, or retrospectives."),
273
- limit: z
274
- .number()
275
- .int()
276
- .min(1)
277
- .max(50)
278
- .optional()
279
- .default(10)
280
- .describe("Maximum number of trajectory results to return. Default: 10."),
281
- }, READ_ONLY, async ({ query, limit }) => {
282
- try {
283
- const hist = await getHist();
284
- const results = hist.searchTrajectories(query, { limit });
285
- if (results.length === 0) {
286
- return { content: [{ type: "text", text: "No trajectories found." }] };
287
- }
288
- const lines = [`Found ${results.length} trajectory result(s):\n`];
289
- for (const t of results)
290
- lines.push(fmtTrajectory(t));
291
- return { content: [{ type: "text", text: lines.join("\n\n") }] };
292
- }
293
- catch (err) {
294
- return { content: [{ type: "text", text: `Error: ${String(err)}` }], isError: true };
19
+ return result(await operation());
295
20
  }
296
- });
297
- server.tool("why_for_task", "Return the decisions and retrospective for the best-matching compacted trajectory. " +
298
- "Use this when starting a task and you need the prior WHY: question, chosen option, " +
299
- "reasoning, alternatives, summary, approach, learnings, and confidence.", {
300
- query: z.string().describe("Task or decision query to match against compacted trajectories."),
301
- }, READ_ONLY, async ({ query }) => {
302
- try {
303
- const hist = await getHist();
304
- const trajectory = hist.whyForTask(query);
305
- if (!trajectory) {
306
- return { content: [{ type: "text", text: "No matching trajectory found." }] };
307
- }
308
- return { content: [{ type: "text", text: JSON.stringify(trajectory, null, 2) }] };
309
- }
310
- catch (err) {
311
- return { content: [{ type: "text", text: `Error: ${String(err)}` }], isError: true };
312
- }
313
- });
314
- server.tool("tag_session", "Tag a source-specific session so future history searches can filter by logical workstream. " +
315
- "If source is omitted and multiple sources have the same session_id, all matching sessions are tagged.", {
316
- session_id: z.string().describe("Session ID to tag."),
317
- tag: z.string().min(1).describe("Tag name, for example release-plan or auth-refactor."),
318
- source: SOURCE_SCHEMA.optional().describe("Optional source to disambiguate the session ID."),
319
- color: z.string().optional().describe("Optional display color label."),
320
- }, WRITE_LOCAL, async ({ session_id, tag, source, color }) => {
321
- try {
322
- const hist = await getHist();
323
- const sessions = hist.tagSession(session_id, tag, { source, color });
324
- if (sessions.length === 0) {
325
- return { content: [{ type: "text", text: `No session found for ${session_id}.` }], isError: true };
326
- }
327
- return {
328
- content: [
329
- {
330
- type: "text",
331
- text: JSON.stringify({ tag, taggedSessions: sessions, dbPath: hist.dbPath }, null, 2),
332
- },
333
- ],
334
- };
335
- }
336
- catch (err) {
337
- return { content: [{ type: "text", text: `Error: ${String(err)}` }], isError: true };
338
- }
339
- });
340
- server.tool("untag_session", "Remove a tag from a session.", {
341
- session_id: z.string().describe("Session ID to untag."),
342
- tag: z.string().min(1).describe("Tag name to remove."),
343
- source: SOURCE_SCHEMA.optional().describe("Optional source to disambiguate the session ID."),
344
- }, WRITE_LOCAL, async ({ session_id, tag, source }) => {
345
- try {
346
- const hist = await getHist();
347
- const removed = hist.untagSession(session_id, tag, { source });
348
- return { content: [{ type: "text", text: JSON.stringify({ tag, removedAssignments: removed }, null, 2) }] };
349
- }
350
- catch (err) {
351
- return { content: [{ type: "text", text: `Error: ${String(err)}` }], isError: true };
352
- }
353
- });
354
- server.tool("list_tags", "List available session tags and optionally include the sessions assigned to each tag.", {
355
- tag: z.string().optional().describe("Optional tag name to inspect."),
356
- include_sessions: z.boolean().optional().default(false).describe("Include tagged session summaries."),
357
- }, READ_ONLY, async ({ tag, include_sessions }) => {
358
- try {
359
- const hist = await getHist();
360
- const tags = hist.listTags({ tag, includeSessions: include_sessions });
361
- return { content: [{ type: "text", text: JSON.stringify(tags, null, 2) }] };
362
- }
363
- catch (err) {
364
- return { content: [{ type: "text", text: `Error: ${String(err)}` }], isError: true };
365
- }
366
- });
367
- server.tool("get_session", "Retrieve all prompts from a specific session ordered oldest to newest. " +
368
- "Use this after search_history or recent_history to read the full conversation " +
369
- "thread for a session_id. Returns all prompts with timestamps and includes the " +
370
- "exact CLI command (claude --resume, codex resume, etc.) to continue that session.", {
371
- session_id: z
372
- .string()
373
- .describe("Session ID to retrieve. Obtain from the session_id field in search_history or recent_history results."),
374
- source: SOURCE_SCHEMA.optional().describe("Optional source to disambiguate the session ID."),
375
- tag: z.string().optional().describe("Only return the session if it has this tag."),
376
- }, READ_ONLY, async ({ session_id, source, tag }) => {
377
- try {
378
- const hist = await getHist();
379
- const entries = hist.getSession(session_id, { source, tag });
380
- if (entries.length === 0) {
381
- return { content: [{ type: "text", text: `No entries found for session ${session_id}.` }] };
382
- }
383
- const first = entries[0];
384
- const resume = resumeCommand(first);
385
- const lines = [
386
- `Session ${session_id} — ${entries.length} entries (${first.source}${first.project ? ", " + first.project : ""})`,
387
- ];
388
- if (resume)
389
- lines.push(`Resume: ${resume}`);
390
- lines.push("");
391
- for (const e of entries) {
392
- const dt = new Date(e.timestampMs).toISOString().slice(0, 16).replace("T", " ");
393
- lines.push(`[${dt}] #${e.id}\n${e.prompt}`);
394
- }
395
- return { content: [{ type: "text", text: lines.join("\n\n") }] };
21
+ catch (error) {
22
+ const value = error;
23
+ return { content: [{ type: 'text', text: `${value.code ?? 'ERROR'}: ${value.message ?? String(error)}` }], isError: true };
396
24
  }
397
- catch (err) {
398
- return { content: [{ type: "text", text: `Error: ${String(err)}` }], isError: true };
399
- }
400
- });
401
- server.tool("list_sessions", "List coding-agent sessions from the local session catalog, newest first. " +
402
- "This is a cache-only read of the `sessions` table: it never opens a provider " +
403
- "transcript and never scans prompt history, so it is the cheapest way to answer " +
404
- '"what sessions exist?" or "what was I working on in this repo?". Each row carries ' +
405
- "source, session_id, cwd, git branch, first/last activity, the first prompt, observed " +
406
- "models, originator, agent version, repo identity, and discovery_state ('shallow' = " +
407
- "catalog metadata only, 'full' = transcript ingested). Populate or refresh the catalog " +
408
- "with `ai-hist sessions discover`; run `ai-hist sync` for full transcripts. Trajectories " +
409
- "are not sessions and never appear here — use search_trajectories for those.", {
410
- sources: z
411
- .array(CATALOG_SOURCE_SCHEMA)
412
- .optional()
413
- .describe("Restrict to these agent sources. Omit for every source in the catalog."),
414
- limit: z
415
- .number()
416
- .int()
417
- .min(1)
418
- .max(200)
419
- .optional()
420
- .default(20)
421
- .describe("Maximum number of sessions to return. Default: 20."),
422
- before_ms: z
423
- .number()
424
- .int()
425
- .optional()
426
- .describe("Coarse cutoff: only sessions whose last activity is strictly older than this epoch-ms " +
427
- "value. It cannot separate sessions that share a millisecond, so use `after` to page. " +
428
- "Ignored when `after` is given."),
429
- after: z
430
- .object({
431
- lastActivityMs: z
432
- .number()
433
- .int()
434
- .nullable()
435
- .optional()
436
- .describe("Null or omitted to continue through sessions whose recency is unknown."),
437
- source: z.string(),
438
- sessionId: z.string(),
439
- })
440
- .optional()
441
- .describe("Precise pagination cursor: pass back the `nextCursor` object from the previous call. " +
442
- "Keep calling until nextCursor is null to walk the whole catalog with no skipped or " +
443
- "repeated sessions, even when many share one timestamp."),
444
- }, READ_ONLY, async ({ sources, limit, before_ms, after }) => {
445
- try {
446
- const hist = await getCatalogHist();
447
- // No database yet: the catalog lives only in SQLite, so this is an empty
448
- // answer rather than a reason to scan every provider file.
449
- if (!hist) {
450
- const dbPath = defaultDbPath();
451
- return {
452
- content: [
453
- {
454
- type: "text",
455
- text: JSON.stringify({
456
- contractVersion: SESSION_CATALOG_CONTRACT_VERSION,
457
- sessions: [],
458
- nextCursor: null,
459
- sourceKind: "none",
460
- dbPath,
461
- note: `No ai-hist database at ${dbPath}. The session catalog is empty until ` +
462
- "`ai-hist sessions discover` (or `ai-hist sync`) writes one — the JSONL " +
463
- "fallback scan only builds prompt history, so other tools still work.",
464
- }, null, 2),
465
- },
466
- ],
467
- };
468
- }
469
- const page = hist.listSessionCatalogPage({ sources, limit, beforeMs: before_ms, after });
470
- return {
471
- content: [
472
- {
473
- type: "text",
474
- text: JSON.stringify({
475
- contractVersion: SESSION_CATALOG_CONTRACT_VERSION,
476
- sessions: page.sessions,
477
- nextCursor: page.nextCursor,
478
- sourceKind: hist.sourceKind,
479
- dbPath: hist.dbPath,
480
- projectScope: hist.projectScope,
481
- }, null, 2),
482
- },
483
- ],
484
- };
485
- }
486
- catch (err) {
487
- return { content: [{ type: "text", text: `Error: ${String(err)}` }], isError: true };
488
- }
489
- });
490
- server.tool("get_context", "Get temporal context around a specific history entry: all prompts from the same " +
491
- "session, plus prompts from other sessions within a configurable time window. " +
492
- "Use this when you remember roughly when you worked on something but not the exact " +
493
- "search terms — useful for reconstructing the full picture of a working session. " +
494
- "Obtain an entry ID from search_history or recent_history.", {
495
- id: z
496
- .number()
497
- .int()
498
- .describe("History entry ID. Obtain from the id field in search_history or recent_history results."),
499
- window_minutes: z
500
- .number()
501
- .int()
502
- .min(0)
503
- .max(60)
504
- .optional()
505
- .default(5)
506
- .describe("Minutes before and after the entry to include from other sessions. Default: 5."),
507
- }, READ_ONLY, async ({ id, window_minutes }) => {
508
- try {
509
- const hist = await getHist();
510
- const entry = hist.getEntry(id);
511
- if (!entry) {
512
- return { content: [{ type: "text", text: `No entry with id ${id}.` }] };
513
- }
514
- const lines = [];
515
- if (entry.sessionId) {
516
- const sessionEntries = hist.getSession(entry.sessionId);
517
- lines.push(`=== Session ${entry.sessionId} (${sessionEntries.length} entries) ===`);
518
- for (const e of sessionEntries) {
519
- const dt = new Date(e.timestampMs).toISOString().slice(0, 16).replace("T", " ");
520
- const marker = e.id === id ? ">>>" : " ";
521
- lines.push(`${marker} [${dt}] #${e.id} (${e.source}) ${e.prompt.slice(0, 150).replace(/\n/g, " ")}`);
522
- }
523
- }
524
- const windowMs = window_minutes * 60 * 1000;
525
- const nearby = hist
526
- .getInTimeWindow(entry.timestampMs, windowMs)
527
- .filter((e) => e.sessionId !== entry.sessionId && e.id !== id);
528
- if (nearby.length > 0) {
529
- lines.push(`\n=== Nearby (±${window_minutes}min, other sessions) ===`);
530
- for (const e of nearby) {
531
- const dt = new Date(e.timestampMs).toISOString().slice(0, 16).replace("T", " ");
532
- lines.push(` [${dt}] #${e.id} (${e.source}) ${e.prompt.slice(0, 150).replace(/\n/g, " ")}`);
533
- }
534
- }
535
- return {
536
- content: [{ type: "text", text: lines.length > 0 ? lines.join("\n") : "No context found." }],
537
- };
538
- }
539
- catch (err) {
540
- return { content: [{ type: "text", text: `Error: ${String(err)}` }], isError: true };
541
- }
542
- });
543
- server.tool("recent_history", "List the most recent AI coding agent history entries across all sources, newest first. " +
544
- "Use this to see what was worked on recently, to get a history entry ID for use with " +
545
- "get_context, or to browse history without a specific keyword in mind. " +
546
- "Optionally filter by agent source or project path.", {
547
- n: z
548
- .number()
549
- .int()
550
- .min(1)
551
- .max(100)
552
- .optional()
553
- .default(20)
554
- .describe("Number of entries to return. Default: 20."),
555
- source: SOURCE_SCHEMA
556
- .optional()
557
- .describe("Filter to a specific agent source. Omit for all sources."),
558
- project: z
559
- .string()
560
- .optional()
561
- .describe('Filter by project directory path (substring match). Example: "/my-app" or "work/api".'),
562
- tag: z.string().optional().describe("Filter to sessions tagged with this tag."),
563
- }, READ_ONLY, async ({ n, source, project, tag }) => {
564
- try {
565
- const hist = await getHist();
566
- const entries = hist.recent({ limit: n, source, project, tag });
567
- if (entries.length === 0)
568
- return { content: [{ type: "text", text: "No history entries found." }] };
569
- const lines = [`Most recent ${entries.length} entries:\n`];
570
- for (const e of entries)
571
- lines.push(fmtEntry(e, 150));
572
- return { content: [{ type: "text", text: lines.join("\n\n") }] };
573
- }
574
- catch (err) {
575
- return { content: [{ type: "text", text: `Error: ${String(err)}` }], isError: true };
576
- }
577
- });
578
- server.tool("recent_entries", "List the most recent AI coding agent history entries across all sources, newest first. " +
579
- "This is the contract alias for recent_history.", {
580
- limit: z
581
- .number()
582
- .int()
583
- .min(1)
584
- .max(100)
585
- .optional()
586
- .default(20)
587
- .describe("Number of entries to return. Default: 20."),
588
- project: z
589
- .string()
590
- .optional()
591
- .describe('Filter by project directory/path or projectId (exact match in the SDK).'),
592
- tag: z.string().optional().describe("Filter to sessions tagged with this tag."),
593
- }, READ_ONLY, async ({ limit, project, tag }) => {
594
- try {
595
- const hist = await getHist();
596
- const entries = hist.recent({ limit, project, tag });
597
- if (entries.length === 0)
598
- return { content: [{ type: "text", text: "No history entries found." }] };
599
- const lines = [`Most recent ${entries.length} entries:\n`];
600
- for (const e of entries)
601
- lines.push(fmtEntry(e, 150));
602
- return { content: [{ type: "text", text: lines.join("\n\n") }] };
603
- }
604
- catch (err) {
605
- return { content: [{ type: "text", text: `Error: ${String(err)}` }], isError: true };
606
- }
607
- });
608
- server.tool("pack_evidence", "Search history and assemble a concise evidence bundle ready to paste into a new " +
609
- "LLM context window. Returns numbered entries with timestamps, source, project path, " +
610
- "truncated prompt text, and the exact resume command for each session. " +
611
- "Use this before starting work on a task to pull in relevant context from past sessions " +
612
- "without exceeding token limits. Adjust tokens_per_entry to control how much of each " +
613
- "prompt is included.", {
614
- query: z.string().describe("Search query to find relevant past sessions."),
615
- limit: z
616
- .number()
617
- .int()
618
- .min(1)
619
- .max(50)
620
- .optional()
621
- .default(10)
622
- .describe("Maximum number of entries in the bundle. Default: 10."),
623
- tokens_per_entry: z
624
- .number()
625
- .int()
626
- .min(50)
627
- .max(2000)
628
- .optional()
629
- .default(200)
630
- .describe("Approximate token budget per entry's prompt text (~4 chars/token). Default: 200."),
631
- }, READ_ONLY, async ({ query, limit, tokens_per_entry }) => {
632
- try {
633
- const hist = await getHist();
634
- const entries = hist.search(query, { limit });
635
- if (entries.length === 0)
636
- return { content: [{ type: "text", text: `No results for "${query}".` }] };
637
- const charsPerEntry = tokens_per_entry * 4;
638
- const now = new Date().toISOString().slice(0, 16).replace("T", " ");
639
- const lines = [`=== ai-hist pack: "${query}" | ${now} | ${entries.length} entries ===\n`];
640
- for (let i = 0; i < entries.length; i++) {
641
- const e = entries[i];
642
- const dt = new Date(e.timestampMs).toISOString().slice(0, 16).replace("T", " ");
643
- const proj = e.project ? ` ${e.project}` : "";
644
- const text = e.prompt.length > charsPerEntry
645
- ? e.prompt.slice(0, charsPerEntry).replace(/\n/g, " ") + "..."
646
- : e.prompt.replace(/\n/g, " ");
647
- const resume = resumeCommand(e);
648
- let entry = `[${i + 1}/${entries.length}] #${e.id} ${dt} ${e.source}${proj}\n ${text}`;
649
- if (resume)
650
- entry += `\n Resume: ${resume}`;
651
- lines.push(entry);
652
- }
653
- return { content: [{ type: "text", text: lines.join("\n\n") }] };
654
- }
655
- catch (err) {
656
- return { content: [{ type: "text", text: `Error: ${String(err)}` }], isError: true };
657
- }
658
- });
659
- server.tool("history_stats", "Return a summary of your AI coding agent history: total entry count broken down by " +
660
- "source, the date range of recorded sessions, and the ten most-active projects. " +
661
- "Use this to confirm that ai-hist sync has run successfully, to see which agents have " +
662
- "the most history, or to get an overview before deciding where to search.", READ_ONLY, async () => {
663
- try {
664
- const hist = await getHist();
665
- const s = hist.stats();
666
- const lines = [`Total entries: ${s.total.toLocaleString()}\n`];
667
- lines.push("By source:");
668
- for (const [src, count] of Object.entries(s.bySource)) {
669
- lines.push(` ${src}: ${(count ?? 0).toLocaleString()}`);
670
- }
671
- if (s.firstTimestampMs && s.lastTimestampMs) {
672
- const first = new Date(s.firstTimestampMs).toISOString().slice(0, 10);
673
- const last = new Date(s.lastTimestampMs).toISOString().slice(0, 10);
674
- lines.push(`\nDate range: ${first} → ${last}`);
675
- }
676
- if (s.byProject.length > 0) {
677
- lines.push("\nTop projects:");
678
- for (const p of s.byProject) {
679
- lines.push(` ${String(p.count).padStart(6)} ${p.project}`);
680
- }
681
- }
682
- lines.push(`\nData source: ${hist.sourceKind} (${hist.dbPath})`);
683
- if (hist.projectScope)
684
- lines.push(`Project scope: ${hist.projectScope}`);
685
- return { content: [{ type: "text", text: lines.join("\n") }] };
686
- }
687
- catch (err) {
688
- return { content: [{ type: "text", text: `Error: ${String(err)}` }], isError: true };
689
- }
690
- });
691
- server.tool("stats", "Contract alias for history_stats. Return total counts by source, date range, top projects, and data source.", READ_ONLY, async () => {
692
- try {
693
- const hist = await getHist();
694
- const s = hist.stats();
695
- return {
696
- content: [
697
- {
698
- type: "text",
699
- text: JSON.stringify({
700
- total: s.total,
701
- bySource: s.bySource,
702
- byProject: s.byProject,
703
- firstTimestampMs: s.firstTimestampMs,
704
- lastTimestampMs: s.lastTimestampMs,
705
- sourceKind: hist.sourceKind,
706
- dbPath: hist.dbPath,
707
- projectScope: hist.projectScope,
708
- }, null, 2),
709
- },
710
- ],
711
- };
712
- }
713
- catch (err) {
714
- return { content: [{ type: "text", text: `Error: ${String(err)}` }], isError: true };
715
- }
716
- });
717
- server.tool("get_handoff", "Find active sessions for a repo/branch and generate a warm-start handoff brief " +
718
- "so you can continue work in a different CLI (e.g. pick up a Claude session in Codex " +
719
- "or vice versa). Returns ranked candidate sessions with: the original goal, files " +
720
- "touched (extracted from prompts), last user prompt, last assistant state, the resume " +
721
- "command for the original CLI, and a ready-to-paste warm-start command for the target CLI. " +
722
- "Use this when switching between Claude Code and Codex mid-task due to quota or outage.", {
723
- repo: z
724
- .string()
725
- .optional()
726
- .describe("Project directory path to filter by (substring match against session cwd). " +
727
- 'Example: "/my-app", "work/api". Omit to search across all projects.'),
728
- branch: z
729
- .string()
730
- .optional()
731
- .describe("Git branch name to filter by (substring match). " +
732
- 'Example: "feat/auth", "main". Requires ai-hist sync to have captured gitBranch.'),
733
- source: z
734
- .enum(["claude", "codex"])
735
- .optional()
736
- .describe("Filter to sessions from a specific CLI. Omit to search both Claude Code and Codex."),
737
- limit: z
738
- .number()
739
- .int()
740
- .min(1)
741
- .max(10)
742
- .optional()
743
- .default(3)
744
- .describe("Maximum number of handoff candidates to return. Default: 3."),
745
- }, READ_ONLY, async ({ repo, branch, source, limit }) => {
746
- try {
747
- const hist = await getHist();
748
- const candidates = hist.getHandoff({ repo, branch, source, limit });
749
- if (candidates.length === 0) {
750
- return {
751
- content: [
752
- {
753
- type: "text",
754
- text: "No sessions found matching the given filters. " +
755
- "Run `ai-hist sync` to populate session metadata, then try again.",
756
- },
757
- ],
758
- };
759
- }
760
- const lines = [`Found ${candidates.length} handoff candidate(s):\n`];
761
- for (let i = 0; i < candidates.length; i++) {
762
- const c = candidates[i];
763
- let dt = "unknown";
764
- if (c.lastActivityMs != null && Number.isFinite(c.lastActivityMs)) {
765
- dt = new Date(c.lastActivityMs).toISOString().slice(0, 16).replace("T", " ");
766
- }
767
- lines.push(`[${i + 1}] ${c.source.toUpperCase()} session confidence: ${(c.confidence * 100).toFixed(0)}%`);
768
- lines.push(` session_id : ${c.sessionId}`);
769
- lines.push(` cwd : ${c.cwd ?? "(unknown)"}`);
770
- lines.push(` branch : ${c.gitBranch ?? "(unknown)"}`);
771
- lines.push(` last seen : ${dt} (${c.promptCount} prompts)`);
772
- lines.push(` goal : ${c.goal.slice(0, 150)}`);
773
- lines.push(` last state : ${c.lastState.slice(0, 150)}`);
774
- if (c.filesTouched.length > 0) {
775
- lines.push(` files : ${c.filesTouched.slice(0, 8).join(", ")}`);
776
- }
777
- if (c.lastAssistantText) {
778
- lines.push(` assistant : ${c.lastAssistantText.slice(0, 200).replace(/\n/g, " ")}`);
779
- }
780
- if (c.resumeCommand) {
781
- lines.push(` resume : ${c.resumeCommand}`);
782
- }
783
- lines.push(` warm-start : ${c.warmStartCommand}`);
784
- }
785
- return { content: [{ type: "text", text: lines.join("\n") }] };
786
- }
787
- catch (err) {
788
- return { content: [{ type: "text", text: `Error: ${String(err)}` }], isError: true };
789
- }
790
- });
791
- // ---------------------------------------------------------------------------
792
- // Start
793
- // ---------------------------------------------------------------------------
794
- const transport = new StdioServerTransport();
795
- await server.connect(transport);
25
+ }
26
+ server.tool('search_history', 'Search already-indexed RelayHistory prompts.', {
27
+ query: z.string(), source: SOURCE.optional(), project: z.string().optional(), tag: z.string().optional(),
28
+ limit: z.number().int().min(1).max(1000).optional().default(20),
29
+ }, READ, ({ query, source, project, tag, limit }) => call(() => search(query, { source, project, tag, limit })));
30
+ server.tool('recent_history', 'List recent already-indexed history.', {
31
+ source: SOURCE.optional(), project: z.string().optional(), tag: z.string().optional(),
32
+ n: z.number().int().min(1).max(1000).optional().default(20),
33
+ before_ms: z.number().int().optional(),
34
+ }, READ, ({ source, project, tag, n, before_ms }) => call(() => recent({ source, project, tag, limit: n, beforeMs: before_ms })));
35
+ server.tool('list_sessions', 'Cache-only indexed session catalog listing. This never discovers or syncs.', {
36
+ sources: z.array(CATALOG_SOURCE).optional(), limit: z.number().int().min(1).max(1000).optional().default(20),
37
+ before_ms: z.number().int().optional(),
38
+ after: z.object({ lastActivityMs: z.number().int().nullable().optional(), source: z.string(), sessionId: z.string() }).optional(),
39
+ }, READ, ({ sources, limit, before_ms, after }) => call(() => listSessionCatalogPage({
40
+ sources, limit, beforeMs: before_ms,
41
+ after: after ? { ...after, lastActivityMs: after.lastActivityMs ?? null } : undefined,
42
+ })));
43
+ server.tool('discover_sessions', 'Explicit shallow provider discovery. Updates only the session catalog.', {
44
+ sources: z.array(CATALOG_SOURCE).optional(), limit: z.number().int().min(1).max(10000).optional(),
45
+ }, WRITE, (args) => call(() => discoverSessions(args)));
46
+ server.tool('get_session', 'Get indexed prompts for one session.', {
47
+ session_id: z.string(), source: SOURCE.optional(), tag: z.string().optional(),
48
+ }, READ, ({ session_id, source, tag }) => call(() => getSession(session_id, { source, tag })));
49
+ server.tool('get_session_events', 'Get one bounded page of normalized events.', {
50
+ session_id: z.string(), source: SOURCE.optional(), limit: z.number().int().min(1).max(1000).optional().default(200),
51
+ after: z.object({ tsMs: z.number().int(), id: z.number().int() }).optional(),
52
+ }, READ, ({ session_id, source, limit, after }) => call(() => getSessionEventsPage(session_id, { source, limit, after })));
53
+ server.tool('history_stats', 'Statistics for already-indexed RelayHistory data.', {
54
+ tag: z.string().optional(),
55
+ }, READ, ({ tag }) => call(() => stats({ tag })));
56
+ server.tool('sync', 'Explicit full provider ingestion into RelayHistory.', {}, WRITE, () => call(() => sync()));
57
+ await server.connect(new StdioServerTransport());
796
58
  //# sourceMappingURL=mcp-server.js.map