@intentic/claude-plugin 1.322.0 → 1.324.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "intentic",
3
3
  "displayName": "Intentic",
4
- "version": "1.322.0",
4
+ "version": "1.324.0",
5
5
  "description": "Cheaper, sharper Claude Code sessions: Bash output trimmed before Claude reads it, a project map and this project's field notes at session start, iq code search and fileq document reading taught, and /intentic:stats to show what each one saved. Every mechanism is a switch in /config.",
6
6
  "author": {
7
7
  "name": "intentic"
package/README.md CHANGED
@@ -21,6 +21,8 @@ The hooks and the bundled commands run on Node.js 20.11 or later, which has to b
21
21
  | Claude reads a document | `fileq` turns docx, pdf, xlsx, pptx, epub, ipynb, images, audio and archives into markdown. | `fileq` |
22
22
  | Claude makes a document | `fileq check` lists what is wrong with a docx, pptx, xlsx or pdf by slide, page or cell, and `fileq render` draws its pages as PNGs for Claude to look at (with LibreOffice and poppler installed). The fileq skill says to run both before handing the file over. | `fileq` |
23
23
 
24
+ | A Bash command succeeds, in Claude Code 2.1.287 or later | A line under the prompt says what the cleaners saved so far this session, and `/intentic-pane` shows the full report in a pane. See [In the interface](#in-the-interface). | `output_cleaners` |
25
+
24
26
  A failed command reaches Claude unchanged. Outside an Intentic sandbox there is no secret store to mask values from, so the cleaners mask only text that looks like a credential: an assignment to a name like `API_KEY` or `TOKEN` whose value looks generated, bearer tokens, AWS access keys, and passwords in URLs.
25
27
 
26
28
  The `intentic:Intentic` output style adds three working habits to Claude Code's own instructions: batch independent lookups, reuse what is already in context, and run long commands in the background instead of sleeping. Pick it in `/output-style`. It is off until you do.
@@ -31,7 +33,7 @@ Open `/config` and find the rows under intentic. The switches read at session st
31
33
 
32
34
  | Key | Default | Effect |
33
35
  | --- | --- | --- |
34
- | `output_cleaners` | on | Clean successful Bash output and ledger what each cleaner saved |
36
+ | `output_cleaners` | on | Clean successful Bash output, ledger what each cleaner saved, and show the saving under the prompt. Off, none of the three happens |
35
37
  | `cleaners` | empty (all) | Which cleaners run: `-cap,-wide` switches some off, `git,pnpm` allows only those |
36
38
  | `output_holdout` | 0 | Share of commands left uncleaned, to measure the saving against real output |
37
39
  | `project_map` | on | Send the project map at session start |
@@ -62,6 +64,21 @@ The numbers above are an illustration of the format. The report reads two things
62
64
 
63
65
  The arithmetic is the one behind the savings page of an Intentic sandbox, from the same packages, so a number here means what it means there.
64
66
 
67
+ ## In the interface
68
+
69
+ Claude Code 2.1.287 and later can load a small mod from a plugin, code that runs inside Claude Code and draws in its interface. The plugin's mod (`hooks/register.ts`) makes the cleaners' work visible, and does nothing else: it changes no output and sends nothing.
70
+
71
+ | What | Where it shows | Needs |
72
+ | --- | --- | --- |
73
+ | A status line under the prompt, such as `intentic: Bash output trimmed 84% · 1.6M tokens saved over 12 commands`. It updates after each Bash command and stays empty until a command has been shortened. | The terminal and the Desktop app | `output_cleaners` on |
74
+ | `/intentic-pane`, the `/intentic:stats` report in a pane with a Refresh button. `/intentic-pane all` reports every project. | The terminal and the Desktop app | `node` on `PATH`, as for the rest of the plugin |
75
+
76
+ Where Claude Code draws nothing, the mod still runs and the status line has no place to go. `/intentic-pane` then answers with the report as text, as it also does in the VS Code extension and in `claude -p`. `/intentic:stats` works the same everywhere, whatever the version, and is the command to use in a script.
77
+
78
+ The status line counts from the ledger rows the session has already written (when it resumes) and adds each Bash command as it finishes. It is the same ledger `/intentic:stats` reads, so the two agree, except that a command left untrimmed on purpose by `output_holdout` counts as saving nothing in the status line. The mod does a few additions and one status update per Bash command and reads the ledger once, when the session starts. Claude Code gives a mod hook 10 seconds, and 50 ms for the hook on prompt edits, which the mod does not use.
79
+
80
+ Older Claude Code versions are not harmed by the mod. `modules` in `hooks/hooks.json` is a key those versions either do not know, in which case they read the file as before (checked on 2.1.200), or know and keep it switched off until a rollout reaches the account (seen on 2.1.283), in which case the Bash and session hooks still run and Claude Code may print one line at start saying that a hooks module was not loaded. From 2.1.287 on the module loads by default.
81
+
65
82
  ## Field notes
66
83
 
67
84
  Run `/intentic:field-notes` every few weeks. It counts this project's sessions (failures by how many sessions hit them, the commands that ran and how often they failed, the files edited most) and asks Claude to rewrite `.claude/intentic/field-notes.toon` from that evidence, checking each fact against the project before writing it. Commit the file to share it with a team, or ignore it to keep it yours.
@@ -89,6 +106,8 @@ The plugin is built from the packages that own each mechanism, so the sandbox an
89
106
  - [@intentic/fileq](../fileq): the CLI, bundled as `dist/fileq.mjs`, the text of its skill, and its NOTICE (rules adapted from SurfSense under Apache-2.0), copied to `generated/fileq-NOTICE`.
90
107
  - [@intentic/iq](../../_search/iq): the iq skill and the hint a session opens with. The build rewords the hint's two sandbox phrases for a plain install and fails if they change upstream.
91
108
 
109
+ The mod is one source file, [hooks/register.ts](hooks/register.ts), shipped as it is: Claude Code loads TypeScript hooks modules itself, in an environment with no Node and no imports beyond the plugin's own files, so there is nothing to bundle. Its helpers are exported and tested with bun (`src/mod.test.ts`); the hooks as Claude Code wires them are tested with its own test runner (`tests/mod.test.ts`).
110
+
92
111
  The build writes `dist/` (one esbuild bundle per hook and command, so an install needs no `node_modules`) and `generated/` (the skills and the output style, from the texts those packages own). Both are ignored by git.
93
112
 
94
113
  ### Key files
@@ -106,7 +125,8 @@ The build writes `dist/` (one esbuild bundle per hook and command, so an install
106
125
  ```sh
107
126
  pnpm --filter @intentic/claude-plugin build # dist/, generated/, and the version in plugin.json
108
127
  pnpm --filter @intentic/claude-plugin test
109
- pnpm --filter @intentic/claude-plugin validate # build, then `claude plugin validate`
128
+ pnpm --filter @intentic/claude-plugin test:mod # the mod's tests, through `claude plugin test` (needs `claude` 2.1.287 or later on PATH)
129
+ pnpm --filter @intentic/claude-plugin validate # build, then `claude plugin validate --strict`
110
130
  claude --plugin-dir _sandbox/claude-plugin # a session with the checkout loaded, after a build
111
131
  ```
112
132
 
package/dist/fileq.mjs CHANGED
@@ -87118,8 +87118,8 @@ var require_body_reader = __commonJS({
87118
87118
  if (checkboxResult) {
87119
87119
  var checkboxElement = fldChar.firstOrEmpty("w:ffData").firstOrEmpty("w:checkBox");
87120
87120
  var checkedElement = checkboxElement.first("w:checked");
87121
- var checked = checkedElement == null ? readBooleanElement(checkboxElement.first("w:default")) : readBooleanElement(checkedElement);
87122
- return { type: "checkbox", checked };
87121
+ var checked2 = checkedElement == null ? readBooleanElement(checkboxElement.first("w:default")) : readBooleanElement(checkedElement);
87122
+ return { type: "checkbox", checked: checked2 };
87123
87123
  }
87124
87124
  return { type: "unknown" };
87125
87125
  }
@@ -105433,12 +105433,12 @@ var init_pdf_furniture = __esm({
105433
105433
  });
105434
105434
 
105435
105435
  // ../fileq/src/lib/derivers/pdf.ts
105436
- import { execFile as execFile2 } from "node:child_process";
105436
+ import { execFile as execFile2, spawnSync } from "node:child_process";
105437
105437
  import { mkdtemp, readdir, readFile as readFile10, rm } from "node:fs/promises";
105438
105438
  import { tmpdir } from "node:os";
105439
105439
  import { join as join3 } from "node:path";
105440
105440
  import { promisify as promisify2 } from "node:util";
105441
- var SCAN_THRESHOLD_CHARS_PER_PAGE, MAX_OCR_PAGES, OCR_DPI, run3, ocrAvailable, recognisePages, pagesToMarkdown, furnitureNotes, withoutFurniture, pdfDeriver;
105441
+ var SCAN_THRESHOLD_CHARS_PER_PAGE, MAX_OCR_PAGES, OCR_DPI, OCR_TIMEOUT_MS, run3, checked, ocrAvailable, readPages, recognisePages, pagesToMarkdown, furnitureNotes, withoutFurniture, pdfDeriver;
105442
105442
  var init_pdf = __esm({
105443
105443
  "../fileq/src/lib/derivers/pdf.ts"() {
105444
105444
  "use strict";
@@ -105448,8 +105448,27 @@ var init_pdf = __esm({
105448
105448
  SCAN_THRESHOLD_CHARS_PER_PAGE = 24;
105449
105449
  MAX_OCR_PAGES = 20;
105450
105450
  OCR_DPI = "200";
105451
+ OCR_TIMEOUT_MS = 6e4 + MAX_OCR_PAGES * 15e3;
105451
105452
  run3 = promisify2(execFile2);
105452
- ocrAvailable = () => onPath("tesseract") && onPath("pdftoppm");
105453
+ checked = /* @__PURE__ */ new Map();
105454
+ ocrAvailable = () => {
105455
+ const path = process.env["PATH"] ?? "";
105456
+ let available = checked.get(path);
105457
+ if (available === void 0) {
105458
+ available = onPath("pdftoppm") && onPath("ocr") && spawnSync("ocr", ["--check"], { stdio: "ignore", timeout: 1e4 }).status === 0;
105459
+ checked.set(path, available);
105460
+ }
105461
+ return available;
105462
+ };
105463
+ readPages = (images) => new Promise((resolve6, reject) => {
105464
+ execFile2("ocr", [...images], { timeout: OCR_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, (error2, stdout) => {
105465
+ if (error2 === null || error2.code === 1) {
105466
+ resolve6(stdout);
105467
+ } else {
105468
+ reject(error2);
105469
+ }
105470
+ });
105471
+ });
105453
105472
  recognisePages = async (absPath, totalPages) => {
105454
105473
  const dir = await mkdtemp(join3(tmpdir(), "fileq-ocr-"));
105455
105474
  try {
@@ -105457,12 +105476,11 @@ var init_pdf = __esm({
105457
105476
  timeout: 12e4
105458
105477
  });
105459
105478
  const images = (await readdir(dir)).filter((name) => name.endsWith(".png")).toSorted();
105460
- const pages = [];
105461
- for (const image of images) {
105462
- const { stdout } = await run3("tesseract", [join3(dir, image), "stdout", "-l", "eng"], { timeout: 12e4, maxBuffer: 16 * 1024 * 1024 });
105463
- pages.push(stdout.replaceAll(/[ \t]+/g, " ").trim());
105479
+ if (images.length === 0) {
105480
+ return [];
105464
105481
  }
105465
- return pages;
105482
+ const pages = (await readPages(images.map((image) => join3(dir, image)))).split("\f");
105483
+ return images.map((_3, index) => (pages[index] ?? "").replaceAll(/[ \t]+/g, " ").trim());
105466
105484
  } finally {
105467
105485
  await rm(dir, { recursive: true, force: true });
105468
105486
  }
@@ -105478,11 +105496,11 @@ ${page === "" ? "(no text on this page)" : page}`).join("\n\n");
105478
105496
  return { markdown: pagesToMarkdown(kept), notes: furnitureNotes(removed, example) };
105479
105497
  };
105480
105498
  pdfDeriver = {
105481
- // The stamp names OCR capability, since a sidecar written before tesseract was available must read as stale now
105482
- // that it's here.
105483
- // A static name would leave a pre-OCR sidecar looking current forever.
105499
+ // The stamp names OCR capability, since a sidecar written before the reader was available must read as stale now
105500
+ // that it's here. A static name would leave a pre-OCR sidecar looking current forever. It names the reader too:
105501
+ // `pdf+ocr` was tesseract's, whose pages PaddleOCR reads again.
105484
105502
  get name() {
105485
- return ocrAvailable() ? "pdf+ocr" : "pdf";
105503
+ return ocrAvailable() ? "pdf+ppocr" : "pdf";
105486
105504
  },
105487
105505
  version: 2,
105488
105506
  derive: async (absPath) => {
@@ -105504,10 +105522,10 @@ ${page === "" ? "(no text on this page)" : page}`).join("\n\n");
105504
105522
  }
105505
105523
  const recognised = await recognisePages(absPath, totalPages);
105506
105524
  const notes = [
105507
- `scanned: text recognised by OCR (tesseract) on ${recognised.length} of ${totalPages} page${plural3} \u2014 recognised, not exact; check figures against the page image`
105525
+ `scanned: text recognised by OCR (PaddleOCR) on ${recognised.length} of ${totalPages} page${plural3} \u2014 recognised, not exact; check figures against the page image`
105508
105526
  ];
105509
105527
  if (totalPages > MAX_OCR_PAGES) {
105510
- notes.push(`OCR stops at ${MAX_OCR_PAGES} pages: run tesseract over the rest yourself if they matter`);
105528
+ notes.push(`OCR stops at ${MAX_OCR_PAGES} pages: draw the rest with pdftoppm and read them with \`ocr <page.png>...\` if they matter`);
105511
105529
  }
105512
105530
  const { markdown, notes: stripped } = withoutFurniture(recognised);
105513
105531
  return { markdown, title, notes: [...notes, ...stripped] };
package/dist/stats.mjs CHANGED
@@ -13,6 +13,8 @@ var OPENING_SEARCHES = { name: "openingSearches", of: (turn) => turn.openingSear
13
13
  var ROOT_LISTINGS = { name: "openingListings", of: (turn) => turn.openingListings, round: round1 };
14
14
  var CALLS_BEFORE_TARGET = { name: "callsBeforeTarget", of: (turn) => turn.callsBeforeTarget, round: round1 };
15
15
  var FAILED_CALLS = { name: "failedCalls", of: (turn) => turn.failedCalls, round: round1 };
16
+ var ROUND_TRIPS = { name: "roundTrips", of: (turn) => turn.roundTrips, round: round1 };
17
+ var CONTEXT_PER_CALL = { name: "contextPerCall", of: (turn) => turn.contextPerCall, round: Math.round };
16
18
  var armOfValues = (values) => {
17
19
  if (values.length === 0) {
18
20
  return { turns: 0, mean: 0, variance: 0 };
@@ -73,8 +75,12 @@ var MECHANISMS = {
73
75
  map: { metrics: [ROOT_LISTINGS, CALLS_BEFORE_TARGET], sampleUnit: "opening turns", sample: openingTurn },
74
76
  // The brief rides the whole session, so turn 40 is as much evidence as turn 1.
75
77
  notes: { metrics: [FAILED_CALLS, CALLS_BEFORE_TARGET], sampleUnit: "conversations", sample: meanOfTurns },
76
- // Judged on what the long form was written to prevent: calls that fail, and calls spent before reaching the work.
77
- guidance: { metrics: [FAILED_CALLS, CALLS_BEFORE_TARGET], sampleUnit: "conversations", sample: meanOfTurns }
78
+ // Judged on what the long form was written to prevent: calls that fail, calls spent before reaching the work, and
79
+ // the round trips its batching and context-reuse paragraphs ask the agent not to spend.
80
+ guidance: { metrics: [FAILED_CALLS, CALLS_BEFORE_TARGET, ROUND_TRIPS], sampleUnit: "conversations", sample: meanOfTurns },
81
+ // Judged on what it is for, the prompt each call carries, and on what it could cost: a model that has lost a result
82
+ // it needed runs the tool again (more round trips) or guesses (more calls that fail).
83
+ clearing: { metrics: [CONTEXT_PER_CALL, ROUND_TRIPS, FAILED_CALLS], sampleUnit: "conversations", sample: meanOfTurns }
78
84
  };
79
85
  var measureExperiment = (turns, design) => {
80
86
  const latest = turns.filter((turn) => design.arm(turn) !== void 0 && design.cohort(turn) !== void 0).toSorted((left, right) => right.at - left.at)[0];
@@ -13,7 +13,7 @@ cached by content, so a repeat read of unchanged content is instant.
13
13
  - Prints a capsule (format, token cost), the content up to the budget, and `saved:` — the markdown file
14
14
  carrying the whole thing. Over budget, the cut is announced with that exact path to Read.
15
15
  - Formats: docx and odt (headings, lists, tables), xlsx (capped tables), pptx (slides + speaker notes),
16
- pdf (text layer; a scan is OCR'd when `tesseract` is on PATH, and the note says the words are
16
+ pdf (text layer; a scan is OCR'd when an `ocr` command is on PATH, and the note says the words are
17
17
  recognised, not exact), epub (chapters in reading order), ipynb (cells, fenced code, capped outputs),
18
18
  png/jpg/gif/webp (dimensions + EXIF), mp3/wav/flac/mp4/… (duration + tags), html, and V8 profiles
19
19
  (`.cpuprofile` from `node --cpu-prof`, `.heapprofile` from `--heap-prof`) as functions ranked by self and total cost.
@@ -29,9 +29,10 @@ without parsing it again. Run it again rather than opening a sidecar under `.int
29
29
  checks the hash for you.
30
30
 
31
31
  ## What it refuses, and why
32
- - A scanned PDF with no `tesseract` on PATH answers "no usable text layer … OCR is not part of this tier"
32
+ - A scanned PDF with no `ocr` on PATH answers "no usable text layer … OCR is not part of this tier"
33
33
  rather than an empty page; images say "no visual description". Treat those notes as "not generated",
34
- never as "nothing there". Installing tesseract turns the first into OCR.
34
+ never as "nothing there". An `ocr` command that prints each image's text, a form feed between images (the
35
+ Intentic sandbox's PaddleOCR reader), turns the first into OCR.
35
36
  - Plain text (md, csv, txt, code) is not fileq's business: Read it directly — but see below for the big ones.
36
37
  - Web pages belong to `webq`; images for a vision model belong to the Read tool, which shows the pixels.
37
38
 
package/hooks/hooks.json CHANGED
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "description": "intentic: session context (map, field notes, iq teaching), Bash output cleaning. Each script reads its own /config switch and answers nothing when it is off. Without node on PATH the tool hooks stand down silently and each session opens with one line saying why nothing ran.",
3
+ "modules": [
4
+ "./register.ts"
5
+ ],
3
6
  "hooks": {
4
7
  "SessionStart": [
5
8
  {
@@ -0,0 +1,311 @@
1
+ // The plugin's mod: what the Bash output cleaners saved, drawn in Claude Code (2.1.287 or later). Claude Code loads this
2
+ // file in-process, from `modules` in hooks.json, next to the shell hooks that do the cleaning; it only shows their work.
3
+ // - a status entry under the prompt, updated after each Bash call: "Bash trimmed 84% · 1.6M tokens saved · 12 commands";
4
+ // - `/intentic-pane`, the /intentic:stats report in a pane, or as text where nothing draws.
5
+ // A mod runs in an environment with no Node, so files and processes go through `$`, and this one file imports nothing:
6
+ // the engine's own types (`import type … from 'claude-code'`) are only written once a mod has loaded, so the few members
7
+ // used here are declared below instead, which keeps the package's typecheck working on a fresh checkout.
8
+ // Limits (code.claude.com/docs/en/plugins/mods/reference): a hook gets 10 s, a prompt.edit hook 50 ms; nothing here hooks
9
+ // prompt.edit, and the per-call hook does a string-length sum and one `$.ui.status` call.
10
+
11
+ // ---- the slice of the mods API this file uses ----------------------------------------------------------------------
12
+
13
+ interface Element {
14
+ readonly type: string;
15
+ }
16
+
17
+ type Component<P> = (props: P) => Element;
18
+
19
+ interface Api {
20
+ readonly plugin: { readonly name: string; readonly root: string };
21
+ readonly env: { get(name: string): Promise<string | undefined> };
22
+ readonly fs: {
23
+ read(path: string): Promise<string>;
24
+ exists(path: string): Promise<boolean>;
25
+ list(path: string): Promise<readonly { readonly name: string; readonly kind: string }[]>;
26
+ stat(path: string): Promise<{ readonly mtimeMs: number }>;
27
+ };
28
+ readonly process: { run(argv: readonly string[], init?: { cwd?: string; timeoutMs?: number }): Promise<{ exitCode: number; stdout: string; stderr: string }> };
29
+ readonly session: { id(): Promise<string>; cwd(): Promise<string>; surfaces(): Promise<readonly string[]> };
30
+ readonly command: { register(spec: { name: string; description: string; argumentHint?: string }): Promise<object> };
31
+ readonly ui: {
32
+ status(text: string | undefined): void;
33
+ open(pane: { id: string; title?: string }): Promise<{ isPlaced: boolean }>;
34
+ close(pane: { id: string }): Promise<void>;
35
+ invalidate(event: "ui.render"): void;
36
+ resolve(e: RenderEvent): {
37
+ Box: Component<{ flexDirection?: "column" | "row"; gap?: number; children?: readonly Element[] }>;
38
+ Text: Component<{ dimColor?: boolean; children?: string }>;
39
+ Markdown: Component<{ text: string }>;
40
+ Button: Component<{ key: string; label: string; hotkey?: string; onPress: () => Promise<void> }>;
41
+ };
42
+ };
43
+ }
44
+
45
+ type Next<E, R> = (e: E) => Promise<R>;
46
+ type Hook<E, R> = ($: Api, e: E, next: Next<E, R>) => Promise<R> | R;
47
+
48
+ interface PostToolUse {
49
+ readonly session_id?: string;
50
+ readonly tool_name?: string;
51
+ readonly tool_response?: BashResponse | string | null;
52
+ }
53
+ interface PostToolUseResult {
54
+ readonly updatedToolOutput?: BashResponse | string | null;
55
+ }
56
+ interface CommandRun {
57
+ readonly args: string;
58
+ }
59
+ interface SessionStart {
60
+ readonly cwd?: string;
61
+ }
62
+ // A tool's result as Claude Code hands it over; Bash's carries these, and whatever else it carries is left alone.
63
+ interface BashResponse {
64
+ readonly stdout?: string | number | boolean | object | null;
65
+ readonly stderr?: string | number | boolean | object | null;
66
+ readonly interrupted?: boolean;
67
+ readonly isImage?: boolean;
68
+ readonly backgroundTaskId?: string | number | null;
69
+ }
70
+ interface LedgerRow {
71
+ readonly session?: string | number | null;
72
+ readonly rawBytes?: string | number | null;
73
+ readonly emittedBytes?: string | number | null;
74
+ }
75
+ interface Options {
76
+ readonly output_cleaners?: boolean | string | number | readonly string[];
77
+ }
78
+
79
+ interface RenderEvent {
80
+ readonly surface?: string;
81
+ }
82
+
83
+ interface On {
84
+ (event: "session.start", hook: Hook<SessionStart, SessionStart>): void;
85
+ (event: "classic.PostToolUse", hook: Hook<PostToolUse, PostToolUseResult | undefined>): void;
86
+ (event: "command.run", matcher: { command: string }, hook: Hook<CommandRun, { text?: string }>): void;
87
+ (event: "ui.render", matcher: { component: "Pane"; requestId: string }, hook: Hook<RenderEvent, Element>): void;
88
+ }
89
+
90
+ export type Register = (on: On, options: Options) => void;
91
+
92
+ // ---- what the cleaners saved: pure helpers, tested without an engine ---------------------------------------------------
93
+
94
+ export interface Tally {
95
+ readonly commands: number;
96
+ readonly raw: number;
97
+ readonly emitted: number;
98
+ }
99
+
100
+ export const EMPTY: Tally = { commands: 0, raw: 0, emitted: 0 };
101
+
102
+ export const add = (tally: Tally, raw: number, emitted: number): Tally => ({ commands: tally.commands + 1, raw: tally.raw + raw, emitted: tally.emitted + emitted });
103
+
104
+ // A ledger row, as @intentic/output-cleaners writes it into filter-stats.jsonl: lengths of the text before and after the
105
+ // cleaners, tagged by the plugin's post-bash hook with the project and the session.
106
+ export const seedFromLedger = (text: string, session: string): Tally => {
107
+ let tally = EMPTY;
108
+ for (const line of text.split("\n")) {
109
+ // The session id is a substring of its own rows, so most lines are skipped before they are parsed.
110
+ if (!line.includes(session)) {
111
+ continue;
112
+ }
113
+ try {
114
+ // SAFETY: a ledger line is whatever a past hook wrote; each field is checked by type before it is used.
115
+ const row = JSON.parse(line) as LedgerRow;
116
+ if (row.session === session && typeof row.rawBytes === "number" && typeof row.emittedBytes === "number") {
117
+ tally = add(tally, row.rawBytes, row.emittedBytes);
118
+ }
119
+ } catch {
120
+ // allow(silent-catch): a torn or foreign line is not a row; the ledger's own reader skips these the same way.
121
+ }
122
+ }
123
+ return tally;
124
+ };
125
+
126
+ // What the model would have read of a Bash result, which is what post-bash.ts counts as the raw text: stdout, then stderr.
127
+ export const textLength = (response: BashResponse | string | null | undefined): number | undefined => {
128
+ if (typeof response !== "object" || response === null) {
129
+ return undefined;
130
+ }
131
+ const { stdout, stderr, interrupted, isImage, backgroundTaskId } = response;
132
+ if (typeof stdout !== "string" || interrupted === true || isImage === true || backgroundTaskId !== undefined) {
133
+ return undefined;
134
+ }
135
+ const err = typeof stderr === "string" ? stderr : "";
136
+ return err === "" ? stdout.length : stdout.length + (stdout === "" || stdout.endsWith("\n") ? 0 : 1) + err.length;
137
+ };
138
+
139
+ export const emittedLength = (result: PostToolUseResult | undefined): number | undefined => {
140
+ return textLength(result?.updatedToolOutput);
141
+ };
142
+
143
+ // Four characters to a token, as the ledger's own summary counts.
144
+ const tokens = (length: number): string => {
145
+ const count = Math.round(length / 4);
146
+ if (count >= 1_000_000) {
147
+ return `${(count / 1_000_000).toFixed(1)}M`;
148
+ }
149
+ if (count >= 10_000) {
150
+ return `${Math.round(count / 1000)}k`;
151
+ }
152
+ return count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count);
153
+ };
154
+
155
+ // Nothing until a command has actually been shortened: "0% saved" under every prompt is noise.
156
+ export const statusText = (tally: Tally): string | undefined => {
157
+ const saved = tally.raw - tally.emitted;
158
+ if (saved <= 0 || tally.raw === 0) {
159
+ return undefined;
160
+ }
161
+ return `intentic: Bash output trimmed ${Math.round((saved / tally.raw) * 100)}% · ${tokens(saved)} tokens saved over ${tally.commands} ${tally.commands === 1 ? "command" : "commands"}`;
162
+ };
163
+
164
+ // The plugin's data directory, where post-bash.ts keeps the ledger and session-start.ts the switches. A hook gets it as
165
+ // CLAUDE_PLUGIN_DATA; a mod is not given it, so it is worked out the way Claude Code does: `plugins/data/<name>-<marketplace>`
166
+ // under the config directory, the marketplace read off the plugin's own install path (`plugins/cache/<marketplace>/<name>/<version>`),
167
+ // and `<name>-inline` for a plugin loaded from a folder.
168
+ export const dataDirOf = (name: string, root: string, env: { data?: string | undefined; config?: string | undefined; home?: string | undefined }): string | undefined => {
169
+ if (env.data !== undefined && env.data !== "") {
170
+ return env.data;
171
+ }
172
+ const config = env.config !== undefined && env.config !== "" ? env.config : env.home !== undefined && env.home !== "" ? `${env.home}/.claude` : undefined;
173
+ if (config === undefined) {
174
+ return undefined;
175
+ }
176
+ const cached = /[\\/]plugins[\\/]cache[\\/]([^\\/]+)[\\/]([^\\/]+)[\\/][^\\/]+[\\/]?$/.exec(root);
177
+ const id = `${name}-${cached?.[1] ?? "inline"}`.replace(/[^a-zA-Z0-9_-]/g, "-");
178
+ return `${config.replace(/[\\/]+$/, "")}/plugins/data/${id}`;
179
+ };
180
+
181
+ // ---- the mod ---------------------------------------------------------------------------------------------------------
182
+
183
+ const PANE = "intentic-savings";
184
+ const COMMAND = "intentic-pane";
185
+ // A pane's Markdown holds 10,000 characters; the report is a fraction of that, but a project list could grow.
186
+ const REPORT_LIMIT = 9000;
187
+
188
+ // Where this plugin keeps its ledger: the directory Claude Code names for its hooks (see dataDirOf). A plugin read from a
189
+ // folder (a marketplace that is a directory) has an install path that says nothing about its marketplace, so when the
190
+ // derived directory holds nothing the session-start hook wrote (`options.json`, written every session), the plugin's own
191
+ // newest sibling under plugins/data is taken instead.
192
+ const dataDir = async ($: Api): Promise<string | undefined> => {
193
+ const derived = dataDirOf($.plugin.name, $.plugin.root, {
194
+ data: await $.env.get("CLAUDE_PLUGIN_DATA"),
195
+ config: await $.env.get("CLAUDE_CONFIG_DIR"),
196
+ home: (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE")),
197
+ });
198
+ if (derived === undefined || (await $.fs.exists(`${derived}/options.json`))) {
199
+ return derived;
200
+ }
201
+ const parent = derived.slice(0, derived.lastIndexOf("/"));
202
+ let best = derived;
203
+ let newest = -1;
204
+ try {
205
+ for (const entry of await $.fs.list(parent)) {
206
+ if (entry.kind !== "dir" || !entry.name.startsWith(`${$.plugin.name}-`)) {
207
+ continue;
208
+ }
209
+ const written = await $.fs.stat(`${parent}/${entry.name}/options.json`).then(
210
+ (stat) => stat.mtimeMs,
211
+ () => -1,
212
+ );
213
+ if (written > newest) {
214
+ newest = written;
215
+ best = `${parent}/${entry.name}`;
216
+ }
217
+ }
218
+ } catch {
219
+ // allow(silent-catch): no plugins/data yet means nothing has run, and the derived directory is as good as any.
220
+ }
221
+ return best;
222
+ };
223
+
224
+ // The /intentic:stats report, from the bundle the plugin ships; the same code the skill runs, so the pane and the command
225
+ // say the same thing.
226
+ const buildReport = async ($: Api, args: string): Promise<string> => {
227
+ const data = await dataDir($);
228
+ if (data === undefined) {
229
+ return "intentic: the plugin's data directory could not be found, so there is no report to show.";
230
+ }
231
+ const argv = ["node", `${$.plugin.root}/dist/stats.mjs`, "--data", data, "--project", await $.session.cwd(), ...(args.trim() === "all" ? ["all"] : [])];
232
+ try {
233
+ const ran = await $.process.run(argv, { timeoutMs: 20_000 });
234
+ return ran.exitCode === 0 ? ran.stdout.slice(0, REPORT_LIMIT) : `intentic: the report failed (exit ${ran.exitCode}): ${ran.stderr.trim().slice(0, 300)}`;
235
+ } catch (error) {
236
+ return `intentic: the report needs Node.js 20.11 or later on PATH (${error instanceof Error ? error.message : String(error)}).`;
237
+ }
238
+ };
239
+
240
+ export const register: Register = (on, options) => {
241
+ // The same switch the shell hooks read: off, nothing is cleaned, nothing is ledgered, and there is nothing to show.
242
+ const cleaning = options["output_cleaners"] !== false;
243
+ let tally: Tally = EMPTY;
244
+ let report = "";
245
+
246
+ on("session.start", async ($, e, next) => {
247
+ await $.command.register({ name: COMMAND, description: "Show what the intentic plugin saved, in a pane", argumentHint: "[all]" });
248
+ if (cleaning) {
249
+ try {
250
+ const data = await dataDir($);
251
+ const session = await $.session.id();
252
+ // Resumed or reloaded: what this session already saved is in the ledger. A ledger over the 4 MiB a mod may
253
+ // read at once is not an error; the count starts from this point.
254
+ tally = data === undefined ? EMPTY : seedFromLedger(await $.fs.read(`${data}/output/filter-stats.jsonl`), session);
255
+ } catch {
256
+ tally = EMPTY;
257
+ }
258
+ $.ui.status(statusText(tally));
259
+ }
260
+ return next(e);
261
+ });
262
+
263
+ if (cleaning) {
264
+ on("classic.PostToolUse", async ($, e, next) => {
265
+ const raw = e.tool_name === "Bash" ? textLength(e.tool_response) : undefined;
266
+ const result = await next(e);
267
+ if (raw !== undefined) {
268
+ // The shell hook answers with the trimmed result, or with nothing when it left the output as it was.
269
+ tally = add(tally, raw, emittedLength(result) ?? raw);
270
+ $.ui.status(statusText(tally));
271
+ }
272
+ return result;
273
+ });
274
+ }
275
+
276
+ on("command.run", { command: COMMAND }, async ($, e) => {
277
+ report = await buildReport($, e.args);
278
+ // A plain `-p` run has no surface at all, and `ui.open` would answer "placed" into nothing; a surface that places no
279
+ // panes answers "not placed". Either way the report goes out as the command's own text, which every Claude Code shows.
280
+ const draws = (await $.session.surfaces()).length > 0;
281
+ const opened = draws ? await $.ui.open({ id: PANE, title: "intentic savings" }) : undefined;
282
+ if (opened?.isPlaced === true) {
283
+ $.ui.invalidate("ui.render");
284
+ return {};
285
+ }
286
+ if (opened !== undefined) {
287
+ await $.ui.close({ id: PANE });
288
+ }
289
+ return { text: report };
290
+ });
291
+
292
+ on("ui.render", { component: "Pane", requestId: PANE }, ($, e) => {
293
+ const { Box, Text, Markdown, Button } = $.ui.resolve(e);
294
+ return Box({
295
+ flexDirection: "column",
296
+ gap: 1,
297
+ children: [
298
+ report === "" ? Text({ dimColor: true, children: "Run /intentic-pane to fill this." }) : Markdown({ text: report }),
299
+ Button({
300
+ key: "refresh",
301
+ label: "Refresh",
302
+ hotkey: "r",
303
+ onPress: async () => {
304
+ report = await buildReport($, "");
305
+ $.ui.invalidate("ui.render");
306
+ },
307
+ }),
308
+ ],
309
+ });
310
+ });
311
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentic/claude-plugin",
3
- "version": "1.322.0",
3
+ "version": "1.324.0",
4
4
  "description": "The intentic Claude Code plugin: trimmed Bash output, a project map and field notes at session start, iq search and fileq teaching, and a /intentic:stats report of what each saved",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,8 +40,8 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@intentic/agent-context": "0.0.0",
43
- "@intentic/fileq": "1.322.0",
44
- "@intentic/iq": "1.322.0",
43
+ "@intentic/fileq": "1.324.0",
44
+ "@intentic/iq": "1.324.0",
45
45
  "@intentic/output-cleaners": "0.0.0",
46
46
  "@intentic/testing": "0.0.0",
47
47
  "@intentic/tsconfig": "0.0.0",
@@ -55,6 +55,7 @@
55
55
  "typecheck": "tsgo --noEmit -p tsconfig.test.json",
56
56
  "test": "suites",
57
57
  "test:watch": "suites --watch",
58
+ "test:mod": "node test-mod.mjs",
58
59
  "validate": "node build.mjs --validate"
59
60
  }
60
61
  }