ctxman 3.0.0 → 3.1.1

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 (224) hide show
  1. package/.ctxman/llm-profiles.json +1067 -39
  2. package/.ctxman/profiles/backend-team.json +13 -0
  3. package/.ctxman/profiles/default.json +12 -0
  4. package/.ctxman/profiles/devops-team.json +21 -0
  5. package/.ctxman/profiles/frontend-team.json +20 -0
  6. package/.ctxman/templates/bug-fix.json +17 -0
  7. package/.ctxman/templates/code-review.json +9 -0
  8. package/.ctxman/templates/documentation.json +9 -0
  9. package/.ctxman/templates/feature.json +9 -0
  10. package/.ctxman/templates/full-context.json +9 -0
  11. package/.ctxman/templates/refactoring.json +18 -0
  12. package/.ctxman/templates/testing.json +9 -0
  13. package/.ctxman/wizard-profiles/security-audit/profile.json +7 -1
  14. package/LICENSE +21 -0
  15. package/README.md +381 -136
  16. package/bin/cli.js +1880 -529
  17. package/bin/cm-ask.js +18 -67
  18. package/bin/cm-audit.js +121 -0
  19. package/bin/cm-count.js +151 -0
  20. package/bin/cm-gitingest.js +128 -115
  21. package/bin/cm-map.js +242 -0
  22. package/bin/cm-mcp.js +293 -0
  23. package/bin/cm-pack.js +405 -0
  24. package/bin/cm-policy.js +170 -0
  25. package/bin/cm-pr.js +248 -0
  26. package/bin/cm-search.js +178 -0
  27. package/bin/cm-update.js +128 -128
  28. package/bin/cm-why.js +197 -0
  29. package/bin/mcp-server.js +8 -24
  30. package/ctxman.js +237 -183
  31. package/examples/README.md +19 -4
  32. package/examples/github-actions/ctxman-check.yml +49 -0
  33. package/examples/wizard-profiles/security-audit/profile.json +7 -1
  34. package/index.d.ts +1422 -0
  35. package/index.js +55 -31
  36. package/lib/agents/pr-failure-agent.js +48 -45
  37. package/lib/analyzers/ai-suggester.js +451 -0
  38. package/lib/analyzers/dependency-scanner.js +505 -0
  39. package/lib/analyzers/duplicate-detector.js +264 -0
  40. package/lib/analyzers/go-method-analyzer.js +151 -110
  41. package/lib/analyzers/import-tracker.js +431 -0
  42. package/lib/analyzers/method-analyzer.js +1055 -434
  43. package/lib/analyzers/performance-dashboard.js +687 -0
  44. package/lib/analyzers/token-calculator.js +895 -504
  45. package/lib/analyzers/unused-exports-detector.js +336 -0
  46. package/lib/api/mcp/install.js +424 -0
  47. package/lib/api/mcp/prompts.js +281 -281
  48. package/lib/api/mcp/resources.js +300 -241
  49. package/lib/api/mcp/sandbox.js +89 -0
  50. package/lib/api/mcp/server.js +772 -312
  51. package/lib/api/mcp/start.js +125 -0
  52. package/lib/api/mcp/transports/HttpSseTransport.js +60 -52
  53. package/lib/api/rest/server.js +105 -28
  54. package/lib/cache/CacheManager.js +9 -17
  55. package/lib/core/Analyzer.js +16 -10
  56. package/lib/core/ContextBuilder.js +19 -28
  57. package/lib/core/Reporter.js +15 -14
  58. package/lib/core/Scanner.js +81 -12
  59. package/lib/count/index.js +100 -0
  60. package/lib/formatters/format-registry.js +394 -372
  61. package/lib/formatters/gitingest-formatter.js +656 -621
  62. package/lib/formatters/toon-formatter-v1.3.js +637 -632
  63. package/lib/formatters/toon-formatter.js +239 -240
  64. package/lib/integrations/git/BlameTracker.js +4 -4
  65. package/lib/integrations/git/DiffAnalyzer.js +38 -31
  66. package/lib/integrations/git/GitClient.js +71 -53
  67. package/lib/lsp/server.js +311 -0
  68. package/lib/map/graph.js +299 -0
  69. package/lib/map/imports.js +233 -0
  70. package/lib/map/index.js +33 -0
  71. package/lib/map/rank.js +253 -0
  72. package/lib/map/render.js +346 -0
  73. package/lib/map/resolver.js +574 -0
  74. package/lib/pack/budget.js +111 -0
  75. package/lib/pack/code-lexer.js +360 -0
  76. package/lib/pack/compress.js +414 -0
  77. package/lib/pack/index.js +354 -0
  78. package/lib/pack/renderers/common.js +134 -0
  79. package/lib/pack/renderers/index.js +24 -0
  80. package/lib/pack/renderers/json.js +47 -0
  81. package/lib/pack/renderers/markdown.js +54 -0
  82. package/lib/pack/renderers/plain.js +38 -0
  83. package/lib/pack/renderers/xml.js +85 -0
  84. package/lib/pack/select.js +177 -0
  85. package/lib/pack/split.js +182 -0
  86. package/lib/pack/tree.js +35 -0
  87. package/lib/parsers/gitignore-parser.js +116 -107
  88. package/lib/parsers/method-filter-parser.js +50 -33
  89. package/lib/plugins/ExporterPlugin.js +3 -3
  90. package/lib/plugins/LanguagePlugin.js +3 -3
  91. package/lib/plugins/PluginManager.js +7 -13
  92. package/lib/policy/audit.js +312 -0
  93. package/lib/policy/enforce.js +125 -0
  94. package/lib/policy/ignore-files.js +169 -0
  95. package/lib/policy/index.js +53 -0
  96. package/lib/policy/policy.js +489 -0
  97. package/lib/pr/diff-parser.js +267 -0
  98. package/lib/pr/method-mapper.js +148 -0
  99. package/lib/pr/pr-context.js +829 -0
  100. package/lib/pr/pr-git.js +149 -0
  101. package/lib/pr/pr-renderer.js +644 -0
  102. package/lib/pr/related-tests.js +296 -0
  103. package/lib/rag/DataSourcePlugin.js +5 -5
  104. package/lib/rag/EmbeddingProvider.js +19 -8
  105. package/lib/rag/Indexer.js +14 -5
  106. package/lib/rag/VectorStoreFactory.js +3 -3
  107. package/lib/rag/ask.js +122 -0
  108. package/lib/rag/sources/FileDataSource.js +12 -9
  109. package/lib/rag/stores/BaseVectorStore.js +5 -5
  110. package/lib/rag/stores/ChromaDBStore.js +10 -10
  111. package/lib/rag/stores/LanceDBStore.js +29 -19
  112. package/lib/rag/stores/PineconeStore.js +11 -9
  113. package/lib/search/bm25.js +433 -0
  114. package/lib/search/code-units.js +201 -0
  115. package/lib/search/symbols.js +189 -0
  116. package/lib/ui/dashboard.js +120 -89
  117. package/lib/ui/index.js +1 -6
  118. package/lib/ui/init-wizard-ui.js +550 -0
  119. package/lib/ui/progress-bar.js +94 -88
  120. package/lib/ui/select-input.js +40 -32
  121. package/lib/ui/template-selector.js +99 -0
  122. package/lib/ui/wizard.js +374 -329
  123. package/lib/utils/claude-token-counter.js +151 -0
  124. package/lib/utils/cli-output.js +17 -0
  125. package/lib/utils/clipboard-utils.js +49 -50
  126. package/lib/utils/config-utils.js +81 -66
  127. package/lib/utils/context-versioning.js +532 -0
  128. package/lib/utils/error-handler.js +112 -112
  129. package/lib/utils/file-utils.js +153 -69
  130. package/lib/utils/format-converter.js +356 -283
  131. package/lib/utils/git-utils.js +366 -294
  132. package/lib/utils/llm-cost-estimator.js +281 -0
  133. package/lib/utils/llm-detector.js +242 -78
  134. package/lib/utils/logger.js +246 -237
  135. package/lib/utils/messages.js +355 -0
  136. package/lib/utils/multi-repo-manager.js +364 -0
  137. package/lib/utils/profile-manager.js +380 -0
  138. package/lib/utils/safe-read.js +61 -0
  139. package/lib/utils/secret-redactor.js +61 -0
  140. package/lib/utils/snapshot-manager.js +679 -0
  141. package/lib/utils/template-manager.js +378 -0
  142. package/lib/utils/token-utils.js +201 -170
  143. package/lib/utils/tokenizer-adapter.js +421 -377
  144. package/lib/utils/updater.js +539 -506
  145. package/lib/watch/FileWatcher.js +12 -8
  146. package/lib/watch/IncrementalAnalyzer.js +7 -7
  147. package/lib/why/coupling.js +81 -0
  148. package/lib/why/errors.js +23 -0
  149. package/lib/why/git.js +169 -0
  150. package/lib/why/history.js +108 -0
  151. package/lib/why/index.js +720 -0
  152. package/lib/why/ownership.js +79 -0
  153. package/lib/why/paths.js +207 -0
  154. package/lib/why/references.js +259 -0
  155. package/lib/why/render.js +722 -0
  156. package/lib/why/search.js +358 -0
  157. package/lib/why/target.js +389 -0
  158. package/lib/wizards/config-generator.js +257 -0
  159. package/lib/wizards/project-detector.js +572 -0
  160. package/package.json +44 -10
  161. package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
  162. package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
  163. package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
  164. package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
  165. package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
  166. package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
  167. package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
  168. package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
  169. package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
  170. package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
  171. package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
  172. package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
  173. package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
  174. package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
  175. package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
  176. package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
  177. package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
  178. package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
  179. package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
  180. package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
  181. package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
  182. package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
  183. package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
  184. package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
  185. package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
  186. package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
  187. package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
  188. package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
  189. package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
  190. package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
  191. package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
  192. package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
  193. package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
  194. package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
  195. package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
  196. package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
  197. package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
  198. package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
  199. package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
  200. package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
  201. package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
  202. package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
  203. package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
  204. package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
  205. package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
  206. package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
  207. package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
  208. package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
  209. package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
  210. package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
  211. package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
  212. package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
  213. package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
  214. package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
  215. package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
  216. package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
  217. package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
  218. package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
  219. package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
  220. package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
  221. package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
  222. package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
  223. package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
  224. package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Pack: file contents in one LLM-ready document
