@tangle-network/agent-docs 0.1.0 → 0.2.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/README.md CHANGED
@@ -65,6 +65,20 @@ export default {
65
65
  - **Private repos:** the deterministic core runs fully offline — no network, no LLM, no external service. This is the whole tool for a private repo.
66
66
  - **Public repos:** add `--deepwiki` to pull the free, no-auth DeepWiki wiki + Q&A for the repo. If the repo isn't indexed yet, the augment is skipped and the deterministic map is unaffected. (Index a public repo once at `deepwiki.com/<owner>/<repo>`.)
67
67
 
68
+ ## Filling the JSDoc gap (`suggest`)
69
+
70
+ The `llms.txt` / API pages are only as rich as your JSDoc — and most codebases leave the majority of exports undocumented. `agent-docs suggest` closes that gap with a cheap model, **at authoring time**:
71
+
72
+ ```bash
73
+ export TANGLE_API_KEY=... # any OpenAI-compatible endpoint via ROUTER_URL (default: Tangle router)
74
+ agent-docs suggest --subpath chat-routes # draft JSDoc for undocumented exports, write into source
75
+ agent-docs suggest --dry-run # draft + print, write nothing
76
+ ```
77
+
78
+ It finds every exported declaration that has no JSDoc, drafts a one-line summary from the signature + source, and inserts it above the declaration. Review the diff, commit, then re-run `agent-docs` — the new comments flow into the gated `llms.txt` **deterministically**.
79
+
80
+ This is the tool's only LLM path, and it is deliberately at authoring time, not generation time: the model improves your **source comments**; the generated docs stay type-derived and never contain raw model text, so `--check` keeps working. Defaults to `gpt-4.1-mini` via the Tangle router; override with `--model`.
81
+
68
82
  ## Requirements
69
83
 
70
84
  Node ≥ 20, and `typescript` resolvable from the target repo (it's a peer dependency — every TS repo has it). agent-docs carries no runtime dependencies of its own.
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
- import { existsSync as existsSync4, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync4, rmSync, writeFileSync } from "fs";
3
- import { basename, dirname as dirname3, join as join5, resolve as resolve2 } from "path";
2
+ import { existsSync as existsSync4, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync2 } from "fs";
3
+ import { basename, dirname as dirname3, join as join5, resolve as resolve3 } from "path";
4
4
 
5
5
  // src/config.ts
6
6
  import { execFileSync } from "child_process";
@@ -478,6 +478,160 @@ async function loadTs(repoRoot) {
478
478
  );
479
479
  }
480
480
 
481
+ // src/suggest.ts
482
+ import { readFileSync as readFileSync4, writeFileSync } from "fs";
483
+ import { resolve as resolve2 } from "path";
484
+ function modelConfig(opts) {
485
+ const baseUrl = (opts.baseUrl ?? process.env.ROUTER_URL ?? "https://router.tangle.tools/v1").replace(/\/$/, "");
486
+ const apiKey = opts.apiKey ?? process.env.TANGLE_API_KEY ?? process.env.OPENAI_API_KEY ?? "";
487
+ const model = opts.model ?? "gpt-4.1-mini";
488
+ if (!apiKey) throw new Error("suggest: no API key. Set TANGLE_API_KEY (or pass --api-key).");
489
+ return { baseUrl, apiKey, model };
490
+ }
491
+ var PROMPT_RULES = 'Write ONE line of JSDoc summary for this TypeScript export. Rules: imperative mood ("Resolve\u2026", "Build\u2026"), describe what it IS or DOES, <= 110 characters, do NOT restate the type signature, do NOT start with "This function/type", no trailing period, no markdown. Return ONLY the summary text, nothing else.';
492
+ async function draftDoc(item, cfg) {
493
+ const content = `${PROMPT_RULES}
494
+
495
+ Name: ${item.name}
496
+ Kind: ${item.kind}
497
+ Signature: ${item.signature}
498
+
499
+ Source:
500
+ ${item.snippet}`;
501
+ let res;
502
+ try {
503
+ res = await fetch(`${cfg.baseUrl}/chat/completions`, {
504
+ method: "POST",
505
+ headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` },
506
+ body: JSON.stringify({
507
+ model: cfg.model,
508
+ messages: [{ role: "user", content }],
509
+ temperature: 0.2,
510
+ max_tokens: 120
511
+ })
512
+ });
513
+ } catch {
514
+ return null;
515
+ }
516
+ if (!res.ok) return null;
517
+ const j = await res.json();
518
+ const raw = j.choices?.[0]?.message?.content?.trim();
519
+ if (!raw) return null;
520
+ const line = raw.replace(/\s+/g, " ").replace(/^["'`]|["'`]$/g, "").replace(/^\/\*+|\*+\/$/g, "").replace(/\.$/, "").trim();
521
+ return line || null;
522
+ }
523
+ function statementOf(ts, decl) {
524
+ let n = decl;
525
+ while (n.parent && !ts.isSourceFile(n.parent)) n = n.parent;
526
+ return n;
527
+ }
528
+ function collectCandidates(ts, repoRoot, entries, subpath) {
529
+ const srcRoot = resolve2(repoRoot, "src");
530
+ const scoped = subpath ? entries.filter((e) => e.id === subpath) : entries;
531
+ const options = { noEmit: true, skipLibCheck: true, allowJs: true };
532
+ const program = ts.createProgram(
533
+ scoped.map((e) => e.file),
534
+ options
535
+ );
536
+ const checker = program.getTypeChecker();
537
+ const seen = /* @__PURE__ */ new Set();
538
+ const out = [];
539
+ for (const entry of scoped) {
540
+ const sf = program.getSourceFile(entry.file);
541
+ if (!sf) continue;
542
+ const moduleSym = checker.getSymbolAtLocation(sf);
543
+ if (!moduleSym) continue;
544
+ for (const exp of checker.getExportsOfModule(moduleSym)) {
545
+ const name = exp.getName();
546
+ if (name.startsWith("__")) continue;
547
+ let sym = exp;
548
+ if (sym.flags & ts.SymbolFlags.Alias) {
549
+ try {
550
+ sym = checker.getAliasedSymbol(sym);
551
+ } catch {
552
+ }
553
+ }
554
+ if (ts.displayPartsToString(exp.getDocumentationComment(checker)).trim()) continue;
555
+ if (ts.displayPartsToString(sym.getDocumentationComment(checker)).trim()) continue;
556
+ const decl = sym.declarations?.[0];
557
+ if (!decl) continue;
558
+ const declSf = decl.getSourceFile();
559
+ if (!declSf.fileName.startsWith(srcRoot + "/")) continue;
560
+ const stmt = statementOf(ts, decl);
561
+ const start = stmt.getStart(declSf);
562
+ const key = `${declSf.fileName}:${start}`;
563
+ if (seen.has(key)) continue;
564
+ seen.add(key);
565
+ const { line } = declSf.getLineAndCharacterOfPosition(start);
566
+ const lineStart = declSf.getPositionOfLineAndCharacter(line, 0);
567
+ const indent = (declSf.text.slice(lineStart, start).match(/^\s*/) ?? [""])[0];
568
+ const kind = kindOf(ts, sym);
569
+ const type = decl && sym.valueDeclaration ? checker.typeToString(checker.getTypeOfSymbolAtLocation(sym, sym.valueDeclaration)) : "";
570
+ const snippet = stmt.getText(declSf).split("\n").slice(0, 14).join("\n").slice(0, 1200);
571
+ out.push({ name, kind, signature: (type || kind).replace(/\s+/g, " ").slice(0, 200), file: declSf.fileName, pos: start, indent, snippet });
572
+ }
573
+ }
574
+ return out;
575
+ }
576
+ function kindOf(ts, sym) {
577
+ const f = sym.flags;
578
+ if (f & ts.SymbolFlags.Class) return "class";
579
+ if (f & ts.SymbolFlags.Interface) return "interface";
580
+ if (f & ts.SymbolFlags.TypeAlias) return "type";
581
+ if (f & ts.SymbolFlags.Enum) return "enum";
582
+ if (f & ts.SymbolFlags.Function) return "function";
583
+ return "const";
584
+ }
585
+ async function pool(items, n, fn) {
586
+ const q = [...items];
587
+ const workers = Array.from({ length: Math.min(n, q.length) }, async () => {
588
+ for (; ; ) {
589
+ const item = q.shift();
590
+ if (item === void 0) return;
591
+ await fn(item);
592
+ }
593
+ });
594
+ await Promise.all(workers);
595
+ }
596
+ async function suggest(repoRoot, opts = {}) {
597
+ const root = resolve2(repoRoot);
598
+ const cfg = modelConfig(opts);
599
+ const log = opts.log ?? (() => {
600
+ });
601
+ const config = await loadConfig(root);
602
+ const ts = await loadTs(root);
603
+ const entries = resolveEntries(root, config, ts);
604
+ let candidates = collectCandidates(ts, root, entries, opts.subpath);
605
+ if (opts.limit) candidates = candidates.slice(0, opts.limit);
606
+ log(`suggest: ${candidates.length} undocumented exports to draft (model ${cfg.model})`);
607
+ const drafts = [];
608
+ const byFile = /* @__PURE__ */ new Map();
609
+ let done = 0;
610
+ await pool(candidates, opts.concurrency ?? 5, async (c) => {
611
+ const doc = await draftDoc(c, cfg);
612
+ done++;
613
+ if (done % 20 === 0) log(` drafted ${done}/${candidates.length}`);
614
+ if (!doc) return;
615
+ drafts.push({ name: c.name, file: c.file, doc });
616
+ const arr = byFile.get(c.file) ?? [];
617
+ arr.push({ pos: c.pos, indent: c.indent, doc });
618
+ byFile.set(c.file, arr);
619
+ });
620
+ const files = [];
621
+ if (!opts.dryRun) {
622
+ for (const [file, inserts] of byFile) {
623
+ let text = readFileSync4(file, "utf8");
624
+ for (const { pos, indent, doc } of inserts.sort((a, b) => b.pos - a.pos)) {
625
+ text = text.slice(0, pos) + `/** ${doc} */
626
+ ${indent}` + text.slice(pos);
627
+ }
628
+ writeFileSync(file, text);
629
+ files.push(file);
630
+ }
631
+ }
632
+ return { documented: drafts.length, skipped: candidates.length - drafts.length, files, drafts, dryRun: !!opts.dryRun };
633
+ }
634
+
481
635
  // src/index.ts
482
636
  var DEFAULT_ASK = [
483
637
  "What is the high-level architecture and what are the main components?",
@@ -487,7 +641,7 @@ function readPackageDescription(root) {
487
641
  const pkg = join5(root, "package.json");
488
642
  if (!existsSync4(pkg)) return void 0;
489
643
  try {
490
- const d = JSON.parse(readFileSync4(pkg, "utf8")).description;
644
+ const d = JSON.parse(readFileSync5(pkg, "utf8")).description;
491
645
  return typeof d === "string" && d ? d : void 0;
492
646
  } catch {
493
647
  return void 0;
@@ -499,14 +653,14 @@ function describeSource(root, config) {
499
653
  const pkg = join5(root, "package.json");
500
654
  if (existsSync4(pkg)) {
501
655
  try {
502
- if (JSON.parse(readFileSync4(pkg, "utf8")).exports) return "package.json `exports`";
656
+ if (JSON.parse(readFileSync5(pkg, "utf8")).exports) return "package.json `exports`";
503
657
  } catch {
504
658
  }
505
659
  }
506
660
  return "src/index";
507
661
  }
508
662
  async function analyze(repoRoot, opts = {}) {
509
- const root = resolve2(repoRoot);
663
+ const root = resolve3(repoRoot);
510
664
  const config = await loadConfig(root);
511
665
  const ts = await loadTs(root);
512
666
  const entries = resolveEntries(root, config, ts);
@@ -536,13 +690,13 @@ async function write(repoRoot, opts = {}) {
536
690
  for (const [rel, content] of r.files) {
537
691
  const abs = join5(r.root, rel);
538
692
  mkdirSync(dirname3(abs), { recursive: true });
539
- writeFileSync(abs, content);
693
+ writeFileSync2(abs, content);
540
694
  written.push(rel);
541
695
  }
542
696
  if (r.wiki) {
543
697
  const abs = join5(r.root, r.wiki.path);
544
698
  mkdirSync(dirname3(abs), { recursive: true });
545
- writeFileSync(abs, r.wiki.content);
699
+ writeFileSync2(abs, r.wiki.content);
546
700
  written.push(r.wiki.path);
547
701
  }
548
702
  return { ...r, written };
@@ -554,7 +708,7 @@ async function check(repoRoot, opts = {}) {
554
708
  for (const [rel, content] of r.files) {
555
709
  const abs = join5(r.root, rel);
556
710
  if (!existsSync4(abs)) missing.push(rel);
557
- else if (readFileSync4(abs, "utf8") !== content) stale.push(rel);
711
+ else if (readFileSync5(abs, "utf8") !== content) stale.push(rel);
558
712
  }
559
713
  const apiDir = join5(r.root, r.meta.out, "api");
560
714
  const orphan = existsSync4(apiDir) ? readdirSync2(apiDir).filter((f) => f.endsWith(".md")).map((f) => `${r.meta.out}/api/${f}`).filter((rel) => !r.files.has(rel)) : [];
@@ -565,6 +719,7 @@ export {
565
719
  parseRemoteUrl,
566
720
  detectRepoSlug,
567
721
  fetchDeepwiki,
722
+ suggest,
568
723
  analyze,
569
724
  write,
570
725
  check
package/dist/cli.js CHANGED
@@ -1,33 +1,46 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  check,
4
+ suggest,
4
5
  write
5
- } from "./chunk-EKQKRTAN.js";
6
+ } from "./chunk-62CEZZVU.js";
6
7
 
7
8
  // src/cli.ts
8
9
  var HELP = `agent-docs \u2014 deterministic repo surface + dependency map for any TypeScript package.
9
10
 
10
11
  Usage:
11
- agent-docs [gen] Regenerate docs/CODEMAP.md + docs/api/*.md + docs/codemap.json
12
+ agent-docs [gen] Regenerate docs/CODEMAP.md + docs/api/*.md + llms.txt + codemap.json
12
13
  agent-docs --check Exit 1 if the committed deterministic docs are stale (CI gate)
13
14
  agent-docs --deepwiki Also write docs/WIKI.md from DeepWiki (public GitHub repos only)
15
+ agent-docs suggest Draft JSDoc for UNDOCUMENTED exports via a cheap model, write into source
14
16
 
15
17
  Options:
16
18
  --repo <path> Repo root (default: cwd)
17
19
  --out <dir> Output dir (default: docs, or agent-docs.config \`out\`)
20
+ suggest --subpath <id> Only document one subpath (e.g. chat-routes)
21
+ suggest --model <id> Model (default: claude-haiku-4-5-20251001, via TANGLE_API_KEY + ROUTER_URL)
22
+ suggest --limit <n> Cap exports documented this run
23
+ suggest --dry-run Draft + print, do not write to source
18
24
  -h, --help
19
25
 
20
26
  Entry points are auto-detected: agent-docs.config > tsup.config \`entry\` > package.json \`exports\` > src/index.
21
- The deterministic files are what --check gates on; the DeepWiki WIKI.md is a non-authoritative reading aid and is never gated.`;
27
+ generate/check are deterministic and LLM-free; suggest is the only LLM path and runs at AUTHORING time \u2014 it edits
28
+ your JSDoc so the gated docs stay type-derived.`;
22
29
  function parseArgs(argv) {
23
- const args2 = { repo: process.cwd(), check: false, deepwiki: false, help: false };
30
+ const args2 = { command: "gen", repo: process.cwd(), deepwiki: false, dryRun: false, help: false };
24
31
  const rest = argv.slice(2);
25
32
  for (let i = 0; i < rest.length; i++) {
26
33
  const a = rest[i];
27
- if (a === "--check" || a === "check") args2.check = true;
34
+ if (a === "suggest") args2.command = "suggest";
35
+ else if (a === "--check" || a === "check") args2.command = "check";
28
36
  else if (a === "--deepwiki") args2.deepwiki = true;
29
37
  else if (a === "--repo") args2.repo = rest[++i];
30
38
  else if (a === "--out") args2.out = rest[++i];
39
+ else if (a === "--subpath") args2.subpath = rest[++i];
40
+ else if (a === "--model") args2.model = rest[++i];
41
+ else if (a === "--limit") args2.limit = Number(rest[++i]);
42
+ else if (a === "--concurrency") args2.concurrency = Number(rest[++i]);
43
+ else if (a === "--dry-run") args2.dryRun = true;
31
44
  else if (a === "-h" || a === "--help") args2.help = true;
32
45
  else if (a === "gen") {
33
46
  } else if (a.startsWith("-")) {
@@ -43,7 +56,7 @@ if (args.help) {
43
56
  process.exit(0);
44
57
  }
45
58
  try {
46
- if (args.check) {
59
+ if (args.command === "check") {
47
60
  const r2 = await check(args.repo, { out: args.out });
48
61
  if (r2.ok) {
49
62
  console.log("agent-docs: docs are fresh");
@@ -55,6 +68,23 @@ try {
55
68
  for (const f of r2.orphan) console.error(` orphaned: ${f}`);
56
69
  process.exit(1);
57
70
  }
71
+ if (args.command === "suggest") {
72
+ const r2 = await suggest(args.repo, {
73
+ subpath: args.subpath,
74
+ model: args.model,
75
+ limit: args.limit,
76
+ concurrency: args.concurrency,
77
+ dryRun: args.dryRun,
78
+ log: (m) => console.error(m)
79
+ });
80
+ if (r2.dryRun) {
81
+ for (const d of r2.drafts) console.log(`${d.name}: ${d.doc}`);
82
+ console.log(`agent-docs: drafted ${r2.documented} (dry run \u2014 nothing written)`);
83
+ } else {
84
+ console.log(`agent-docs: documented ${r2.documented} exports across ${r2.files.length} files (${r2.skipped} skipped). Review the diff, then \`agent-docs\` to refresh the map.`);
85
+ }
86
+ process.exit(0);
87
+ }
58
88
  const r = await write(args.repo, { out: args.out, deepwiki: args.deepwiki });
59
89
  const det = r.written.filter((f) => !f.endsWith("WIKI.md")).length;
60
90
  console.log(`agent-docs: wrote ${det} files under ${r.meta.out}/ (${r.rows.length} entries)`);
package/dist/index.d.ts CHANGED
@@ -107,6 +107,43 @@ declare function fetchDeepwiki(slug: string, { ask, timeoutMs }?: {
107
107
  timeoutMs?: number;
108
108
  }): Promise<DeepwikiResult | null>;
109
109
 
110
+ /**
111
+ * `agent-docs suggest` — draft a one-line JSDoc for every UNDOCUMENTED public
112
+ * export via a cheap model on an OpenAI-compatible endpoint (the Tangle router
113
+ * by default), and insert it into source above the declaration.
114
+ *
115
+ * This is the ONLY LLM-touching authoring path, and it is deliberately at
116
+ * AUTHORING time, not generation time: the model improves the source comments;
117
+ * once committed, the deterministic extractor picks them up so the gated
118
+ * `llms.txt` / `CODEMAP.md` stay type-derived and never contain raw model text.
119
+ * Nothing here runs during `generate` or `check`.
120
+ */
121
+ interface SuggestOptions {
122
+ /** Only document this export subpath id (e.g. `chat-routes`); default: all. */
123
+ subpath?: string;
124
+ model?: string;
125
+ baseUrl?: string;
126
+ apiKey?: string;
127
+ /** Cap the number of exports documented in one run. */
128
+ limit?: number;
129
+ /** Draft + report, but do NOT write to source. */
130
+ dryRun?: boolean;
131
+ concurrency?: number;
132
+ log?: (msg: string) => void;
133
+ }
134
+ interface SuggestResult {
135
+ documented: number;
136
+ skipped: number;
137
+ files: string[];
138
+ drafts: Array<{
139
+ name: string;
140
+ file: string;
141
+ doc: string;
142
+ }>;
143
+ dryRun: boolean;
144
+ }
145
+ declare function suggest(repoRoot: string, opts?: SuggestOptions): Promise<SuggestResult>;
146
+
110
147
  /** Extract the surface + graph (+ optional DeepWiki) and build the output files. */
111
148
  declare function analyze(repoRoot: string, opts?: AnalyzeOptions): Promise<AnalyzeResult>;
112
149
  /** Regenerate all files to disk. Cleans stale `api/*.md` first. */
@@ -120,4 +157,4 @@ declare function write(repoRoot: string, opts?: AnalyzeOptions): Promise<Analyze
120
157
  */
121
158
  declare function check(repoRoot: string, opts?: AnalyzeOptions): Promise<CheckResult>;
122
159
 
123
- export { type AnalyzeOptions, type AnalyzeResult, type CartographConfig, type CheckResult, type DeepwikiResult, type Entry, type ExportInfo, type ExportKind, type Meta, type RepoSlug, type Row, type TS, analyze, check, detectRepoSlug, fetchDeepwiki, parseRemoteUrl, write };
160
+ export { type AnalyzeOptions, type AnalyzeResult, type CartographConfig, type CheckResult, type DeepwikiResult, type Entry, type ExportInfo, type ExportKind, type Meta, type RepoSlug, type Row, type SuggestOptions, type SuggestResult, type TS, analyze, check, detectRepoSlug, fetchDeepwiki, parseRemoteUrl, suggest, write };
package/dist/index.js CHANGED
@@ -4,13 +4,15 @@ import {
4
4
  detectRepoSlug,
5
5
  fetchDeepwiki,
6
6
  parseRemoteUrl,
7
+ suggest,
7
8
  write
8
- } from "./chunk-EKQKRTAN.js";
9
+ } from "./chunk-62CEZZVU.js";
9
10
  export {
10
11
  analyze,
11
12
  check,
12
13
  detectRepoSlug,
13
14
  fetchDeepwiki,
14
15
  parseRemoteUrl,
16
+ suggest,
15
17
  write
16
18
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-docs",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Deterministic repo surface + dependency map for any TypeScript package, with an optional DeepWiki narrative augment for public repos.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -30,7 +30,9 @@
30
30
  "typescript": ">=5.0.0"
31
31
  },
32
32
  "peerDependenciesMeta": {
33
- "typescript": { "optional": false }
33
+ "typescript": {
34
+ "optional": false
35
+ }
34
36
  },
35
37
  "devDependencies": {
36
38
  "@types/node": "^22.0.0",