@raymondchins/agentmap 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +439 -0
  3. package/package.json +54 -0
  4. package/repomap.mjs +461 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Raymond Surya Chin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,439 @@
1
+ # agentmap
2
+
3
+ **The repo map your coding agent is _forced_ to use.**
4
+
5
+ A queryable, ranked code-relationship map for TypeScript/JavaScript repos — personalized
6
+ PageRank importance, Aider-style symbol ranking, a token-budgeted digest, and a single
7
+ `--any` router (file → symbol → feature → live git-grep) — wired straight into the agent
8
+ loop so it actually gets used, not just published.
9
+
10
+ <!-- badges (placeholder — wire up once published) -->
11
+ [![npm](https://img.shields.io/npm/v/@raymondchins/agentmap)](https://www.npmjs.com/package/@raymondchins/agentmap)
12
+ [![CI](https://img.shields.io/badge/CI-pending-lightgrey)](#)
13
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green)](./LICENSE)
14
+ [![node](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](#)
15
+
16
+ > One file, one dependency (`ts-morph`). No vector DB, no embedding API, no server.
17
+ > `npx @raymondchins/agentmap --any <query>` and you have a ranked answer.
18
+
19
+ ---
20
+
21
+ ## Why it's different
22
+
23
+ Most "repo context" tools are one-shot: they pack the repository (or a slice of it) into a
24
+ prompt and stop there. agentmap is a **queryable, ranked, and self-refreshing** map that an
25
+ agent can interrogate flag-by-flag — and, crucially, is **wired into the agent loop** via a
26
+ post-commit auto-refresh and a `PreToolUse` hook that nudges the agent to use the map
27
+ *before* it falls back to serial grep.
28
+
29
+ | | **agentmap** | Aider repo map | RepoMapper | Repomix | code2prompt |
30
+ | --- | --- | --- | --- | --- | --- |
31
+ | **Ranking algorithm** | Personalized PageRank (file + symbol graphs) | PageRank (graph ranking) | Importance heuristics | None (file order) | None (file order) |
32
+ | **Languages** | TS/JS (via ts-morph) | Many (tree-sitter) | Many (tree-sitter) | Language-agnostic (text) | Language-agnostic (text) |
33
+ | **Token-budget output** | Yes — `--map [--tokens N]` ranked digest | Yes (built into Aider's context) | Partial | Yes (size caps) | Yes (templates/caps) |
34
+ | **Agent-loop integration** | **Yes — post-commit auto-refresh + PreToolUse hook** | In-process (Aider only) | No | No | No |
35
+ | **Dependencies** | `ts-morph` only | Python + tree-sitter stack | Python + tree-sitter | Node | Rust binary |
36
+ | **Install** | `npx @raymondchins/agentmap` | `pip install aider` | `pip install` | `npx`/global | `cargo`/binary |
37
+
38
+ What that table is **not** claiming: agentmap is TS/JS-only (the others are multi-language),
39
+ and it's a **file-level import graph**, not a full call-site/reference resolver (see
40
+ [Scope & limitations](#scope--limitations)). The differentiators are narrow and honest:
41
+ **(1)** the `--any` router, and **(2)** the agent-loop wiring. Everything else is table stakes.
42
+
43
+ ---
44
+
45
+ ## Quickstart
46
+
47
+ No install needed:
48
+
49
+ ```bash
50
+ npx @raymondchins/agentmap --any <query>
51
+ ```
52
+
53
+ …or run it directly from a checkout:
54
+
55
+ ```bash
56
+ node repomap.mjs --any <query>
57
+ ```
58
+
59
+ The first run builds and caches the map to `.claude/repomap.json` (add it to
60
+ `.gitignore`). Subsequent runs serve the cache when the tree is clean and `HEAD` is
61
+ unchanged, and silently rebuild from disk when there are uncommitted `.ts/.tsx/.js/...`
62
+ edits — so queries always reflect your in-flight work.
63
+
64
+ Run with no flag to build + print a one-line summary:
65
+
66
+ ```
67
+ $ node repomap.mjs
68
+ repomap: 154 files | 4 features | top hub: lib/utils.ts (deg 52, pr 0.105171)
69
+ ```
70
+
71
+ ---
72
+
73
+ ## The `--any` router
74
+
75
+ One flag, no flag-picking. `--any <query>` resolves your query through a cascade and
76
+ returns the first layer that hits:
77
+
78
+ ```
79
+ --any <query>
80
+
81
+ ├─ 1. FILE exact path → unique basename → unique substring
82
+ ├─ 2. SYMBOL exported name contains the query (across all files)
83
+ ├─ 3. FEATURE app/-router feature name contains the query
84
+ └─ 4. CONTENT live `git grep` (tracked + untracked) — never stale
85
+ ```
86
+
87
+ Layers 1–3 read the cached structural map (fast, ranked). Layer 4 is a **live disk read**
88
+ via `git grep -F`, so raw strings, copy, Tailwind classes, and config values the structural
89
+ graph never indexes still resolve instead of coming up empty.
90
+
91
+ **Symbol hit** (query resolved to a symbol → full block):
92
+
93
+ ```
94
+ $ node repomap.mjs --any cn
95
+ [structure] 1 symbol, 0 feature match for "cn"
96
+ lib/utils.ts → cn (FunctionDeclaration)
97
+ ```
98
+
99
+ **Ambiguous file hit** (query matched multiple files → narrow it):
100
+
101
+ ```
102
+ $ node repomap.mjs --any utils
103
+ [structure] "utils" matched 3 files — narrow it:
104
+ lib/utils.ts
105
+ lib/db/utils.ts
106
+ tests/prompts/utils.ts
107
+ ```
108
+
109
+ **Content fallback** (no file/symbol/feature match → live git-grep):
110
+
111
+ ```
112
+ $ node repomap.mjs --any streamText
113
+ [content] 13 lines:
114
+ app/(chat)/api/chat/route.ts:8: streamText,
115
+ app/(chat)/api/chat/route.ts:194: const result = streamText({
116
+ artifacts/code/server.ts:1:import { streamText } from "ai";
117
+ artifacts/code/server.ts:18: const { fullStream } = streamText({
118
+ artifacts/code/server.ts:40: const { fullStream } = streamText({
119
+ artifacts/sheet/server.ts:1:import { streamText } from "ai";
120
+ artifacts/sheet/server.ts:11: const { fullStream } = streamText({
121
+ ```
122
+
123
+ ---
124
+
125
+ ## Commands
126
+
127
+ Every snippet below is **verbatim output** from running agentmap against the public
128
+ 154-file Next.js repo [vercel/ai-chatbot](https://github.com/vercel/ai-chatbot) (sha 2becdb4).
129
+
130
+ ### `--any <q>` — the router (file → symbol → feature → live content)
131
+
132
+ See [The `--any` router](#the---any-router) above. Default first move for any
133
+ "where/what/who" question.
134
+
135
+ ### `--find <q>` — reuse-before-rebuild symbol search
136
+
137
+ Find every exported symbol whose name contains the query. Use it before writing a new util
138
+ or component to check what already exists.
139
+
140
+ ```
141
+ $ node repomap.mjs --find Message
142
+ find "Message": 55 match
143
+ hooks/use-messages.tsx → useMessages (FunctionDeclaration)
144
+ lib/errors.ts → getMessageByErrorCode (FunctionDeclaration)
145
+ lib/types.ts → messageMetadataSchema (VariableDeclaration)
146
+ lib/types.ts → MessageMetadata (TypeAliasDeclaration)
147
+ lib/types.ts → ChatMessage (TypeAliasDeclaration)
148
+ lib/utils.ts → convertToUIMessages (FunctionDeclaration)
149
+ lib/utils.ts → getTextFromMessage (FunctionDeclaration)
150
+ tests/helpers.ts → generateTestMessage (FunctionDeclaration)
151
+ app/(chat)/actions.ts → generateTitleFromUserMessage (FunctionDeclaration)
152
+
153
+ ```
154
+
155
+ ### `--relates <path>` — blast radius + transitive relevance
156
+
157
+ The file's own block (exports / imports / direct dependents) **plus** a random-walk
158
+ relevance list (personalized PageRank on the bidirectional import graph) — the files most
159
+ related to the target, transitively, not just its direct importers.
160
+
161
+ ```
162
+ $ node repomap.mjs --relates lib/db/schema.ts
163
+ relates: lib/db/schema.ts (pr 0.073744)
164
+ exports (14): user(VariableDeclaration), User(TypeAliasDeclaration), chat(VariableDeclaration), Chat(TypeAliasDeclaration), message(VariableDeclaration), DBMessage(TypeAliasDeclaration), …
165
+ imports (0): —
166
+ dependents (21): hooks/use-active-chat.tsx, lib/types.ts, lib/utils.ts, components/chat/artifact.tsx, components/chat/message.tsx, lib/db/queries.ts, app/(chat)/api/chat/route.ts, …
167
+ related (random-walk relevance):
168
+ lib/utils.ts (0.0476)
169
+ lib/types.ts (0.0376)
170
+ components/chat/artifact.tsx (0.0372)
171
+ components/chat/icons.tsx (0.0264)
172
+ components/chat/message.tsx (0.0237)
173
+ lib/db/queries.ts (0.0225)
174
+ app/(chat)/api/chat/route.ts (0.0218)
175
+
176
+ ```
177
+
178
+ ### `--feature <name>` — files that make up a feature
179
+
180
+ Resolves a Next.js `app/`-router feature to its file set, plus the external files that
181
+ depend on it.
182
+
183
+ ```
184
+ $ node repomap.mjs --feature api
185
+ feature "api": 11 files
186
+ app/(chat)/api/chat/route.ts
187
+ app/(chat)/api/chat/schema.ts
188
+ app/(chat)/api/document/route.ts
189
+ app/(chat)/api/history/route.ts
190
+ app/(chat)/api/messages/route.ts
191
+ app/(chat)/api/models/route.ts
192
+ app/(chat)/api/suggestions/route.ts
193
+ app/(chat)/api/vote/route.ts
194
+ app/(auth)/api/auth/guest/route.ts
195
+ app/(chat)/api/files/upload/route.ts
196
+ app/(chat)/api/chat/[id]/stream/route.ts
197
+ external dependents (0): —
198
+ ```
199
+
200
+ ### `--features` — list features by size
201
+
202
+ ```
203
+ $ node repomap.mjs --features
204
+ features (4):
205
+ api (11 files)
206
+ login (1 files)
207
+ register (1 files)
208
+ chat (1 files)
209
+ ```
210
+
211
+ ### `--hubs` — most important files (PageRank)
212
+
213
+ The files that matter most, ranked by PageRank importance (raw dependent degree shown
214
+ alongside).
215
+
216
+ ```
217
+ $ node repomap.mjs --hubs
218
+ repomap: 154 files (sha 2becdb4)
219
+ hubs (PageRank importance):
220
+ lib/utils.ts (deg 52, pr 0.105171)
221
+ lib/db/schema.ts (deg 21, pr 0.073744)
222
+ lib/types.ts (deg 23, pr 0.067589)
223
+ components/chat/artifact.tsx (deg 15, pr 0.036882)
224
+ components/chat/icons.tsx (deg 27, pr 0.035378)
225
+ lib/errors.ts (deg 9, pr 0.032787)
226
+ lib/db/queries.ts (deg 14, pr 0.030085)
227
+
228
+ ```
229
+
230
+ ### `--symbols [N]` — top ranked symbols (Aider-style)
231
+
232
+ The most important individual symbols across the repo, ranked by the identifier graph
233
+ (defaults to 30).
234
+
235
+ ```
236
+ $ node repomap.mjs --symbols 10
237
+ top 10 ranked symbols (Aider-style):
238
+ 0.109902 lib/utils.ts → cn (FunctionDeclaration)
239
+ 0.036013 lib/types.ts → ChatMessage (TypeAliasDeclaration)
240
+ 0.025686 components/chat/artifact.tsx → ArtifactKind (TypeAliasDeclaration)
241
+ 0.022461 lib/errors.ts → ChatbotError (ClassDeclaration)
242
+ 0.021068 lib/types.ts → CustomUIDataTypes (TypeAliasDeclaration)
243
+ 0.020872 lib/db/schema.ts → Document (TypeAliasDeclaration)
244
+ 0.020555 components/ai-elements/suggestion.tsx → Suggestion (VariableDeclaration)
245
+ 0.020555 lib/db/schema.ts → Suggestion (TypeAliasDeclaration)
246
+ 0.018124 lib/db/schema.ts → DBMessage (TypeAliasDeclaration)
247
+ 0.015034 lib/errors.ts → ErrorCode (TypeAliasDeclaration)
248
+ ```
249
+
250
+ ### `--map [--tokens N] [--focus <path>]` — token-budgeted ranked digest
251
+
252
+ The token-budgeted digest (Aider's killer feature): a ranked, files-and-symbols summary
253
+ that fits a token budget. Default budget is 8192 (1024 with `--focus`). `--focus <path>`
254
+ personalizes the ranking toward a file you're working on.
255
+
256
+ ```
257
+ $ node repomap.mjs --map --tokens 400
258
+ # repomap (154 files, sha 2becdb4) — focus: global, budget ~400 tok
259
+
260
+ lib/utils.ts:
261
+ cn (FunctionDeclaration)
262
+ generateUUID (FunctionDeclaration)
263
+
264
+ lib/types.ts:
265
+ ChatMessage (TypeAliasDeclaration)
266
+ CustomUIDataTypes (TypeAliasDeclaration)
267
+ ChatTools (TypeAliasDeclaration)
268
+ Attachment (TypeAliasDeclaration)
269
+
270
+ components/chat/artifact.tsx:
271
+ ArtifactKind (TypeAliasDeclaration)
272
+ UIArtifact (TypeAliasDeclaration)
273
+ Artifact (VariableDeclaration)
274
+
275
+ lib/errors.ts:
276
+ ChatbotError (ClassDeclaration)
277
+ ErrorCode (TypeAliasDeclaration)
278
+
279
+ lib/db/schema.ts:
280
+ Document (TypeAliasDeclaration)
281
+ Suggestion (TypeAliasDeclaration)
282
+ DBMessage (TypeAliasDeclaration)
283
+
284
+ # ~387 tokens (14 files shown)
285
+ ```
286
+
287
+ Focused on a working file — the ranking re-centers on what `lib/db/queries.ts` actually touches:
288
+
289
+ ```
290
+ $ node repomap.mjs --map --focus lib/db/queries.ts --tokens 350
291
+ # repomap (154 files, sha 2becdb4) — focus: lib/db/queries.ts, budget ~350 tok
292
+
293
+ lib/utils.ts:
294
+ cn (FunctionDeclaration)
295
+ generateUUID (FunctionDeclaration)
296
+ getDocumentTimestampByIndex (FunctionDeclaration)
297
+ fetcher (VariableDeclaration)
298
+ getTextFromMessage (FunctionDeclaration)
299
+ convertToUIMessages (FunctionDeclaration)
300
+ fetchWithErrorHandlers (FunctionDeclaration)
301
+ sanitizeText (FunctionDeclaration)
302
+
303
+ lib/db/schema.ts:
304
+ DBMessage (TypeAliasDeclaration)
305
+ Suggestion (TypeAliasDeclaration)
306
+ Document (TypeAliasDeclaration)
307
+ Chat (TypeAliasDeclaration)
308
+ User (TypeAliasDeclaration)
309
+ chat (VariableDeclaration)
310
+ document (VariableDeclaration)
311
+ message (VariableDeclaration)
312
+
313
+ lib/errors.ts:
314
+ ChatbotError (ClassDeclaration)
315
+ ErrorCode (TypeAliasDeclaration)
316
+
317
+ # ~324 tokens (8 files shown)
318
+ ```
319
+
320
+ ### `--print` — full map as JSON
321
+
322
+ Dumps the cached map (`hubs`, `features`, `rankedSymbols`, `files`) as one JSON object —
323
+ for piping into other tools.
324
+
325
+ ```
326
+ $ node repomap.mjs --print | jq '.hubs[0]'
327
+ "lib/utils.ts (deg 52, pr 0.105171)"
328
+ ```
329
+
330
+ ---
331
+
332
+ ## The agent loop (the actual point)
333
+
334
+ A repo map only helps if the agent uses it. agentmap ships two hooks (in [`./hooks/`](./hooks/))
335
+ that close the loop: the map refreshes itself after every commit, and the agent gets nudged
336
+ to query the map before it serial-greps.
337
+
338
+ ### 1. Auto-refresh on commit
339
+
340
+ [`hooks/post-commit`](./hooks/post-commit) rebuilds `.claude/repomap.json` after each
341
+ commit, detached + silenced so it never slows the commit. It skips during
342
+ rebase/merge/cherry-pick and no-ops if Node is missing.
343
+
344
+ ```bash
345
+ # from your repo root
346
+ cp hooks/post-commit .git/hooks/post-commit
347
+ chmod +x .git/hooks/post-commit
348
+ ```
349
+
350
+ The hook auto-locates the builder: a local `repomap.mjs`, then `scripts/repomap.mjs`, then
351
+ the installed `agentmap` binary, then `npx @raymondchins/agentmap`.
352
+
353
+ ### 2. Force the agent to use it — `PreToolUse` hook
354
+
355
+ [`hooks/repomap-nudge.mjs`](./hooks/repomap-nudge.mjs) is a **non-blocking** `PreToolUse(Grep)`
356
+ hook for Claude Code. When a `Grep` looks like a dependency / who-imports / component-usage /
357
+ reuse search, it injects a reminder steering the agent to `agentmap --any` first. It never
358
+ denies the grep, and stays silent for raw-string / Tailwind-class / lowercase-HTML-tag
359
+ sweeps — so it's high-signal, not nagging.
360
+
361
+ Wire it up in `.claude/settings.json`:
362
+
363
+ ```json
364
+ {
365
+ "hooks": {
366
+ "PreToolUse": [
367
+ {
368
+ "matcher": "Grep",
369
+ "hooks": [
370
+ { "type": "command", "command": "node ./hooks/repomap-nudge.mjs" }
371
+ ]
372
+ }
373
+ ]
374
+ }
375
+ }
376
+ ```
377
+
378
+ That's the "forced to use it" in the tagline: the map stays current on its own, and the
379
+ agent is steered to it the moment it reaches for a dependency-shaped grep.
380
+
381
+ ---
382
+
383
+ ## Scope & limitations
384
+
385
+ Honesty first — this is deliberately a small, sharp tool, not a universal code-graph.
386
+
387
+ - **TS/JS only, by design.** Built on `ts-morph`. No Python, Go, Rust, etc. If your repo
388
+ isn't TypeScript/JavaScript, use a tree-sitter-based tool instead.
389
+ - **File-level import graph, not a full reference graph.** Edges come from static
390
+ `import` / re-export declarations and the named symbols crossing them. It does **not**
391
+ do call-site or full reference resolution — `--relates` tells you which files import a
392
+ module, not every line that calls a given function.
393
+ - **PageRank + symbol ranking are real and implemented** (damping 0.85, deterministic
394
+ power iteration; personalized variants for `--relates` and `--map --focus`). The symbol
395
+ ranking is a faithful port of Aider's identifier-graph approach (credit:
396
+ [Aider](https://github.com/Aider-AI/aider), Apache-2.0).
397
+ - **Feature detection assumes the Next.js `app/` router.** `--feature` / `--features`
398
+ derive features from the first real route segment under `app/` (or `src/app/`), skipping
399
+ route groups `(...)`, dynamic `[...]`, and parallel `@...` segments. Repos without an
400
+ `app/` directory simply report zero features — every other command still works.
401
+ - **Token counts are estimates** (`chars / 4`), not a real BPE tokenizer. Treat
402
+ `--map`/`--tokens` budgets as approximate (±10%).
403
+ - The PreToolUse hook is **Claude Code-specific** (it speaks Claude Code's hook JSON). The
404
+ post-commit hook is generic git.
405
+
406
+ ---
407
+
408
+ ## Benchmark
409
+
410
+ Against the public **154-file Next.js repo (vercel/ai-chatbot, sha 2becdb4)**:
411
+
412
+ - **70.3% fewer tokens across 3 scenarios (5598 → 1664 tokens)**:
413
+ - **A. Understand file deps** (`lib/utils.ts`): baseline 583 tok → agentmap 517 tok (11.3% saved)
414
+ - **B. Find symbol** (`ChatMessage`): baseline 1950 tok → agentmap 20 tok (99% saved)
415
+ - **C. Repo overview** (tree + 3 hub files): baseline 3065 tok → agentmap 1127 tok (63.2% saved)
416
+ - Cold build (parse + PageRank + symbol graph): **~1.2s**. Warm cached query (`--hubs`,
417
+ clean tree): **~0.2s**.
418
+
419
+ Caveat: these numbers measure context efficiency (tokens sent to the model per task), **not**
420
+ end-to-end retrieval accuracy — there's no "did the agent fix the bug faster" eval yet.
421
+
422
+ Full methodology, commands, and caveats: **[`./benchmark/RESULTS.md`](./benchmark/RESULTS.md)**.
423
+
424
+ ---
425
+
426
+ ## Contributing
427
+
428
+ Issues and PRs welcome. High-value directions:
429
+
430
+ - An end-to-end retrieval/accuracy eval (the benchmark is context-efficiency only today).
431
+ - A real tokenizer behind the `--map` budget.
432
+ - Hardening feature detection for non-`app/`-router layouts.
433
+
434
+ Keep the dependency footprint minimal — `ts-morph` is the only runtime dep, and that's a
435
+ feature.
436
+
437
+ ## License
438
+
439
+ [MIT](./LICENSE). Symbol-ranking algorithm credit: [Aider](https://github.com/Aider-AI/aider) (Apache-2.0).
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@raymondchins/agentmap",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "The repo map your coding agent is forced to use. A queryable, ranked ts-morph code-relationship map for TypeScript/JavaScript repos — PageRank hubs, Aider-style symbol ranking, a token-budgeted digest, and a single --any router (file → symbol → feature → live git-grep), wired into the agent loop via post-commit auto-refresh and a PreToolUse hook.",
8
+ "type": "module",
9
+ "bin": {
10
+ "agentmap": "./repomap.mjs"
11
+ },
12
+ "main": "repomap.mjs",
13
+ "files": [
14
+ "repomap.mjs"
15
+ ],
16
+ "scripts": {
17
+ "map": "node repomap.mjs"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "dependencies": {
23
+ "ts-morph": "28.0.0"
24
+ },
25
+ "keywords": [
26
+ "repo-map",
27
+ "repomap",
28
+ "code-map",
29
+ "ts-morph",
30
+ "typescript",
31
+ "javascript",
32
+ "pagerank",
33
+ "static-analysis",
34
+ "ast",
35
+ "code-graph",
36
+ "dependency-graph",
37
+ "ai-agent",
38
+ "coding-agent",
39
+ "llm",
40
+ "claude",
41
+ "aider",
42
+ "context"
43
+ ],
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/raymondchins/agentmap.git"
47
+ },
48
+ "homepage": "https://github.com/raymondchins/agentmap#readme",
49
+ "bugs": {
50
+ "url": "https://github.com/raymondchins/agentmap/issues"
51
+ },
52
+ "author": "Raymond Surya Chin",
53
+ "license": "MIT"
54
+ }
package/repomap.mjs ADDED
@@ -0,0 +1,461 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: MIT
3
+ // ============================================================================
4
+ // agentmap — the repo map your coding agent is *forced* to use.
5
+ //
6
+ // A ts-morph code-relationship map for TypeScript/JavaScript repos. Unlike
7
+ // one-shot "pack the repo into a prompt" tools, this is a QUERYABLE, RANKED
8
+ // map: PageRank importance (ported from Aider's repo map), Aider-style
9
+ // symbol ranking, a token-budgeted `--map` digest, and a single `--any`
10
+ // router (file → symbol → feature → live git-grep) — wired into the agent
11
+ // loop via a post-commit auto-refresh + a PreToolUse hook.
12
+ //
13
+ // Near-zero deps (ts-morph only). Runs in the target repo's cwd.
14
+ // Algorithm credit: Aider's repo map (Apache-2.0) — github.com/Aider-AI/aider
15
+ // ============================================================================
16
+ import { Project } from "ts-morph";
17
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
18
+ import { execSync, execFileSync } from "node:child_process";
19
+
20
+ const MAP = ".claude/repomap.json";
21
+ const SCHEMA_VERSION = 2;
22
+ const sh = (c) => { try { return execSync(c, { stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } catch { return ""; } };
23
+
24
+ // Live content search for the --any fallback. `git grep` over tracked +
25
+ // untracked files (skips gitignored paths like node_modules). Reads DISK, so
26
+ // never stale. -F = fixed-string so literals like "bg-[#faf8f2]" aren't regex.
27
+ const contentSearch = (q) => {
28
+ try {
29
+ return execFileSync("git", ["grep", "-F", "--untracked", "-n", "-i", "-I", "-e", q, "--", ".", ":!.claude/repomap.json"], { encoding: "utf8" }).trim();
30
+ } catch { return ""; }
31
+ };
32
+ const currentSha = () => sh("git rev-parse --short HEAD");
33
+ const dirtyCount = () =>
34
+ sh("git status --porcelain").split("\n").filter(Boolean).filter((l) => {
35
+ let p = l.slice(3); // strip "XY " status prefix
36
+ if (p.includes(" -> ")) p = p.split(" -> ").pop(); // rename: keep the new path
37
+ p = p.replace(/^"|"$/g, ""); // unquote space/special paths
38
+ return /\.(ts|tsx|mjs|cjs|jsx|js)$/.test(p);
39
+ }).length;
40
+ const tokEst = (s) => Math.ceil((s || "").length / 4); // rough chars/4 estimate
41
+
42
+ // Feature = first real route segment under app/ (or src/app/), skipping route
43
+ // groups (parens), dynamic segments ([id]) and parallel routes (@slot).
44
+ function featureOf(path) {
45
+ const m = path.match(/(?:^|.*\/)(?:src\/)?app\/(.+)/);
46
+ if (!m) return null;
47
+ for (const p of m[1].split("/").slice(0, -1)) {
48
+ if (p.startsWith("(") || p.startsWith("[") || p.startsWith("@")) continue;
49
+ return p;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Personalized PageRank — dependency-free power iteration. Deterministic
56
+ // (stable node order, no PRNG). Edges = [{from, to, weight}]. Rank flows
57
+ // from→to, so with importer→imported edges, heavily-imported hubs rank high.
58
+ // Dangling-node mass + teleport both go to the personalization vector
59
+ // (matches Aider's `dangling=personalization`). Returns { node: score }.
60
+ // ---------------------------------------------------------------------------
61
+ function pagerank(nodes, edges, { personalization = null, damping = 0.85, tol = 1e-6, maxIter = 100 } = {}) {
62
+ const N = nodes.length;
63
+ if (N === 0) return {};
64
+ const idx = new Map(nodes.map((n, i) => [n, i]));
65
+ const outW = new Float64Array(N);
66
+ const adj = Array.from({ length: N }, () => []);
67
+ for (const e of edges) {
68
+ const a = idx.get(e.from), b = idx.get(e.to);
69
+ if (a === undefined || b === undefined || a === b) continue; // skip self-loops
70
+ const w = e.weight > 0 ? e.weight : 1;
71
+ adj[a].push([b, w]); outW[a] += w;
72
+ }
73
+ // teleport vector p (normalized personalization, or uniform)
74
+ const p = new Float64Array(N);
75
+ if (personalization) {
76
+ let s = 0;
77
+ for (const [k, v] of Object.entries(personalization)) {
78
+ const i = idx.get(k);
79
+ if (i !== undefined && v > 0) { p[i] = v; s += v; }
80
+ }
81
+ if (s === 0) p.fill(1 / N); else for (let i = 0; i < N; i++) p[i] /= s;
82
+ } else p.fill(1 / N);
83
+ let r = Float64Array.from(p);
84
+ for (let iter = 0; iter < maxIter; iter++) {
85
+ let dangling = 0;
86
+ for (let i = 0; i < N; i++) if (outW[i] === 0) dangling += r[i];
87
+ const next = new Float64Array(N);
88
+ for (let i = 0; i < N; i++) next[i] = (1 - damping) * p[i] + damping * dangling * p[i];
89
+ for (let i = 0; i < N; i++) {
90
+ if (outW[i] === 0) continue;
91
+ const ri = damping * r[i];
92
+ for (const [j, w] of adj[i]) next[j] += ri * (w / outW[i]);
93
+ }
94
+ let diff = 0;
95
+ for (let i = 0; i < N; i++) diff += Math.abs(next[i] - r[i]);
96
+ r = next;
97
+ if (diff < tol) break;
98
+ }
99
+ const out = {};
100
+ for (let i = 0; i < N; i++) out[nodes[i]] = r[i];
101
+ return out;
102
+ }
103
+
104
+ // Aider-style identifier edge-weight multipliers. `mentioned` = focus/query
105
+ // idents (boosted). Rarity is approximated by the >5-definers penalty.
106
+ function identMul(ident, defineCount, mentioned) {
107
+ let mul = 1.0;
108
+ const hasAlpha = /[a-zA-Z]/.test(ident);
109
+ const isSnake = ident.includes("_") && hasAlpha;
110
+ const isKebab = ident.includes("-") && hasAlpha;
111
+ const isCamel = /[a-z]/.test(ident) && /[A-Z]/.test(ident);
112
+ if (mentioned && mentioned.has(ident)) mul *= 10;
113
+ if ((isSnake || isKebab || isCamel) && ident.length >= 8) mul *= 10;
114
+ if (ident.startsWith("_")) mul *= 0.1;
115
+ if (defineCount > 5) mul *= 0.1;
116
+ return mul;
117
+ }
118
+
119
+ // Construct a ts-morph Project robustly: use tsconfig.json when present + valid;
120
+ // else (missing / malformed / solution-style references that index 0 files) fall
121
+ // back to broad source globs so the tool degrades gracefully instead of crashing.
122
+ function makeProject() {
123
+ let project;
124
+ if (existsSync("tsconfig.json")) {
125
+ try { project = new Project({ tsConfigFilePath: "tsconfig.json" }); }
126
+ catch { project = new Project({ compilerOptions: { allowJs: true } }); }
127
+ } else {
128
+ project = new Project({ compilerOptions: { allowJs: true } });
129
+ }
130
+ // tsconfig `include` usually omits build/pipeline scripts — add by path.
131
+ project.addSourceFilesAtPaths(["scripts/**/*.mjs", "scripts/**/*.cjs", "scripts/**/*.js", "*.mjs", "*.cjs"]);
132
+ // Fallback: nothing indexed (no / empty / references-only tsconfig) → broad globs.
133
+ if (project.getSourceFiles().length === 0)
134
+ project.addSourceFilesAtPaths([
135
+ "src/**/*.{ts,tsx,js,jsx}", "app/**/*.{ts,tsx,js,jsx}",
136
+ "components/**/*.{ts,tsx,js,jsx}", "lib/**/*.{ts,tsx,js,jsx}",
137
+ "pages/**/*.{ts,tsx,js,jsx}", "*.{ts,tsx,js,jsx}",
138
+ ]);
139
+ return project;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // build() — parse the repo, extract file imports/exports (+ which named
144
+ // symbols cross each edge), compute file PageRank, run the Aider-style
145
+ // identifier graph to rank individual symbols, and persist repomap.json.
146
+ // ---------------------------------------------------------------------------
147
+ function build() {
148
+ const project = makeProject();
149
+ const cwd = process.cwd().replace(/\\/g, "/");
150
+ const rel = (p) => p.replace(cwd + "/", "");
151
+ const files = {}, dependents = {}, features = {};
152
+
153
+ for (const sf of project.getSourceFiles()) {
154
+ const path = rel(sf.getFilePath());
155
+ if (path.includes("node_modules") || path.includes(".next")) continue;
156
+ const exports = [...sf.getExportedDeclarations()].map(([name, d]) => ({
157
+ name: name === "default" ? (d[0]?.getName?.() ?? "default") : name,
158
+ kind: d[0]?.getKindName?.() ?? "?",
159
+ }));
160
+ // Dependency edges from static imports + re-export barrels, with the set
161
+ // of named symbols crossing each edge (used for edge weights + the ident
162
+ // graph). importedSymbols[targetPath] = [names...].
163
+ const importedSymbols = {};
164
+ const addEdge = (tp, names) => {
165
+ if (tp.includes("node_modules")) return;
166
+ (importedSymbols[tp] ??= []).push(...names);
167
+ };
168
+ for (const imp of sf.getImportDeclarations()) {
169
+ const t = imp.getModuleSpecifierSourceFile();
170
+ if (!t) continue;
171
+ const names = imp.getNamedImports().map((n) => n.getName());
172
+ if (imp.getDefaultImport()) names.push("default"); // canonical: local alias never matches the export name
173
+ if (imp.getNamespaceImport()) names.push("*");
174
+ addEdge(rel(t.getFilePath()), names.length ? names : ["*"]);
175
+ }
176
+ for (const exp of sf.getExportDeclarations()) {
177
+ const t = exp.getModuleSpecifierSourceFile();
178
+ if (!t) continue;
179
+ addEdge(rel(t.getFilePath()), exp.getNamedExports().map((n) => n.getName()));
180
+ }
181
+ const imports = Object.keys(importedSymbols);
182
+ for (const tp of imports) (dependents[tp] ??= []).push(path);
183
+ files[path] = { exports, imports, importedSymbols };
184
+ const feat = featureOf(path);
185
+ if (feat) (features[feat] ??= []).push(path);
186
+ }
187
+ for (const p in files) files[p].dependents = dependents[p] ?? [];
188
+
189
+ // --- File PageRank: edges importer→imported, weighted by # symbols crossed.
190
+ const nodes = Object.keys(files);
191
+ const fileEdges = [];
192
+ for (const [p, f] of Object.entries(files))
193
+ for (const tp of f.imports)
194
+ if (files[tp]) fileEdges.push({ from: p, to: tp, weight: (f.importedSymbols[tp] || []).length || 1 });
195
+ const fileRank = pagerank(nodes, fileEdges);
196
+ for (const p of nodes) files[p].pagerank = +(fileRank[p] || 0).toFixed(6);
197
+
198
+ // --- Symbol ranking (Aider-style): identifier graph from named imports.
199
+ const rankedSymbols = rankSymbols(files, null);
200
+
201
+ // hubs: now PageRank-ranked (raw dependent count shown alongside).
202
+ const hubs = nodes
203
+ .map((p) => [p, files[p].pagerank, files[p].dependents.length])
204
+ .sort((a, b) => b[1] - a[1])
205
+ .slice(0, 15)
206
+ .map(([p, pr, deg]) => `${p} (deg ${deg}, pr ${pr})`);
207
+
208
+ const out = {
209
+ schema: SCHEMA_VERSION, generatedSha: currentSha(), dirty: dirtyCount(), fileCount: nodes.length,
210
+ hubs, features, rankedSymbols: rankedSymbols.slice(0, 80), files,
211
+ };
212
+ mkdirSync(".claude", { recursive: true });
213
+ writeFileSync(MAP, JSON.stringify(out));
214
+ return out;
215
+ }
216
+
217
+ // Build the Aider-style identifier graph from the file map and return a
218
+ // ranked list of { file, name, kind, rank }. `focus` (Set of paths) +
219
+ // derived mentioned idents personalize the ranking when given.
220
+ function rankSymbols(files, focus) {
221
+ const defines = new Map(); // ident -> Set(file)
222
+ const references = new Map(); // ident -> [file...] (multiplicity)
223
+ const definition = new Map(); // `${file}|${ident}` -> {file, name, kind}
224
+ for (const [p, f] of Object.entries(files)) {
225
+ for (const e of f.exports) {
226
+ (defines.get(e.name) ?? defines.set(e.name, new Set()).get(e.name)).add(p);
227
+ definition.set(`${p}|${e.name}`, { file: p, name: e.name, kind: e.kind });
228
+ }
229
+ }
230
+ for (const [p, f] of Object.entries(files))
231
+ for (const tp of f.imports)
232
+ for (const name of f.importedSymbols[tp] || [])
233
+ if (name !== "*" && name !== "default") (references.get(name) ?? references.set(name, []).get(name)).push(p);
234
+
235
+ // mentioned idents from focus files' exports + their basenames
236
+ let mentioned = null;
237
+ if (focus && focus.size) {
238
+ mentioned = new Set();
239
+ for (const p of focus) {
240
+ for (const e of (files[p]?.exports || [])) mentioned.add(e.name);
241
+ const base = p.split("/").pop().replace(/\.[^.]+$/, "");
242
+ mentioned.add(base);
243
+ }
244
+ }
245
+
246
+ const nodes = Object.keys(files);
247
+ const edges = [];
248
+ for (const ident of defines.keys()) {
249
+ if (!references.has(ident)) continue;
250
+ const mul = identMul(ident, defines.get(ident).size, mentioned);
251
+ const counts = new Map();
252
+ for (const refFile of references.get(ident)) counts.set(refFile, (counts.get(refFile) || 0) + 1);
253
+ for (const [refFile, n] of counts)
254
+ for (const defFile of defines.get(ident)) {
255
+ if (refFile === defFile) continue;
256
+ let useMul = mul;
257
+ if (focus && focus.has(refFile)) useMul *= 50;
258
+ edges.push({ from: refFile, to: defFile, weight: useMul * Math.sqrt(n), ident });
259
+ }
260
+ }
261
+ // personalization seeds: focus files + files whose name matches a mention
262
+ let pers = null;
263
+ if (focus && focus.size) {
264
+ pers = {};
265
+ const unit = 100 / nodes.length;
266
+ for (const p of nodes) {
267
+ let v = 0;
268
+ if (focus.has(p)) v += unit;
269
+ const parts = new Set([...p.split("/"), p.split("/").pop(), p.split("/").pop().replace(/\.[^.]+$/, "")]);
270
+ if (mentioned && [...parts].some((x) => mentioned.has(x))) v += unit;
271
+ if (v > 0) pers[p] = v;
272
+ }
273
+ if (!Object.keys(pers).length) pers = null;
274
+ }
275
+ const rank = pagerank(nodes, edges, pers ? { personalization: pers } : {});
276
+
277
+ // redistribute each file's rank across its out-edges onto (defFile, ident)
278
+ const out = new Map(); // `${file}|${ident}` -> total weight
279
+ const totalW = new Map();
280
+ for (const e of edges) totalW.set(e.from, (totalW.get(e.from) || 0) + e.weight);
281
+ for (const e of edges) {
282
+ const share = (rank[e.from] || 0) * e.weight / (totalW.get(e.from) || 1);
283
+ const k = `${e.to}|${e.ident}`;
284
+ out.set(k, (out.get(k) || 0) + share);
285
+ }
286
+ return [...out.entries()]
287
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
288
+ .map(([k, r]) => ({ ...(definition.get(k) || { file: k.slice(0, k.lastIndexOf("|")), name: k.slice(k.lastIndexOf("|") + 1), kind: "?" }), rank: +r.toFixed(6) }))
289
+ .filter((d) => !(focus && focus.has(d.file)));
290
+ }
291
+
292
+ // Serve the cached map only when provably current: same HEAD, known schema,
293
+ // clean tree. A dirty tree REBUILDS from disk so queries reflect in-flight edits.
294
+ function ensureFresh() {
295
+ const sha = currentSha();
296
+ if (existsSync(MAP)) {
297
+ try {
298
+ const cached = JSON.parse(readFileSync(MAP, "utf8"));
299
+ // Trust cache only if: same HEAD, known schema, it was built CLEAN
300
+ // (cached.dirty === 0 — never trust a map built mid-edit, even after a
301
+ // revert returns the tree to clean), AND the tree is clean right now.
302
+ if (sha && cached.generatedSha === sha && cached.schema === SCHEMA_VERSION && cached.dirty === 0 && dirtyCount() === 0) return cached;
303
+ } catch {}
304
+ }
305
+ return build();
306
+ }
307
+
308
+ // Resolve a query to a file key: exact path → unique basename → unique substring.
309
+ function resolveFile(keys, filesObj, q) {
310
+ if (filesObj[q]) return { key: q };
311
+ const base = keys.filter((k) => k.split("/").pop() === q);
312
+ if (base.length === 1) return { key: base[0] };
313
+ const subs = keys.filter((k) => k.toLowerCase().includes(q.toLowerCase()));
314
+ if (subs.length === 1) return { key: subs[0] };
315
+ return { key: null, candidates: subs };
316
+ }
317
+
318
+ function fileBlock(key, f) {
319
+ console.log(`exports (${f.exports.length}): ${f.exports.map((e) => `${e.name}(${e.kind})`).join(", ") || "—"}`);
320
+ console.log(`imports (${f.imports.length}): ${f.imports.join(", ") || "—"}`);
321
+ console.log(`dependents (${f.dependents.length}): ${f.dependents.join(", ") || "—"}`);
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // CLI
326
+ // ---------------------------------------------------------------------------
327
+ const args = process.argv.slice(2);
328
+ const has = (f) => args.includes(f);
329
+ const arg = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : undefined; };
330
+
331
+ if (has("--any")) {
332
+ // Unified router: cached structure (file → symbol → feature) then a LIVE
333
+ // git-grep fallback for data/copy/string-literals the graph never indexes.
334
+ const raw = arg("--any") || "";
335
+ if (!raw) { console.log('--any needs a query, e.g. `--any PremiumCard` or `--any "multi-modal"`'); }
336
+ else {
337
+ const q = raw.toLowerCase();
338
+ const data = ensureFresh();
339
+ const keys = Object.keys(data.files);
340
+ const { key: fileKey, candidates } = resolveFile(keys, data.files, raw);
341
+ const symHits = [];
342
+ for (const [path, f] of Object.entries(data.files))
343
+ for (const e of f.exports)
344
+ if (e.name.toLowerCase().includes(q)) symHits.push(` ${path} → ${e.name} (${e.kind})`);
345
+ const featNames = Object.keys(data.features || {}).filter((k) => k.toLowerCase().includes(q));
346
+ if (fileKey) {
347
+ console.log(`[structure:file] ${fileKey} (pr ${data.files[fileKey].pagerank ?? "—"})`);
348
+ fileBlock(fileKey, data.files[fileKey]);
349
+ } else if (symHits.length || featNames.length) {
350
+ console.log(`[structure] ${symHits.length} symbol, ${featNames.length} feature match for "${raw}"`);
351
+ if (symHits.length) console.log(symHits.join("\n"));
352
+ if (featNames.length) console.log("features: " + featNames.map((n) => `${n} (${data.features[n].length})`).join(", "));
353
+ } else if (candidates && candidates.length > 1) {
354
+ console.log(`[structure] "${raw}" matched ${candidates.length} files — narrow it:`);
355
+ for (const k of candidates) console.log(` ${k}`);
356
+ } else {
357
+ const res = contentSearch(raw);
358
+ if (!res) console.log(`[content] 0 match for "${raw}" (git grep, tracked + untracked)`);
359
+ else {
360
+ const lines = res.split("\n");
361
+ console.log(`[content] ${lines.length} line${lines.length > 1 ? "s" : ""}${lines.length > 40 ? " (showing 40)" : ""}:`);
362
+ console.log(lines.slice(0, 40).join("\n"));
363
+ }
364
+ }
365
+ }
366
+ } else if (has("--find")) {
367
+ const raw = arg("--find") || "", q = raw.toLowerCase();
368
+ const data = ensureFresh();
369
+ const hits = [];
370
+ for (const [path, f] of Object.entries(data.files))
371
+ for (const e of f.exports)
372
+ if (e.name.toLowerCase().includes(q)) hits.push(` ${path} → ${e.name} (${e.kind})`);
373
+ console.log(`find "${raw}": ${hits.length} match`);
374
+ if (hits.length) console.log(hits.join("\n"));
375
+ } else if (has("--relates")) {
376
+ const q = arg("--relates") || "";
377
+ const data = ensureFresh();
378
+ const keys = Object.keys(data.files);
379
+ const { key, candidates } = resolveFile(keys, data.files, q);
380
+ if (!key) {
381
+ if (candidates && candidates.length > 1) { console.log(`relates: "${q}" matched ${candidates.length} files — narrow it:`); for (const k of candidates) console.log(` ${k}`); }
382
+ else console.log(`relates: no file matching "${q}"`);
383
+ } else {
384
+ const f = data.files[key];
385
+ console.log(`relates: ${key} (pr ${f.pagerank ?? "—"})`);
386
+ fileBlock(key, f);
387
+ // query-focused relevance: personalized PageRank (random-walk-with-restart)
388
+ // on a BIDIRECTIONAL graph → files most related to the target, transitively.
389
+ const biEdges = [];
390
+ for (const [p, ff] of Object.entries(data.files))
391
+ for (const tp of ff.imports) if (data.files[tp]) { biEdges.push({ from: p, to: tp, weight: 1 }); biEdges.push({ from: tp, to: p, weight: 1 }); }
392
+ const rel = pagerank(keys, biEdges, { personalization: { [key]: 1 } });
393
+ const top = Object.entries(rel).filter(([k]) => k !== key).sort((a, b) => b[1] - a[1]).slice(0, 10);
394
+ console.log(`related (random-walk relevance):`);
395
+ for (const [k, r] of top) console.log(` ${k} (${r.toFixed(4)})`);
396
+ }
397
+ } else if (has("--map")) {
398
+ // Token-budgeted ranked digest (Aider's killer feature). --focus <path>
399
+ // personalizes toward a file; default budget 1024, ×8 with no focus.
400
+ const data = ensureFresh();
401
+ const focusArg = arg("--focus");
402
+ const tk = parseInt(arg("--tokens") ?? "", 10);
403
+ const budget = Number.isFinite(tk) && tk > 0 ? tk : (focusArg ? 1024 : 8192);
404
+ let ranked = data.rankedSymbols || [];
405
+ let focusLabel = "global";
406
+ if (focusArg) {
407
+ const { key, candidates } = resolveFile(Object.keys(data.files), data.files, focusArg);
408
+ if (key) { ranked = rankSymbols(data.files, new Set([key])); focusLabel = key; }
409
+ else console.error(`# warning: --focus "${focusArg}" matched ${(candidates && candidates.length) || 0} files — using global ranking`);
410
+ }
411
+ // Fallback for default-export-heavy repos (sparse named-symbol graph): build
412
+ // the digest from file PageRank so --map is never empty.
413
+ if (!ranked.length)
414
+ ranked = Object.entries(data.files)
415
+ .sort((a, b) => (b[1].pagerank || 0) - (a[1].pagerank || 0))
416
+ .flatMap(([file, f]) => (f.exports || []).map((e) => ({ file, name: e.name, kind: e.kind, rank: f.pagerank || 0 })));
417
+ console.log(`# repomap (${data.fileCount} files, sha ${data.generatedSha}) — focus: ${focusLabel}, budget ~${budget} tok`);
418
+ let used = 0, shown = 0;
419
+ const byFile = new Map();
420
+ for (const s of ranked) { if (!byFile.has(s.file)) byFile.set(s.file, []); byFile.get(s.file).push(s); }
421
+ for (const [file, syms] of byFile) {
422
+ const line = `\n${file}:\n` + syms.slice(0, 8).map((s) => ` ${s.name} (${s.kind})`).join("\n");
423
+ const t = tokEst(line);
424
+ if (used + t > budget) break;
425
+ used += t; shown++; console.log(line);
426
+ }
427
+ console.log(`\n# ~${used} tokens (${shown} files shown)`);
428
+ } else if (has("--symbols")) {
429
+ const data = ensureFresh();
430
+ const sn = parseInt(arg("--symbols") ?? "", 10); const n = Number.isFinite(sn) && sn > 0 ? sn : 30;
431
+ console.log(`top ${n} ranked symbols (Aider-style):`);
432
+ for (const s of (data.rankedSymbols || []).slice(0, n)) console.log(` ${s.rank} ${s.file} → ${s.name} (${s.kind})`);
433
+ } else if (has("--feature")) {
434
+ const raw = arg("--feature") || "", q = raw.toLowerCase();
435
+ const data = ensureFresh();
436
+ const name = Object.keys(data.features).find((k) => k.toLowerCase() === q) || Object.keys(data.features).find((k) => k.toLowerCase().includes(q));
437
+ if (!name) console.log(`feature: no match for "${raw}" — run --features to list them.`);
438
+ else {
439
+ const fl = data.features[name], set = new Set(fl), exts = new Set();
440
+ for (const p of fl) for (const dep of (data.files[p]?.dependents || [])) if (!set.has(dep)) exts.add(dep);
441
+ console.log(`feature "${name}": ${fl.length} files`);
442
+ for (const p of fl) console.log(` ${p}`);
443
+ console.log(`external dependents (${exts.size}): ${[...exts].join(", ") || "—"}`);
444
+ }
445
+ } else if (has("--features")) {
446
+ const data = ensureFresh();
447
+ const list = Object.entries(data.features).map(([k, v]) => [k, v.length]).sort((a, b) => b[1] - a[1]);
448
+ console.log(`features (${list.length}):`);
449
+ for (const [k, n] of list) console.log(` ${k} (${n} files)`);
450
+ } else if (has("--hubs")) {
451
+ const data = ensureFresh();
452
+ console.log(`repomap: ${data.fileCount} files (sha ${data.generatedSha})`);
453
+ console.log("hubs (PageRank importance):");
454
+ for (const h of data.hubs) console.log(` ${h}`);
455
+ } else if (has("--print")) {
456
+ const data = ensureFresh();
457
+ console.log(JSON.stringify({ hubs: data.hubs, features: data.features, rankedSymbols: data.rankedSymbols, files: data.files }));
458
+ } else {
459
+ const out = build();
460
+ console.log(`repomap: ${out.fileCount} files | ${Object.keys(out.features).length} features | top hub: ${out.hubs[0] || "—"}`);
461
+ }