3
+ *
4
+ * Pipeline per file: read -> skip binary -> compress (optional) -> line numbers (optional)
5
+ * -> redact secrets (default) -> count tokens. Then the budget picks files (optional), the
6
+ * renderer for the style builds the document, and --split cuts it into parts (optional).
7
+ *
8
+ * Every count uses one tiktoken encoding: the target model's (o200k_base for recent OpenAI
9
+ * models) when one is given, cl100k_base otherwise.
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import TokenUtils from '../utils/token-utils.js';
14
+ import { LLMDetector } from '../utils/llm-detector.js';
15
+ import { redactSecrets } from '../utils/secret-redactor.js';
16
+ import { selectFiles, PackError } from './select.js';
17
+ import { compressCode } from './compress.js';
18
+ import { buildTree } from './tree.js';
19
+ import { rankEntries, fitToBudget } from './budget.js';
20
+ import { splitIntoParts } from './split.js';
21
+ import { getRenderer, STYLES } from './renderers/index.js';
22
+
23
+ export { PackError, STYLES };
24
+
25
+ /** Files larger than this are skipped unless `maxFileSize` says otherwise */
26
+ export const DEFAULT_MAX_FILE_SIZE = 1024 * 1024;
27
+ /** Budget of the MCP tool when the client gives none: whole projects would flood its context */
28
+ export const TOOL_DEFAULT_BUDGET = 20000;
29
+ // Omitted files listed by name in the document; the rest are counted
30
+ const OMITTED_LISTED = 20;
31
+ // A NUL byte in the first bytes marks a binary file
32
+ const BINARY_SNIFF_BYTES = 8000;
33
+
34
+ /**
35
+ * Prefix each line with its number, right-aligned
36
+ * @param {string} text
37
+ * @param {number[]|null} numbers - Number of each line (null: 1, 2, 3, ...)
38
+ * @returns {string}
39
+ */
40
+ export function addLineNumbers(text, numbers = null) {
41
+ const lines = text.split('\n');
42
+ const count = lines.length - (text.endsWith('\n') ? 1 : 0);
43
+ const numberOf = (i) => (numbers ? numbers[i] : i + 1);
44
+ let widest = 0;
45
+ for (let i = 0; i < count; i++) widest = Math.max(widest, numberOf(i));
46
+ const width = String(widest).length;
47
+ return lines
48
+ .map((line, i) => {
49
+ if (i >= count) return line;
50
+ const number = String(numberOf(i)).padStart(width);
51
+ return line === '' || line === '\r' ? `${number} |${line}` : `${number} | ${line}`;
52
+ })
53
+ .join('\n');
54
+ }
55
+
56
+ const positiveInteger = (value, name) => {
57
+ if (value === undefined || value === null) return null;
58
+ const number = Number(value);
59
+ if (!Number.isInteger(number) || number <= 0) {
60
+ throw new PackError('INVALID_OPTION', `${name} must be a positive integer, got ${value}`);
61
+ }
62
+ return number;
63
+ };
64
+
65
+ const stringList = (value, name) => {
66
+ if (value === undefined || value === null) return [];
67
+ const list = Array.isArray(value) ? value : [value];
68
+ if (!list.every((item) => typeof item === 'string')) {
69
+ throw new PackError('INVALID_OPTION', `${name} must be a list of strings`);
70
+ }
71
+ return list;
72
+ };
73
+
74
+ /** Profile of the target model, or null without one; unknown ids are an error */
75
+ function targetProfile(model) {
76
+ if (model === undefined || model === null || model === '') return null;
77
+ const id = LLMDetector.resolveModelId(String(model));
78
+ if (!id) {
79
+ throw new PackError(
80
+ 'UNKNOWN_MODEL',
81
+ `Unknown model "${model}" (ctxman --list-llms lists the known ids)`
82
+ );
83
+ }
84
+ return LLMDetector.getProfile(id);
85
+ }
86
+
87
+ function normalizeOptions(options) {
88
+ const style = options.style ?? 'xml';
89
+ if (!STYLES.includes(style)) {
90
+ throw new PackError('INVALID_OPTION', `Unknown style "${style}" (use ${STYLES.join(', ')})`);
91
+ }
92
+ const profile = targetProfile(options.targetModel);
93
+ return {
94
+ style,
95
+ include: stringList(options.include, 'include'),
96
+ exclude: stringList(options.exclude, 'exclude'),
97
+ files: options.files ? stringList(options.files, 'files') : null,
98
+ cwd: options.cwd,
99
+ excludeFiles: stringList(options.excludeFiles, 'excludeFiles'),
100
+ maxFileSize: positiveInteger(options.maxFileSize, 'maxFileSize') ?? DEFAULT_MAX_FILE_SIZE,
101
+ tree: options.tree !== false,
102
+ header: options.header ? String(options.header).trimEnd() : null,
103
+ instructions: options.instructions ? String(options.instructions).trimEnd() : null,
104
+ lineNumbers: Boolean(options.lineNumbers),
105
+ compress: Boolean(options.compress),
106
+ redact: options.redact !== false,
107
+ budget: positiveInteger(options.budget, 'budget'),
108
+ split: positiveInteger(options.split, 'split'),
109
+ profile,
110
+ encoding: TokenUtils.encodingFor(profile?.id),
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Read and transform one file
116
+ * @returns {object|{skipped: string}} Entry, or the reason it is skipped
117
+ */
118
+ function prepareFile(file, options) {
119
+ let buffer;
120
+ try {
121
+ buffer = fs.readFileSync(file.absolutePath);
122
+ } catch (_error) {
123
+ return { skipped: 'unreadable' };
124
+ }
125
+ if (buffer.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return { skipped: 'binary' };
126
+
127
+ let content = buffer.toString('utf8');
128
+ if (content.charCodeAt(0) === 0xfeff) content = content.slice(1);
129
+ const totalLines = content.split('\n').length - (content.endsWith('\n') ? 1 : 0);
130
+ const count = (text) => TokenUtils.calculate(text, file.path, options.encoding);
131
+
132
+ let lineNumbers = null;
133
+ let compression = null;
134
+ if (options.compress) {
135
+ const result = compressCode(content, file.path);
136
+ if (result.elided > 0) {
137
+ compression = {
138
+ bodies: result.elided,
139
+ originalTokens: count(content),
140
+ compressedTokens: count(result.text),
141
+ };
142
+ content = result.text;
143
+ lineNumbers = result.lineMap;
144
+ }
145
+ }
146
+
147
+ // Numbers first, so a secret masked below cannot shift the numbers of later lines
148
+ if (options.lineNumbers) content = addLineNumbers(content, lineNumbers);
149
+
150
+ let findings = [];
151
+ if (options.redact) {
152
+ const redacted = redactSecrets(content);
153
+ if (redacted.findings.length) {
154
+ // A masked private key block becomes one line: line positions no longer line up
155
+ if (redacted.text.split('\n').length !== content.split('\n').length) lineNumbers = null;
156
+ content = redacted.text;
157
+ findings = redacted.findings;
158
+ }
159
+ }
160
+
161
+ return {
162
+ path: file.path,
163
+ absolutePath: file.absolutePath,
164
+ modified: file.modified,
165
+ content,
166
+ tokens: count(content),
167
+ totalLines,
168
+ lineNumbers,
169
+ compression,
170
+ findings,
171
+ };
172
+ }
173
+
174
+ /** Mask secrets in user text (header, instructions) and note what was found */
175
+ function redactText(text, options, findings) {
176
+ if (!text || !options.redact) return text;
177
+ const redacted = redactSecrets(text);
178
+ findings.push(...redacted.findings);
179
+ return redacted.text;
180
+ }
181
+
182
+ function omittedSection(omitted) {
183
+ if (!omitted.length) return null;
184
+ const rest = omitted.slice(OMITTED_LISTED);
185
+ const sum = (entries) => entries.reduce((total, entry) => total + entry.tokens, 0);
186
+ return {
187
+ files: omitted.slice(0, OMITTED_LISTED).map(({ path, tokens }) => ({ path, tokens })),
188
+ count: omitted.length,
189
+ tokens: sum(omitted),
190
+ more: rest.length,
191
+ moreTokens: sum(rest),
192
+ };
193
+ }
194
+
195
+ /** How the document compares with the target model's context window */
196
+ function modelFit(profile, tokens, encoding) {
197
+ if (!profile) return null;
198
+ return {
199
+ id: profile.id,
200
+ name: profile.name,
201
+ vendor: profile.vendor,
202
+ encoding: TokenUtils.isExact() ? encoding : 'estimate',
203
+ // tiktoken is the model's own tokenizer only for OpenAI models with a published encoding
204
+ exact: TokenUtils.isExact() && profile.tokenizer?.exact === true,
205
+ contextWindow: profile.contextWindow,
206
+ maxRecommendedInput: profile.maxRecommendedInput ?? null,
207
+ fits: tokens <= profile.contextWindow,
208
+ percentOfWindow: (tokens / profile.contextWindow) * 100,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Pack the contents of a project's files into one document
214
+ * @param {string} root - Project root
215
+ * @param {object} [options]
216
+ * @param {'xml'|'markdown'|'plain'|'json'} [options.style='xml']
217
+ * @param {string[]} [options.include] - Gitignore-style globs: keep only matching files
218
+ * @param {string[]} [options.exclude] - Gitignore-style globs: leave matching files out
219
+ * @param {string[]} [options.files] - Explicit file list instead of scanning the root
220
+ * @param {string} [options.cwd] - Base for relative `files` entries (default: root)
221
+ * @param {string[]} [options.excludeFiles] - Absolute paths to leave out
222
+ * @param {number} [options.maxFileSize=1048576] - Skip larger files (bytes)
223
+ * @param {boolean} [options.tree=true] - Directory tree at the top
224
+ * @param {string} [options.header] - Text at the top
225
+ * @param {string} [options.instructions] - Text at the end, after the files
226
+ * @param {boolean} [options.lineNumbers=false] - Number every line
227
+ * @param {boolean} [options.compress=false] - Replace function bodies with placeholders
228
+ * @param {boolean} [options.redact=true] - Mask secrets
229
+ * @param {number} [options.budget] - Token limit for the document; highest-ranked files first
230
+ * @param {number} [options.split] - Token limit per part; adds `parts` to the result
231
+ * @param {string} [options.targetModel] - Count with this model's encoding and report fit
232
+ * @returns {Promise<object>} {text, style, encoding, totalTokens, files, omitted, skipped,
233
+ * redactions, compression, budget, model, parts, splitFiles}
234
+ */
235
+ export async function packContext(root, options = {}) {
236
+ const opts = normalizeOptions(options);
237
+ const renderer = getRenderer(opts.style);
238
+ const count = (text) => TokenUtils.calculate(text, `pack${renderer.extension}`, opts.encoding);
239
+
240
+ const selection = selectFiles(root, opts);
241
+ const skipped = [...selection.skipped];
242
+ const entries = [];
243
+ for (const file of selection.files) {
244
+ const entry = prepareFile(file, opts);
245
+ if (entry.skipped) skipped.push({ path: file.path, reason: entry.skipped });
246
+ else entries.push(entry);
247
+ }
248
+
249
+ const textFindings = [];
250
+ const header = redactText(opts.header, opts, textFindings);
251
+ const instructions = redactText(opts.instructions, opts, textFindings);
252
+
253
+ const docFor = (packed, omitted) => ({
254
+ header,
255
+ partLabel: null,
256
+ part: null,
257
+ tree: opts.tree ? buildTree(packed.map((entry) => entry.path)) : null,
258
+ files: packed,
259
+ firstIndex: 1,
260
+ omitted: omittedSection(omitted),
261
+ instructions,
262
+ });
263
+ const measure = (packed, omitted) => {
264
+ const text = renderer.render(docFor(packed, omitted));
265
+ return { text, tokens: count(text) };
266
+ };
267
+
268
+ let packed = entries;
269
+ let omitted = [];
270
+ let rendered;
271
+ if (opts.budget) {
272
+ const { ranked } = rankEntries(selection.root, entries);
273
+ const separator = count('\n\n');
274
+ const wrapperCost = (entry) =>
275
+ opts.style === 'json'
276
+ ? count(renderer.renderFile(entry, entries.length))
277
+ : entry.tokens + count(renderer.renderFile({ ...entry, content: '' }, entries.length));
278
+ const cost = (entry) =>
279
+ (entry.cost ??= wrapperCost(entry) + separator + (opts.tree ? count(entry.path) + 1 : 0));
280
+ ({ packed, omitted, ...rendered } = fitToBudget(entries, ranked, opts.budget, {
281
+ measure,
282
+ cost,
283
+ }));
284
+ } else {
285
+ rendered = measure(entries, []);
286
+ }
287
+
288
+ let parts = null;
289
+ let splitFiles = [];
290
+ if (opts.split) {
291
+ const doc = docFor(packed, omitted);
292
+ ({ parts, splitFiles } = splitIntoParts(packed, opts.split, { renderer, count, doc }));
293
+ }
294
+
295
+ const redactionCounts = {};
296
+ const findings = [...textFindings, ...packed.flatMap((entry) => entry.findings)];
297
+ for (const type of findings) redactionCounts[type] = (redactionCounts[type] ?? 0) + 1;
298
+
299
+ const compressed = packed.filter((entry) => entry.compression);
300
+ const sum = (key) => compressed.reduce((total, entry) => total + entry.compression[key], 0);
301
+ const compression = opts.compress
302
+ ? {
303
+ files: compressed.length,
304
+ bodies: sum('bodies'),
305
+ originalTokens: sum('originalTokens'),
306
+ compressedTokens: sum('compressedTokens'),
307
+ savedTokens: sum('originalTokens') - sum('compressedTokens'),
308
+ }
309
+ : null;
310
+
311
+ return {
312
+ text: rendered.text,
313
+ style: opts.style,
314
+ encoding: TokenUtils.isExact() ? opts.encoding : 'estimate',
315
+ totalTokens: rendered.tokens,
316
+ files: packed.map((entry) => ({
317
+ path: entry.path,
318
+ tokens: entry.tokens,
319
+ lines: entry.totalLines,
320
+ compressed: Boolean(entry.compression),
321
+ })),
322
+ omitted: omitted.map(({ path, tokens }) => ({ path, tokens })),
323
+ skipped,
324
+ redactions: { total: findings.length, byType: redactionCounts },
325
+ compression,
326
+ budget: opts.budget ? { limit: opts.budget, used: rendered.tokens } : null,
327
+ model: modelFit(opts.profile, rendered.tokens, opts.encoding),
328
+ parts,
329
+ splitFiles,
330
+ };
331
+ }
332
+
333
+ /**
334
+ * One sentence about a pack result: size, files left out, compression, redactions, model fit
335
+ * @param {object} result - From packContext()
336
+ * @returns {string}
337
+ */
338
+ export function describePack(result) {
339
+ const parts = [`${result.files.length} file(s), ${result.totalTokens} tokens (${result.style})`];
340
+ if (result.omitted.length) parts.push(`${result.omitted.length} file(s) left out by the budget`);
341
+ if (result.compression) {
342
+ parts.push(`${result.compression.savedTokens} tokens saved by compression`);
343
+ }
344
+ if (result.redactions.total) parts.push(`${result.redactions.total} secret(s) redacted`);
345
+ if (result.model) {
346
+ const fit = result.model.fits ? 'fits' : "doesn't fit";
347
+ parts.push(
348
+ `${result.model.percentOfWindow.toFixed(1)}% of ${result.model.id}'s window, ${fit}`
349
+ );
350
+ }
351
+ return `Packed ${parts.join('; ')}.`;
352
+ }
353
+
354
+ export default packContext;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Helpers shared by the pack renderers
3
+ *
4
+ * A renderer turns a document model into text:
5
+ * header text for the top, or null
6
+ * partLabel "Part 2 of 3" when the output is split, or null
7
+ * part {index, count} when the output is split, or null
8
+ * tree directory tree text, or null
9
+ * files [{path, content, tokens, lines?: [first, last], totalLines?}]
10
+ * firstIndex index of the first file (documents are numbered across parts)
11
+ * omitted {files: [{path, tokens}], count, tokens, more, moreTokens}, or null
12
+ * instructions text for the end, or null
13
+ */
14
+
15
+ /** @param {number} value */
16
+ export const formatNumber = (value) => value.toLocaleString('en-US');
17
+
18
+ /**
19
+ * Display name of a packed file; a piece of a file split by lines says which lines
20
+ * @param {{path: string, lines?: number[], totalLines?: number}} file
21
+ * @returns {string}
22
+ */
23
+ export function sourceLabel(file) {
24
+ if (!file.lines) return file.path;
25
+ return `${file.path} (lines ${file.lines[0]}-${file.lines[1]} of ${file.totalLines})`;
26
+ }
27
+
28
+ /**
29
+ * Content without its final line break (the wrapper adds one)
30
+ * @param {string} content
31
+ * @returns {string}
32
+ */
33
+ export function withoutFinalNewline(content) {
34
+ return content.endsWith('\n') ? content.slice(0, -1) : content;
35
+ }
36
+
37
+ /**
38
+ * Lines of the omitted-files section: the listed files, then a count of the rest
39
+ * @param {object} omitted - Document model `omitted`
40
+ * @param {string} bullet - Prefix of each line
41
+ * @returns {string[]}
42
+ */
43
+ export function omittedLines(omitted, bullet = '') {
44
+ const lines = omitted.files.map(
45
+ (file) => `${bullet}${file.path} (${formatNumber(file.tokens)} tokens)`
46
+ );
47
+ if (omitted.more > 0) {
48
+ lines.push(
49
+ `${bullet}… and ${formatNumber(omitted.more)} more files (${formatNumber(omitted.moreTokens)} tokens)`
50
+ );
51
+ }
52
+ return lines;
53
+ }
54
+
55
+ /**
56
+ * One-line description of the omitted files
57
+ * @param {object} omitted - Document model `omitted`
58
+ * @returns {string}
59
+ */
60
+ export function omittedSummary(omitted) {
61
+ const files = omitted.count === 1 ? '1 file' : `${formatNumber(omitted.count)} files`;
62
+ return `${files} (${formatNumber(omitted.tokens)} tokens) did not fit in the token budget`;
63
+ }
64
+
65
+ /**
66
+ * Join the non-empty sections of a document with blank lines
67
+ * @param {Array<string|null|false>} sections
68
+ * @returns {string}
69
+ */
70
+ export function joinSections(sections) {
71
+ return `${sections.filter((section) => section).join('\n\n')}\n`;
72
+ }
73
+
74
+ // Fenced-code language per extension (Markdown style)
75
+ const LANGUAGES = {
76
+ js: 'javascript',
77
+ mjs: 'javascript',
78
+ cjs: 'javascript',
79
+ jsx: 'jsx',
80
+ ts: 'typescript',
81
+ mts: 'typescript',
82
+ cts: 'typescript',
83
+ tsx: 'tsx',
84
+ py: 'python',
85
+ rb: 'ruby',
86
+ go: 'go',
87
+ rs: 'rust',
88
+ java: 'java',
89
+ kt: 'kotlin',
90
+ kts: 'kotlin',
91
+ swift: 'swift',
92
+ cs: 'csharp',
93
+ c: 'c',
94
+ h: 'c',
95
+ cc: 'cpp',
96
+ cpp: 'cpp',
97
+ cxx: 'cpp',
98
+ hpp: 'cpp',
99
+ scala: 'scala',
100
+ php: 'php',
101
+ json: 'json',
102
+ md: 'markdown',
103
+ yml: 'yaml',
104
+ yaml: 'yaml',
105
+ html: 'html',
106
+ vue: 'vue',
107
+ css: 'css',
108
+ scss: 'scss',
109
+ sass: 'sass',
110
+ less: 'less',
111
+ xml: 'xml',
112
+ svg: 'xml',
113
+ sh: 'bash',
114
+ bash: 'bash',
115
+ zsh: 'zsh',
116
+ sql: 'sql',
117
+ toml: 'toml',
118
+ ini: 'ini',
119
+ conf: 'ini',
120
+ txt: 'text',
121
+ };
122
+
123
+ /**
124
+ * Fenced-code language of a file, from its extension ('' when unknown)
125
+ * @param {string} filePath
126
+ * @returns {string}
127
+ */
128
+ export function languageOf(filePath) {
129
+ const name = filePath.split('/').pop().toLowerCase();
130
+ if (name === 'dockerfile') return 'dockerfile';
131
+ if (name === 'makefile') return 'makefile';
132
+ const dot = name.lastIndexOf('.');
133
+ return dot > 0 ? (LANGUAGES[name.slice(dot + 1)] ?? '') : '';
134
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Pack renderers by style name
3
+ */
4
+
5
+ import xml from './xml.js';
6
+ import markdown from './markdown.js';
7
+ import plain from './plain.js';
8
+ import json from './json.js';
9
+
10
+ export const RENDERERS = { xml, markdown, plain, json };
11
+
12
+ export const STYLES = Object.keys(RENDERERS);
13
+
14
+ /**
15
+ * @param {string} style - xml, markdown, plain or json
16
+ * @returns {{name: string, extension: string, render: Function, renderFile: Function}}
17
+ */
18
+ export function getRenderer(style) {
19
+ const renderer = RENDERERS[style];
20
+ if (!renderer) throw new Error(`Unknown style "${style}" (use ${STYLES.join(', ')})`);
21
+ return renderer;
22
+ }
23
+
24
+ export default { RENDERERS, STYLES, getRenderer };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * JSON style: {header?, part?, parts?, tree?, files: [{path, tokens, content}], omitted?,
3
+ * instructions?} with the instructions last
4
+ */
5
+
6
+ const fileEntry = (file) => ({
7
+ path: file.path,
8
+ ...(file.lines && { lines: file.lines, totalLines: file.totalLines }),
9
+ tokens: file.tokens,
10
+ content: file.content,
11
+ });
12
+
13
+ /**
14
+ * One file entry as it appears in the `files` array (for size estimates)
15
+ * @param {object} file - Document model file
16
+ * @returns {string}
17
+ */
18
+ export function renderFile(file) {
19
+ return JSON.stringify(fileEntry(file), null, 2).replace(/\n/g, '\n ');
20
+ }
21
+
22
+ /**
23
+ * @param {object} doc - Document model (see renderers/common.js)
24
+ * @returns {string}
25
+ */
26
+ export function render(doc) {
27
+ const output = {};
28
+ if (doc.header) output.header = doc.header;
29
+ if (doc.part) {
30
+ output.part = doc.part.index;
31
+ output.parts = doc.part.count;
32
+ }
33
+ if (doc.tree !== null) output.tree = doc.tree;
34
+ output.files = doc.files.map(fileEntry);
35
+ if (doc.omitted) {
36
+ output.omitted = {
37
+ count: doc.omitted.count,
38
+ tokens: doc.omitted.tokens,
39
+ files: doc.omitted.files.map(({ path, tokens }) => ({ path, tokens })),
40
+ ...(doc.omitted.more > 0 && { more: doc.omitted.more, moreTokens: doc.omitted.moreTokens }),
41
+ };
42
+ }
43
+ if (doc.instructions) output.instructions = doc.instructions;
44
+ return `${JSON.stringify(output, null, 2)}\n`;
45
+ }
46
+
47
+ export default { name: 'json', extension: '.json', render, renderFile };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Markdown style: a heading per file and a fenced code block whose fence is longer than any
3
+ * backtick run in the file, so the content cannot close it
4
+ */
5
+
6
+ import {
7
+ joinSections,
8
+ languageOf,
9
+ omittedLines,
10
+ omittedSummary,
11
+ sourceLabel,
12
+ withoutFinalNewline,
13
+ } from './common.js';
14
+
15
+ /**
16
+ * Backtick fence for a block of text: at least three, one more than its longest run
17
+ * @param {string} text
18
+ * @returns {string}
19
+ */
20
+ export function fenceFor(text) {
21
+ let longest = 0;
22
+ for (const run of text.match(/`+/g) || []) longest = Math.max(longest, run.length);
23
+ return '`'.repeat(Math.max(3, longest + 1));
24
+ }
25
+
26
+ /**
27
+ * One file: heading and fenced content
28
+ * @param {object} file - Document model file
29
+ * @returns {string}
30
+ */
31
+ export function renderFile(file) {
32
+ const content = withoutFinalNewline(file.content);
33
+ const fence = fenceFor(content);
34
+ return `### ${sourceLabel(file)}\n\n${fence}${languageOf(file.path)}\n${content}\n${fence}`;
35
+ }
36
+
37
+ /**
38
+ * @param {object} doc - Document model (see renderers/common.js)
39
+ * @returns {string}
40
+ */
41
+ export function render(doc) {
42
+ const treeFence = doc.tree !== null && fenceFor(doc.tree);
43
+ return joinSections([
44
+ doc.header,
45
+ doc.partLabel,
46
+ doc.tree !== null && `## Directory structure\n\n${treeFence}\n${doc.tree}\n${treeFence}`,
47
+ doc.files.length > 0 && `## Files\n\n${doc.files.map((file) => renderFile(file)).join('\n\n')}`,
48
+ doc.omitted &&
49
+ `## Omitted files\n\n${omittedSummary(doc.omitted)}:\n\n${omittedLines(doc.omitted, '- ').join('\n')}`,
50
+ doc.instructions && `## Instructions\n\n${doc.instructions}`,
51
+ ]);
52
+ }
53
+
54
+ export default { name: 'markdown', extension: '.md', render, renderFile };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Plain style: `=== path ===` separators and raw content
3
+ */
4
+
5
+ import {
6
+ joinSections,
7
+ omittedLines,
8
+ omittedSummary,
9
+ sourceLabel,
10
+ withoutFinalNewline,
11
+ } from './common.js';
12
+
13
+ /**
14
+ * One file: separator line and content
15
+ * @param {object} file - Document model file
16
+ * @returns {string}
17
+ */
18
+ export function renderFile(file) {
19
+ return `=== ${sourceLabel(file)} ===\n${withoutFinalNewline(file.content)}`;
20
+ }
21
+
22
+ /**
23
+ * @param {object} doc - Document model (see renderers/common.js)
24
+ * @returns {string}
25
+ */
26
+ export function render(doc) {
27
+ return joinSections([
28
+ doc.header,
29
+ doc.partLabel,
30
+ doc.tree !== null && `Directory structure:\n${doc.tree}`,
31
+ doc.files.length > 0 && doc.files.map((file) => renderFile(file)).join('\n\n'),
32
+ doc.omitted &&
33
+ `Omitted files: ${omittedSummary(doc.omitted)}.\n${omittedLines(doc.omitted, ' ').join('\n')}`,
34
+ doc.instructions && `Instructions:\n${doc.instructions}`,
35
+ ]);
36
+ }
37
+
38
+ export default { name: 'plain', extension: '.txt', render, renderFile };