context-doctor 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -41,6 +41,10 @@ import { startDashboard } from "./dashboard.js";
41
41
  import { listCursorChats, parseCursorChat } from "./cursor.js";
42
42
  import { analyzeCacheUsage, renderCacheReport } from "./cache.js";
43
43
  import { renderToolTimings } from "./timing.js";
44
+ import { packContextSemantic, readSources, renderPack } from "./pack.js";
45
+ import { measureBaseline, measureMcpSizes, overheadReport, renderOverhead } from "./overhead.js";
46
+ import { applySplit, planSplit, renderSplit } from "./split.js";
47
+ import { ciReport, renderCiMarkdown, renderCiText } from "./ci.js";
44
48
  const HELP = `context-doctor — profile and optimize LLM context windows
45
49
 
46
50
  Usage:
@@ -92,6 +96,23 @@ Usage:
92
96
  were resolved, and what it saves
93
97
  context-doctor accuracy Measure the token heuristic against the API's own
94
98
  counts recorded in your transcripts (--limit n)
99
+ context-doctor overhead [--days n] [--mcp] What every request re-reads before your message:
100
+ measured first-request size, each CLAUDE.md /
101
+ AGENTS.md / rules file priced per month, findings
102
+ context-doctor ci [--base <ref>] [--max-tokens n] [--max-increase n] [--markdown]
103
+ For pull requests: memory files (CLAUDE.md, AGENTS.md,
104
+ GEMINI.md, rules) at HEAD vs base; exit 1 over budget
105
+ context-doctor overhead split <file> [--write]
106
+ Move a memory file's big sections to a reference
107
+ file, leaving pointers (plan first; --write backs up)
108
+ context-doctor pack <files|dirs...> --query "<q>" [--max-tokens n]
109
+ Only the parts of big docs/code a question needs:
110
+ chunk along headings and declarations, rank, fit a
111
+ token budget (default 4000). No --query: an outline
112
+ to pick from (--ids a#2,b#5). Offline, no API key.
113
+ --semantic: also rank by meaning with a local
114
+ Ollama embedding model, when one is installed
115
+ (--embed-model <name> picks it)
95
116
  context-doctor watch [file] Live-monitor a growing session/agent trace: running
96
117
  token/cost line per change, new findings as they appear
97
118
  (--interval-ms n, default 2000)
@@ -247,12 +268,55 @@ function parseArgs(argv) {
247
268
  case "--config":
248
269
  args.config = argv[++i];
249
270
  break;
271
+ case "-q":
272
+ case "--query":
273
+ args.query = argv[++i];
274
+ break;
275
+ case "--max-tokens":
276
+ args.maxTokens = numArg("--max-tokens", argv[++i], { min: 1, integer: true });
277
+ break;
278
+ case "--chunk-tokens":
279
+ args.chunkTokens = numArg("--chunk-tokens", argv[++i], { min: 50, integer: true });
280
+ break;
281
+ case "--mcp":
282
+ args.mcp = true;
283
+ break;
284
+ case "--write":
285
+ args.write = true;
286
+ break;
287
+ case "--semantic":
288
+ args.semantic = args.semantic || true;
289
+ break;
290
+ case "--embed-model":
291
+ args.semantic = argv[++i];
292
+ break;
293
+ case "--base":
294
+ args.base = argv[++i];
295
+ break;
296
+ case "--markdown":
297
+ args.markdown = true;
298
+ break;
299
+ case "--max-increase":
300
+ args.maxIncrease = numArg("--max-increase", argv[++i], { min: 0, integer: true });
301
+ break;
302
+ case "--requests-per-day":
303
+ args.requestsPerDay = numArg("--requests-per-day", argv[++i], { min: 1 });
304
+ break;
305
+ case "--min-tokens":
306
+ args.minTokens = numArg("--min-tokens", argv[++i], { min: 50, integer: true });
307
+ break;
308
+ case "--ids":
309
+ args.ids = (argv[++i] ?? "").split(",").map((x) => x.trim()).filter(Boolean);
310
+ break;
250
311
  case "--upstream-anthropic":
251
312
  args.upstreamAnthropic = argv[++i];
252
313
  break;
253
314
  case "--upstream-openai":
254
315
  args.upstreamOpenai = argv[++i];
255
316
  break;
317
+ case "--upstream-google":
318
+ args.upstreamGoogle = argv[++i];
319
+ break;
256
320
  default: positional.push(a);
257
321
  }
258
322
  }
@@ -639,6 +703,7 @@ async function main() {
639
703
  autopilotPauseFile: args.autopilotPauseFile,
640
704
  anthropicUpstream: args.upstreamAnthropic,
641
705
  openaiUpstream: args.upstreamOpenai,
706
+ googleUpstream: args.upstreamGoogle,
642
707
  strategies: args.strategies.length > 0 ? args.strategies : loadedRc.config.strategies,
643
708
  keepRecent: args.keepRecent ?? loadedRc.config.keepRecent,
644
709
  maxToolResultTokens: args.maxToolTokens ?? loadedRc.config.maxToolResultTokens,
@@ -646,6 +711,58 @@ async function main() {
646
711
  });
647
712
  return; // server keeps the process alive
648
713
  }
714
+ if (args.command === "ci") {
715
+ const report = ciReport({ base: args.base, maxTokens: args.maxTokens, maxIncrease: args.maxIncrease });
716
+ console.log(args.json ? JSON.stringify(report, null, 2) : args.markdown ? renderCiMarkdown(report, { model: args.model, requestsPerDay: args.requestsPerDay }) : renderCiText(report));
717
+ if (report.breaches.length)
718
+ process.exitCode = 1;
719
+ return;
720
+ }
721
+ if (args.command === "overhead" && args.positionals?.[0] === "split") {
722
+ const file = args.positionals[1];
723
+ if (!file) {
724
+ console.error("usage: context-doctor overhead split <memory file> [--min-tokens 400] [--write]");
725
+ process.exit(1);
726
+ }
727
+ let plan;
728
+ try {
729
+ plan = planSplit(file, { minTokens: args.minTokens });
730
+ }
731
+ catch (e) {
732
+ console.error(`Could not read ${file}: ${e.message}`);
733
+ process.exit(1);
734
+ }
735
+ console.log(renderSplit(plan, measureBaseline(args.days ?? 30)?.usdPerKPerMonth));
736
+ if (args.write && plan.sections.some((s) => s.moved)) {
737
+ const { backup } = applySplit(plan);
738
+ console.log(`\nWritten. Backup: ${backup}\nUndo: mv "${backup}" "${file}" (and delete or trim ${plan.referencePath}).`);
739
+ }
740
+ return;
741
+ }
742
+ if (args.command === "overhead") {
743
+ const report = overheadReport({ days: args.days });
744
+ if (args.mcp)
745
+ await measureMcpSizes(report);
746
+ console.log(args.json ? JSON.stringify(report, (_k, v) => (v instanceof Map ? Object.fromEntries(v) : v), 2) : renderOverhead(report));
747
+ return;
748
+ }
749
+ if (args.command === "pack") {
750
+ const paths = args.positionals ?? [];
751
+ if (paths.length === 0) {
752
+ console.error('usage: context-doctor pack <files|dirs...> --query "<question>" [--max-tokens 4000] [--ids a#2,b#5] [--json]');
753
+ process.exit(1);
754
+ }
755
+ const { sources, skipped } = paths.includes("-")
756
+ ? { sources: [{ name: "stdin", text: readFileSync(0, "utf8") }], skipped: [] }
757
+ : readSources(paths);
758
+ for (const s of skipped)
759
+ console.error(`context-doctor: skipped ${s}`);
760
+ if (sources.length === 0)
761
+ process.exit(1);
762
+ const result = await packContextSemantic(sources, { query: args.query, budget: args.maxTokens, ids: args.ids, maxChunkTokens: args.chunkTokens, model: args.model, semantic: args.semantic });
763
+ console.log(args.json ? JSON.stringify({ ...result, chunks: undefined, outline: result.chunks.map(({ text, ...c }) => c) }, null, 2) : renderPack(result));
764
+ return;
765
+ }
649
766
  if (!args.command || !args.file) {
650
767
  const known = ["analyze", "optimize"];
651
768
  if (args.command && !known.includes(args.command)) {
package/dist/doctor.js CHANGED
@@ -13,6 +13,7 @@ import { dirname, join } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { ledgerPath, recordLedger } from "./ledger.js";
15
15
  import { loadConfig } from "./config.js";
16
+ import { geminiCliPresent, geminiSettingsPath } from "./install.js";
16
17
  function claudeDesktopConfigPath() {
17
18
  switch (platform()) {
18
19
  case "darwin": return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
@@ -165,6 +166,32 @@ export async function runDoctor() {
165
166
  else {
166
167
  checks.push({ label: "Codex", status: "skip", detail: "not detected (ChatGPT's Codex agent, IDE extension or CLI)" });
167
168
  }
169
+ // Gemini CLI: MCP server and the BeforeAgent hook, both in ~/.gemini/settings.json.
170
+ if (geminiCliPresent()) {
171
+ try {
172
+ const path = geminiSettingsPath();
173
+ const settings = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
174
+ checks.push(settings.mcpServers?.["context-doctor"]
175
+ ? { label: "Gemini CLI MCP", status: "ok", detail: "wired in ~/.gemini/settings.json" }
176
+ : { label: "Gemini CLI MCP", status: "fail", detail: "Gemini CLI detected but not wired — run: context-doctor install" });
177
+ const entries = settings.hooks?.BeforeAgent ?? [];
178
+ const ours = entries.map((e) => e.hooks?.[0]?.command ?? "").find((c) => /context-doctor|cli\.js"?\s+hook/.test(c));
179
+ const missing = ours ? hookBinaryMissing(ours) : undefined;
180
+ checks.push(!ours
181
+ ? { label: "Gemini CLI hook", status: "fail", detail: "not registered — run: context-doctor install" }
182
+ : missing
183
+ ? { label: "Gemini CLI hook", status: "fail", detail: `registered, but ${missing} no longer exists — re-run: context-doctor install` }
184
+ : settings.hooksConfig?.enabled === false
185
+ ? { label: "Gemini CLI hook", status: "fail", detail: "registered, but hooksConfig.enabled is false in ~/.gemini/settings.json" }
186
+ : { label: "Gemini CLI hook", status: "ok", detail: "BeforeAgent hook in ~/.gemini/settings.json" });
187
+ }
188
+ catch (e) {
189
+ checks.push({ label: "Gemini CLI", status: "fail", detail: `~/.gemini/settings.json unreadable (${e.message})` });
190
+ }
191
+ }
192
+ else {
193
+ checks.push({ label: "Gemini CLI", status: "skip", detail: "not detected" });
194
+ }
168
195
  // Cursor: its agent gets the guidance through a native postToolUse hook
169
196
  // (the Claude Code hook it also runs cannot add context there).
170
197
  if (existsSync(join(homedir(), ".cursor"))) {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Optional semantic ranking for `pack`, through a local Ollama.
3
+ *
4
+ * BM25 misses pure paraphrase ("how long before the cache goes cold" against
5
+ * `COLD_IDLE_MS = 65 * 60_000`). When Ollama runs on this machine with an
6
+ * embedding model, `pack --semantic` embeds the question and the chunks and
7
+ * merges the two rankings by reciprocal rank fusion, which needs no
8
+ * calibration between BM25 scores and cosine similarities. Never required:
9
+ * without Ollama or an embedding model, pack stays lexical and says why.
10
+ *
11
+ * Local only (OLLAMA_HOST, default 127.0.0.1:11434), no key. Chunk vectors are
12
+ * cached on disk by model and text hash, so packing the same folder again
13
+ * embeds only what changed.
14
+ */
15
+ import type { Chunk } from "./pack.js";
16
+ export interface SemanticResult {
17
+ used: boolean;
18
+ model?: string;
19
+ /** Why semantic ranking was not used, or what it did. */
20
+ note: string;
21
+ }
22
+ /** The embedding model to use: the one asked for, else the first installed model that looks like one. */
23
+ export declare function findEmbeddingModel(preferred?: string): Promise<string | undefined>;
24
+ /**
25
+ * Re-score `chunks` in place: score = RRF(BM25 rank) + RRF(embedding rank),
26
+ * k = 60. A chunk BM25 never matched can still rank by meaning alone.
27
+ */
28
+ export declare function semanticRerank(chunks: Chunk[], query: string, opts?: {
29
+ model?: string;
30
+ }): Promise<SemanticResult>;
package/dist/embed.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Optional semantic ranking for `pack`, through a local Ollama.
3
+ *
4
+ * BM25 misses pure paraphrase ("how long before the cache goes cold" against
5
+ * `COLD_IDLE_MS = 65 * 60_000`). When Ollama runs on this machine with an
6
+ * embedding model, `pack --semantic` embeds the question and the chunks and
7
+ * merges the two rankings by reciprocal rank fusion, which needs no
8
+ * calibration between BM25 scores and cosine similarities. Never required:
9
+ * without Ollama or an embedding model, pack stays lexical and says why.
10
+ *
11
+ * Local only (OLLAMA_HOST, default 127.0.0.1:11434), no key. Chunk vectors are
12
+ * cached on disk by model and text hash, so packing the same folder again
13
+ * embeds only what changed.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ const EMBED_NAME = /embed|bge|e5-|gte|minilm|nomic|mxbai|arctic|snowflake/i;
20
+ const BATCH = 64;
21
+ const CACHE_CAP = 50_000;
22
+ function host() {
23
+ const h = process.env.OLLAMA_HOST ?? "http://127.0.0.1:11434";
24
+ return h.startsWith("http") ? h.replace(/\/$/, "") : `http://${h}`;
25
+ }
26
+ async function getJson(path, body, timeoutMs = 60_000) {
27
+ const r = await fetch(host() + path, {
28
+ method: body ? "POST" : "GET",
29
+ headers: body ? { "content-type": "application/json" } : undefined,
30
+ body: body ? JSON.stringify(body) : undefined,
31
+ signal: AbortSignal.timeout(timeoutMs),
32
+ });
33
+ if (!r.ok)
34
+ throw new Error(`${path}: HTTP ${r.status}`);
35
+ return r.json();
36
+ }
37
+ /** The embedding model to use: the one asked for, else the first installed model that looks like one. */
38
+ export async function findEmbeddingModel(preferred) {
39
+ const tags = await getJson("/api/tags", undefined, 2_000);
40
+ const names = (tags.models ?? []).map((m) => String(m.name));
41
+ if (preferred)
42
+ return names.find((n) => n === preferred || n.startsWith(preferred + ":")) ?? preferred;
43
+ return names.find((n) => EMBED_NAME.test(n));
44
+ }
45
+ function cachePath() {
46
+ return join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "context-doctor", "embeddings.json");
47
+ }
48
+ function loadCache() {
49
+ try {
50
+ return existsSync(cachePath()) ? JSON.parse(readFileSync(cachePath(), "utf8")) : {};
51
+ }
52
+ catch {
53
+ return {};
54
+ }
55
+ }
56
+ function saveCache(cache) {
57
+ try {
58
+ const keys = Object.keys(cache);
59
+ // Oldest entries go first once the cache passes its cap (insertion order).
60
+ if (keys.length > CACHE_CAP)
61
+ for (const k of keys.slice(0, keys.length - CACHE_CAP))
62
+ delete cache[k];
63
+ mkdirSync(join(cachePath(), ".."), { recursive: true });
64
+ writeFileSync(cachePath(), JSON.stringify(cache));
65
+ }
66
+ catch {
67
+ /* a cache is an optimization */
68
+ }
69
+ }
70
+ async function embed(model, texts) {
71
+ const out = [];
72
+ for (let i = 0; i < texts.length; i += BATCH) {
73
+ const r = await getJson("/api/embed", { model, input: texts.slice(i, i + BATCH), truncate: true });
74
+ out.push(...r.embeddings);
75
+ }
76
+ return out;
77
+ }
78
+ function cosine(a, b) {
79
+ let dot = 0, na = 0, nb = 0;
80
+ for (let i = 0; i < a.length; i++) {
81
+ dot += a[i] * b[i];
82
+ na += a[i] * a[i];
83
+ nb += b[i] * b[i];
84
+ }
85
+ return na && nb ? dot / Math.sqrt(na * nb) : 0;
86
+ }
87
+ /**
88
+ * Re-score `chunks` in place: score = RRF(BM25 rank) + RRF(embedding rank),
89
+ * k = 60. A chunk BM25 never matched can still rank by meaning alone.
90
+ */
91
+ export async function semanticRerank(chunks, query, opts = {}) {
92
+ let model;
93
+ try {
94
+ model = await findEmbeddingModel(opts.model);
95
+ }
96
+ catch {
97
+ return { used: false, note: `semantic ranking off: no Ollama at ${host()} (keyword ranking only)` };
98
+ }
99
+ if (!model)
100
+ return { used: false, note: "semantic ranking off: Ollama has no embedding model; run `ollama pull nomic-embed-text` (keyword ranking only)" };
101
+ const cache = loadCache();
102
+ const key = (t) => createHash("sha1").update(model + "\u0000" + t).digest("hex");
103
+ const texts = chunks.map((c) => (c.heading ? c.heading + "\n" : "") + c.text);
104
+ const missing = [...new Set(texts.filter((t) => !cache[key(t)]))];
105
+ let vectors;
106
+ let queryVec;
107
+ try {
108
+ const fresh = await embed(model, [query, ...missing]);
109
+ queryVec = fresh[0];
110
+ missing.forEach((t, i) => (cache[key(t)] = fresh[i + 1]));
111
+ vectors = texts.map((t) => cache[key(t)]);
112
+ }
113
+ catch (e) {
114
+ return { used: false, model, note: `semantic ranking off: ${model} failed (${e.message}); keyword ranking only` };
115
+ }
116
+ if (missing.length)
117
+ saveCache(cache);
118
+ const K = 60;
119
+ const lexRank = new Map([...chunks].filter((c) => c.score > 0).sort((a, b) => b.score - a.score).map((c, i) => [c, i]));
120
+ const sims = chunks.map((c, i) => ({ c, s: cosine(queryVec, vectors[i]) })).sort((a, b) => b.s - a.s);
121
+ const semRank = new Map(sims.map((x, i) => [x.c, i]));
122
+ for (const c of chunks) {
123
+ const l = lexRank.get(c);
124
+ c.score = (l === undefined ? 0 : 1 / (K + l)) + 1 / (K + semRank.get(c));
125
+ }
126
+ return { used: true, model, note: `semantic ranking: ${model}, ${missing.length} of ${texts.length} chunks embedded (rest cached)` };
127
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Text out of PDFs and office documents, with tools the OS already has.
3
+ *
4
+ * Most big documents people paste into a chat are PDFs and Word files. pack
5
+ * stays dependency-free, so this shells out to what is usually installed:
6
+ * `pdftotext` (poppler) anywhere, PDFKit through `osascript` on macOS,
7
+ * `textutil` for Word/RTF/ODT on macOS, and `unzip` for the zipped XML
8
+ * formats (docx, pptx, odt) elsewhere. When none is available the caller
9
+ * gets a reason that names what to install, never a silent skip.
10
+ */
11
+ export declare const EXTRACTABLE: Set<string>;
12
+ /** The document's text, or the reason it could not be read. */
13
+ export declare function extractText(path: string): {
14
+ text: string;
15
+ } | {
16
+ error: string;
17
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Text out of PDFs and office documents, with tools the OS already has.
3
+ *
4
+ * Most big documents people paste into a chat are PDFs and Word files. pack
5
+ * stays dependency-free, so this shells out to what is usually installed:
6
+ * `pdftotext` (poppler) anywhere, PDFKit through `osascript` on macOS,
7
+ * `textutil` for Word/RTF/ODT on macOS, and `unzip` for the zipped XML
8
+ * formats (docx, pptx, odt) elsewhere. When none is available the caller
9
+ * gets a reason that names what to install, never a silent skip.
10
+ */
11
+ import { spawnSync } from "node:child_process";
12
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { dirname, extname, join } from "node:path";
15
+ export const EXTRACTABLE = new Set([".pdf", ".docx", ".doc", ".rtf", ".odt", ".pptx"]);
16
+ const RUN = { encoding: "utf8", timeout: 30_000, maxBuffer: 64 * 1024 * 1024 };
17
+ function run(cmd, args) {
18
+ try {
19
+ const r = spawnSync(cmd, args, RUN);
20
+ return r.status === 0 && typeof r.stdout === "string" && r.stdout.trim() ? r.stdout : undefined;
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ const PDFKIT_JXA = 'ObjC.import("PDFKit"); function run(a) { const d = $.PDFDocument.alloc.initWithURL($.NSURL.fileURLWithPath(a[0])); return d.isNil() ? "" : d.string.js }';
27
+ /**
28
+ * pdftotext comes in two families (poppler, and Xpdf as shipped with some
29
+ * Windows toolchains) that disagree on `-enc` names and on writing to stdout,
30
+ * so try the common form, then without the encoding flag, then via a file.
31
+ */
32
+ function pdftotext(path) {
33
+ const direct = run("pdftotext", ["-enc", "UTF-8", path, "-"]) ?? run("pdftotext", [path, "-"]);
34
+ if (direct)
35
+ return direct;
36
+ const out = join(mkdtempSync(join(tmpdir(), "cd-pdf-")), "out.txt");
37
+ try {
38
+ spawnSync("pdftotext", [path, out], RUN);
39
+ const text = existsSync(out) ? readFileSync(out, "utf8") : "";
40
+ return text.trim() ? text : undefined;
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ finally {
46
+ rmSync(dirname(out), { recursive: true, force: true });
47
+ }
48
+ }
49
+ /** Paragraph-ish text from zipped XML: one paragraph per closing p/slide-text tag, entities decoded. */
50
+ function fromZippedXml(path, members) {
51
+ const xml = run("unzip", ["-p", path, ...members]);
52
+ if (!xml)
53
+ return undefined;
54
+ return xml
55
+ .replace(/<\/(w:p|a:p|text:p|text:h)>/g, "\n\n")
56
+ .replace(/<(w:tab|w:br)\/>/g, " ")
57
+ .replace(/<[^>]+>/g, "")
58
+ .replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&")
59
+ .replace(/\n{3,}/g, "\n\n")
60
+ .trim();
61
+ }
62
+ /** The document's text, or the reason it could not be read. */
63
+ export function extractText(path) {
64
+ const ext = extname(path).toLowerCase();
65
+ const mac = process.platform === "darwin";
66
+ let text;
67
+ if (ext === ".pdf") {
68
+ // Form feeds separate pages; as blank lines they become paragraph boundaries.
69
+ text = pdftotext(path)?.replace(/\f/g, "\n\n");
70
+ if (!text && mac)
71
+ text = run("osascript", ["-l", "JavaScript", "-e", PDFKIT_JXA, path]);
72
+ if (!text) {
73
+ return { error: mac ? "no text layer (scanned PDF?) or unreadable" : "needs pdftotext (install poppler-utils), or the PDF has no text layer" };
74
+ }
75
+ }
76
+ else {
77
+ if (mac && ext !== ".pptx")
78
+ text = run("textutil", ["-convert", "txt", "-stdout", path]);
79
+ if (!text) {
80
+ const members = ext === ".docx" ? ["word/document.xml"] : ext === ".pptx" ? ["ppt/slides/*.xml"] : ext === ".odt" ? ["content.xml"] : [];
81
+ if (members.length)
82
+ text = fromZippedXml(path, members);
83
+ }
84
+ if (!text)
85
+ return { error: mac ? "unreadable document" : `needs unzip${ext === ".doc" || ext === ".rtf" ? " (and .doc/.rtf need macOS textutil; save as .docx)" : ""}` };
86
+ }
87
+ return { text };
88
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Gemini CLI chat recordings, read into the same shape as Claude Code and
3
+ * Codex transcripts so `session`, `watch` and the every-prompt hook work on
4
+ * them.
5
+ *
6
+ * Gemini CLI writes ~/.gemini/tmp/<project>/chats/session-*.jsonl: a metadata
7
+ * record, then one record per message (`user`, `gemini`, `info`, `error`).
8
+ * A message written again with the same id replaces the earlier copy (tool
9
+ * results and token counts arrive after the text), `{"$rewindTo": id}` drops
10
+ * that message and everything after it, and `{"$set": …}` updates metadata.
11
+ * Older versions wrote the whole conversation as one JSON document with a
12
+ * `messages` array; both are read.
13
+ *
14
+ * Each `gemini` message records the API's own usage (`tokens.input` is the
15
+ * request's promptTokenCount, cached part included), so the live context size
16
+ * is exact, as it is for Claude Code and Codex.
17
+ */
18
+ import { type ParsedSession, type SessionInfo } from "./session.js";
19
+ /** True when the file's first record looks like a Gemini CLI chat (cheap: reads 4 KB). */
20
+ export declare function isGeminiChat(path: string): boolean;
21
+ export declare function parseGeminiChat(path: string): ParsedSession;
22
+ /** Gemini CLI chats on this machine: ~/.gemini/tmp/<project>/chats/session-*.json[l]. */
23
+ export declare function listGeminiChats(home?: string): SessionInfo[];