@pugi/cli 0.1.0-beta.12 → 0.1.0-beta.13

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 (57) hide show
  1. package/dist/core/consensus/diff-capture.js +73 -0
  2. package/dist/core/context/index.js +7 -0
  3. package/dist/core/context/markdown-traverse.js +255 -0
  4. package/dist/core/edits/dispatch.js +218 -2
  5. package/dist/core/edits/journal.js +199 -0
  6. package/dist/core/edits/layer-d-ast.js +557 -14
  7. package/dist/core/edits/verify-hook.js +273 -0
  8. package/dist/core/engine/anvil-client.js +80 -5
  9. package/dist/core/engine/context-prefix.js +155 -0
  10. package/dist/core/engine/intent.js +260 -0
  11. package/dist/core/engine/native-pugi.js +663 -249
  12. package/dist/core/engine/prompts.js +52 -2
  13. package/dist/core/engine/tool-bridge.js +311 -9
  14. package/dist/core/lsp/client.js +57 -0
  15. package/dist/core/mcp/client.js +9 -0
  16. package/dist/core/mcp/http-server.js +553 -0
  17. package/dist/core/mcp/permission.js +190 -0
  18. package/dist/core/mcp/server-tools.js +219 -0
  19. package/dist/core/mcp/server.js +397 -0
  20. package/dist/core/repl/history.js +11 -1
  21. package/dist/core/repl/model-pricing.js +135 -0
  22. package/dist/core/repl/session.js +328 -12
  23. package/dist/core/repl/slash-commands.js +18 -4
  24. package/dist/core/settings.js +43 -0
  25. package/dist/core/subagents/dispatcher-real.js +600 -0
  26. package/dist/core/subagents/dispatcher.js +113 -24
  27. package/dist/core/subagents/index.js +18 -5
  28. package/dist/core/subagents/isolation-matrix.js +213 -0
  29. package/dist/core/subagents/spawn.js +19 -4
  30. package/dist/core/transport/version-interceptor.js +166 -0
  31. package/dist/index.js +28 -0
  32. package/dist/runtime/bootstrap.js +190 -0
  33. package/dist/runtime/cli.js +534 -268
  34. package/dist/runtime/commands/lsp.js +165 -5
  35. package/dist/runtime/commands/mcp.js +537 -0
  36. package/dist/runtime/headless.js +543 -0
  37. package/dist/runtime/load-hooks-or-exit.js +71 -0
  38. package/dist/runtime/version.js +65 -0
  39. package/dist/tools/agent-tool.js +192 -0
  40. package/dist/tools/apply-patch.js +62 -1
  41. package/dist/tools/mcp-tool.js +260 -0
  42. package/dist/tools/multi-edit.js +361 -0
  43. package/dist/tools/registry.js +5 -0
  44. package/dist/tools/web-fetch.js +147 -2
  45. package/dist/tools/web-search.js +458 -0
  46. package/dist/tui/agent-tree.js +10 -0
  47. package/dist/tui/ask-modal.js +2 -2
  48. package/dist/tui/conversation-pane.js +1 -1
  49. package/dist/tui/input-box.js +1 -1
  50. package/dist/tui/markdown-render.js +4 -4
  51. package/dist/tui/repl-render.js +105 -15
  52. package/dist/tui/repl-splash.js +2 -2
  53. package/dist/tui/repl.js +10 -4
  54. package/dist/tui/splash.js +1 -1
  55. package/dist/tui/status-bar.js +94 -16
  56. package/dist/tui/update-banner.js +20 -2
  57. package/package.json +5 -4
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Verify hook — β1b Pl10 (2026-05-26).
3
+ *
4
+ * After the edit-dispatcher writes a multi-file change to the
5
+ * workspace, this hook fires three lightweight checks against the
6
+ * post-state and reports each result back to the engine loop as a
7
+ * status event:
8
+ *
9
+ * 1. tsc — if `tsconfig.json` is present in the workspace root, run
10
+ * `tsc --noEmit` to catch compile-time breakage. Pass/fail tracked
11
+ * per file is overkill at this stage; we surface the exit code +
12
+ * first ~40 lines of stderr.
13
+ * 2. tests — if package.json has a `test` script AND a test runner
14
+ * is available (jest / vitest / node --test), run `pnpm test
15
+ * --bail` (or `npm test --bail`). Same exit-code + tail of output
16
+ * contract.
17
+ * 3. URL probes — extract every `https?://...` literal from the diff
18
+ * (README + code) and HEAD-probe each. A response < 400 counts as
19
+ * live; >=400 surfaces as a warning. Capped at 8 unique URLs per
20
+ * hook to avoid spending the budget on doc rot.
21
+ *
22
+ * Retry contract (β1b r1 rescope): the hook itself is stateless and
23
+ * runs ONCE per invocation, returning a structured report. The
24
+ * "feedback → model → re-edit" retry orchestrator does NOT exist yet
25
+ * in the engine loop — it was promised as part of β1b Pl10 but never
26
+ * shipped because the engine refactor that hosts it is bigger than
27
+ * the verify-hook itself can absorb. Retry orchestration is deferred
28
+ * to β6 plan-mode integration where the loop already needs a
29
+ * model→hook feedback channel for plan replay. Today the operator
30
+ * re-runs `pugi code` to re-drive verification after a fail. The
31
+ * stateless hook ships unchanged so the β6 driver can wrap it.
32
+ *
33
+ * Why HEAD and not GET for URL probes:
34
+ * - HEAD avoids the body fetch; cheaper + faster.
35
+ * - SSRF guard from `web-fetch.ts::validateHostnameForFetch` runs
36
+ * before every probe so private IPs / localhost cannot ride.
37
+ * - Some servers reject HEAD (rare but real); on 4xx-from-HEAD we
38
+ * do NOT escalate to GET — that would burn the budget. We surface
39
+ * a `head_rejected` warning so the operator decides.
40
+ *
41
+ * Skip cases (return early with `skipped: true` on the relevant
42
+ * check):
43
+ * - tsc: no `tsconfig.json` at workspace root.
44
+ * - tests: no `package.json` OR no `test` script.
45
+ * - urls: no `https?://...` literals in the diff.
46
+ *
47
+ * Best-effort: every failure mode degrades to a structured report; the
48
+ * hook itself NEVER throws. The engine loop decides whether to
49
+ * surface as a hard fail vs a model-correctable warning.
50
+ *
51
+ * Brand voice: ASCII only, no banned words.
52
+ */
53
+ import { spawnSync } from 'node:child_process';
54
+ import { existsSync, readFileSync } from 'node:fs';
55
+ import { resolve } from 'node:path';
56
+ import { validateHostnameForFetch } from '../../tools/web-fetch.js';
57
+ const DEFAULT_TIMEOUT_MS = 30_000;
58
+ const DEFAULT_MAX_URL_PROBES = 8;
59
+ const URL_LITERAL_RE = /(https?:\/\/[^\s"'<>()`\\\]]+)/g;
60
+ /**
61
+ * Drive one verify pass. Synchronous tsc + test child processes,
62
+ * concurrent URL probes (up to `maxUrlProbes`). Returns once every
63
+ * check has completed (no streaming events — the caller wraps the
64
+ * report into its own status event format).
65
+ */
66
+ export async function runVerifyHook(input) {
67
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
68
+ const runProc = input.runProc ?? defaultRunProc;
69
+ return {
70
+ tsc: runTscCheck(input.workspaceRoot, runProc, timeoutMs),
71
+ tests: runTestsCheck(input.workspaceRoot, runProc, timeoutMs),
72
+ urls: await runUrlChecks(input),
73
+ };
74
+ }
75
+ /* ----------------------- tsc check ---------------------- */
76
+ function runTscCheck(workspaceRoot, runProc, timeoutMs) {
77
+ const tsconfig = resolve(workspaceRoot, 'tsconfig.json');
78
+ if (!existsSync(tsconfig)) {
79
+ return { ok: true, skipped: true, reason: 'no_tsconfig' };
80
+ }
81
+ // Prefer `pnpm exec tsc` because pnpm-aware monorepos hoist tsc into
82
+ // `node_modules/.bin`; fallback to bare `tsc` for global installs.
83
+ // We try `pnpm exec tsc` first only if a `pnpm-lock.yaml` is at the
84
+ // workspace root; otherwise we go straight to `tsc`.
85
+ const pnpmLock = existsSync(resolve(workspaceRoot, 'pnpm-lock.yaml'));
86
+ const cmd = pnpmLock ? 'pnpm' : 'tsc';
87
+ const args = pnpmLock ? ['exec', 'tsc', '--noEmit'] : ['--noEmit'];
88
+ const result = runProc(cmd, args, workspaceRoot, timeoutMs);
89
+ if (result.exitCode === 0)
90
+ return { ok: true };
91
+ return {
92
+ ok: false,
93
+ reason: `tsc_exit_${result.exitCode}`,
94
+ detail: tailOutput(result.stdout, result.stderr, 40),
95
+ };
96
+ }
97
+ /* ----------------------- tests check ---------------------- */
98
+ function runTestsCheck(workspaceRoot, runProc, timeoutMs) {
99
+ const pkgPath = resolve(workspaceRoot, 'package.json');
100
+ if (!existsSync(pkgPath)) {
101
+ return { ok: true, skipped: true, reason: 'no_package_json' };
102
+ }
103
+ let pkg;
104
+ try {
105
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
106
+ }
107
+ catch {
108
+ return { ok: true, skipped: true, reason: 'malformed_package_json' };
109
+ }
110
+ if (!pkg.scripts || typeof pkg.scripts.test !== 'string') {
111
+ return { ok: true, skipped: true, reason: 'no_test_script' };
112
+ }
113
+ const pnpmLock = existsSync(resolve(workspaceRoot, 'pnpm-lock.yaml'));
114
+ // Prefer pnpm test --bail; some test runners reject the flag (node
115
+ // --test ignores it), so we surface non-zero exits clearly but do
116
+ // not retry without --bail.
117
+ // Both pnpm + npm accept `<cmd> test -- --bail`; the runner-side
118
+ // flag-forwarding contract is identical, so the ternary collapses to
119
+ // a single args literal.
120
+ const cmd = pnpmLock ? 'pnpm' : 'npm';
121
+ const args = ['test', '--', '--bail'];
122
+ const result = runProc(cmd, args, workspaceRoot, timeoutMs);
123
+ if (result.exitCode === 0)
124
+ return { ok: true };
125
+ return {
126
+ ok: false,
127
+ reason: `tests_exit_${result.exitCode}`,
128
+ detail: tailOutput(result.stdout, result.stderr, 60),
129
+ };
130
+ }
131
+ /* ----------------------- url probes ---------------------- */
132
+ async function runUrlChecks(input) {
133
+ const diffText = input.diffText ?? '';
134
+ if (diffText.length === 0) {
135
+ return { ok: true, skipped: true, reason: 'no_diff_text' };
136
+ }
137
+ const urls = extractUrls(diffText);
138
+ if (urls.length === 0) {
139
+ return { ok: true, skipped: true, reason: 'no_urls' };
140
+ }
141
+ const cap = input.maxUrlProbes ?? DEFAULT_MAX_URL_PROBES;
142
+ const probed = urls.slice(0, cap);
143
+ const probeFn = input.probeFn ?? defaultProbeFn;
144
+ const failures = [];
145
+ for (const url of probed) {
146
+ // Hostname SSRF guard — never probe a localhost / private IP even
147
+ // when a literal in the diff points there.
148
+ let parsed;
149
+ try {
150
+ parsed = new URL(url);
151
+ }
152
+ catch {
153
+ failures.push({ url, error: 'invalid_url' });
154
+ continue;
155
+ }
156
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
157
+ failures.push({ url, error: `unsupported_scheme_${parsed.protocol}` });
158
+ continue;
159
+ }
160
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
161
+ const guard = await validateHostnameForFetch(hostname);
162
+ if (guard) {
163
+ failures.push({ url, error: `ssrf_refused: ${guard}` });
164
+ continue;
165
+ }
166
+ try {
167
+ const r = await probeFn(url);
168
+ if ('error' in r) {
169
+ failures.push({ url, error: r.error });
170
+ continue;
171
+ }
172
+ if (r.status >= 400) {
173
+ failures.push({ url, error: `http_${r.status}` });
174
+ }
175
+ }
176
+ catch (error) {
177
+ failures.push({
178
+ url,
179
+ error: error instanceof Error ? error.message : String(error),
180
+ });
181
+ }
182
+ }
183
+ if (failures.length === 0) {
184
+ return { ok: true, probedCount: probed.length };
185
+ }
186
+ return {
187
+ ok: false,
188
+ reason: `url_probe_failed`,
189
+ probedCount: probed.length,
190
+ failures,
191
+ detail: failures.map((f) => `${f.url} → ${f.error}`).join('; '),
192
+ };
193
+ }
194
+ /**
195
+ * Extract unique http(s) URLs from a diff/text blob. Order preserved
196
+ * (first-seen) so the cap picks the earliest-mentioned ones, which
197
+ * intuitively matches the operator's expectation.
198
+ */
199
+ export function extractUrls(text) {
200
+ const seen = new Set();
201
+ const out = [];
202
+ let match;
203
+ // Reset the regex's lastIndex; URL_LITERAL_RE is module-scoped and
204
+ // /g means stateful exec calls.
205
+ URL_LITERAL_RE.lastIndex = 0;
206
+ while ((match = URL_LITERAL_RE.exec(text)) !== null) {
207
+ const raw = match[1];
208
+ if (!raw)
209
+ continue;
210
+ // Strip a trailing punctuation that the regex tolerates inside
211
+ // the match (sentences in Markdown often end `... https://x).`).
212
+ // We also strip `!` and `?` so prose like "see https://x!" lands
213
+ // as `https://x`.
214
+ const cleaned = raw.replace(/[.,;:!?)\]>]+$/, '');
215
+ if (cleaned.length === 0)
216
+ continue;
217
+ if (seen.has(cleaned))
218
+ continue;
219
+ seen.add(cleaned);
220
+ out.push(cleaned);
221
+ }
222
+ return out;
223
+ }
224
+ /* ----------------------- defaults ---------------------- */
225
+ function defaultRunProc(cmd, args, cwd, timeoutMs) {
226
+ const result = spawnSync(cmd, [...args], {
227
+ cwd,
228
+ encoding: 'utf8',
229
+ timeout: timeoutMs,
230
+ // Inherit a minimal env — every check is read-only against the
231
+ // workspace and we do not want to leak PUGI_API_KEY into a
232
+ // sub-process accidentally.
233
+ env: { ...process.env, PUGI_API_KEY: undefined, PUGI_LOGIN_TOKEN: undefined },
234
+ });
235
+ return {
236
+ exitCode: typeof result.status === 'number' ? result.status : -1,
237
+ stdout: result.stdout ?? '',
238
+ stderr: result.stderr ?? '',
239
+ };
240
+ }
241
+ async function defaultProbeFn(url) {
242
+ // Lazy-import undici so the verify-hook module stays cheap when
243
+ // url probes are skipped.
244
+ const { request } = await import('undici');
245
+ try {
246
+ const response = await request(url, {
247
+ method: 'HEAD',
248
+ bodyTimeout: 5_000,
249
+ headersTimeout: 5_000,
250
+ });
251
+ // Drain so the connection releases promptly.
252
+ try {
253
+ await response.body.dump();
254
+ }
255
+ catch {
256
+ /* swallow */
257
+ }
258
+ return { status: response.statusCode };
259
+ }
260
+ catch (error) {
261
+ return { error: error instanceof Error ? error.message : String(error) };
262
+ }
263
+ }
264
+ function tailOutput(stdout, stderr, maxLines) {
265
+ const merged = `${stdout}\n${stderr}`.trim();
266
+ if (merged.length === 0)
267
+ return '';
268
+ const lines = merged.split('\n');
269
+ if (lines.length <= maxLines)
270
+ return merged;
271
+ return `... (${lines.length - maxLines} earlier lines elided)\n${lines.slice(-maxLines).join('\n')}`;
272
+ }
273
+ //# sourceMappingURL=verify-hook.js.map
@@ -1,3 +1,10 @@
1
+ // PR-CLI-SERVER-VERSION-HANDSHAKE (#225). The interceptor stamps the
2
+ // outbound X-Pugi-Cli-Version header, inspects the inbound recommended/
3
+ // server-version headers, and throws PugiCliUpgradeRequiredError on a
4
+ // 426 server response. The top-level catch in `runtime/cli.ts` /
5
+ // `index.ts` renders the upgrade message and exits 1.
6
+ import { assertNotUpgradeRequired, injectClientVersionHeader, inspectVersionResponse, } from '../transport/version-interceptor.js';
7
+ import { PUGI_CLI_VERSION } from '../../runtime/version.js';
1
8
  /**
2
9
  * Anvil-backed engine loop client.
3
10
  *
@@ -54,13 +61,17 @@ export class AnvilEngineLoopClient {
54
61
  options.signal.addEventListener('abort', onAbort);
55
62
  const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
56
63
  try {
64
+ // PR-CLI-SERVER-VERSION-HANDSHAKE (#225). Stamp the outbound
65
+ // X-Pugi-Cli-Version header so the admin-api middleware can
66
+ // decide whether to honour, soft-warn, or 426 this request.
67
+ const outboundHeaders = injectClientVersionHeader({
68
+ 'content-type': 'application/json',
69
+ authorization: `Bearer ${this.config.apiKey}`,
70
+ 'user-agent': 'pugi-cli/0.0.1',
71
+ }, PUGI_CLI_VERSION);
57
72
  const res = await fetch(url, {
58
73
  method: 'POST',
59
- headers: {
60
- 'content-type': 'application/json',
61
- authorization: `Bearer ${this.config.apiKey}`,
62
- 'user-agent': 'pugi-cli/0.0.1',
63
- },
74
+ headers: outboundHeaders,
64
75
  body: JSON.stringify({
65
76
  personaSlug: options.personaSlug,
66
77
  messages,
@@ -87,6 +98,40 @@ export class AnvilEngineLoopClient {
87
98
  signal: controller.signal,
88
99
  });
89
100
  const text = await res.text();
101
+ // PR-CLI-SERVER-VERSION-HANDSHAKE: cache server-recommended +
102
+ // server-version headers so UpdateBanner / `pugi doctor` can
103
+ // surface them, then short-circuit on 426 by throwing
104
+ // PugiCliUpgradeRequiredError. The throw bubbles to the
105
+ // top-level catch in index.ts which renders the upgrade banner.
106
+ // The getter shim handles both real `Response` (`.headers.get`)
107
+ // and minimal fixture/stub responses (`.headers?.[name]`) so
108
+ // existing transport tests that mock `fetch` with `{status, text}`
109
+ // don't need to grow a Headers polyfill just to keep passing.
110
+ //
111
+ // Cache poison guard: skip the inspect step on 426. A hostile
112
+ // upstream (proxy with a compromised cert pin, or a transient MITM
113
+ // on a coffee-shop network) could otherwise forge an
114
+ // `X-Pugi-Cli-Upgrade-Recommended` header alongside a 426 status
115
+ // and poison `cachedServerRecommendation` for the rest of the REPL
116
+ // session — `UpdateBanner` would then surface attacker-chosen
117
+ // version strings to the operator. The 426 body still carries the
118
+ // legitimate `recommendedVersion` field, which assertNotUpgrade-
119
+ // Required parses + throws with, so the operator-facing banner
120
+ // remains accurate via the error path.
121
+ if (res.status !== 426) {
122
+ inspectVersionResponse((name) => {
123
+ const h = res.headers;
124
+ if (h && typeof h.get === 'function') {
125
+ return h.get(name);
126
+ }
127
+ if (h && typeof h === 'object') {
128
+ const lowered = h[name.toLowerCase()];
129
+ return lowered ?? null;
130
+ }
131
+ return null;
132
+ });
133
+ }
134
+ assertNotUpgradeRequired(res.status, text, PUGI_CLI_VERSION);
90
135
  if (res.status === 200) {
91
136
  try {
92
137
  const json = JSON.parse(text);
@@ -135,6 +180,36 @@ export class AnvilEngineLoopClient {
135
180
  };
136
181
  }
137
182
  if (res.status === 401 || res.status === 403) {
183
+ // 403 has two distinct causes:
184
+ // 1. genuinely invalid / expired token (auth_missing) — the
185
+ // old default.
186
+ // 2. tenant authenticated successfully but the privacy mode
187
+ // (strict / balanced policy) refused upstream LLM dispatch.
188
+ // The admin-api returns
189
+ // `{ code: 'privacy_strict_upstream_blocked', mode, model,
190
+ // message: '...switch via pugi config set privacy=...' }`.
191
+ // Reported as `auth_missing` the user runs `pugi login`
192
+ // again, which does nothing — the actual fix is a privacy-
193
+ // mode change. Parse the body and route accordingly.
194
+ // (2026-05-27 P0.3 — dogfood surfaced this on /api/pugi/engine
195
+ // for a strict-mode tenant; see memory feedback_no_fake_dispatch_promises
196
+ // for the broader "misleading error" pattern.)
197
+ try {
198
+ const parsed = text ? JSON.parse(text) : null;
199
+ if (parsed?.code === 'privacy_strict_upstream_blocked' || parsed?.code === 'privacy_blocked') {
200
+ return {
201
+ stop: 'error',
202
+ code: 'privacy_blocked',
203
+ message: parsed.message ?? 'Tenant privacy mode forbids upstream LLM dispatch.',
204
+ remediation: 'pugi config set privacy=balanced — OR configure a self-hosted Anvil model.',
205
+ };
206
+ }
207
+ }
208
+ catch {
209
+ // Body not JSON — fall through to the generic auth_missing
210
+ // branch below; the 200-char text echo on `failed` will at
211
+ // least give the operator the raw response to triage.
212
+ }
138
213
  return {
139
214
  stop: 'error',
140
215
  code: 'auth_missing',
@@ -0,0 +1,155 @@
1
+ /** Hard cap on the rendered `<context>` block, bytes. */
2
+ export const CONTEXT_PREFIX_MAX_BYTES = 5 * 1024;
3
+ /** Hard cap on working-set entries surfaced in the prefix. */
4
+ export const CONTEXT_PREFIX_MAX_WORKING_SET = 50;
5
+ /** Hard cap on per-dir markdown files surfaced inline. */
6
+ export const CONTEXT_PREFIX_MAX_MARKDOWN = 3;
7
+ /** Per-markdown-file inline excerpt cap, bytes. Keeps any one file from dominating. */
8
+ export const CONTEXT_PREFIX_MAX_PER_MARKDOWN_BYTES = 1024;
9
+ /**
10
+ * Build the `<context>` block. Always returns a result — when there
11
+ * is nothing to surface (no cwd hint, no working set, no per-dir
12
+ * files), returns `{ block: '', bytes: 0, truncated: false, counts: ... }`
13
+ * so the caller can short-circuit the splice cleanly.
14
+ *
15
+ * Determinism: same input always produces byte-identical output. The
16
+ * working-set order comes from the caller's summary; we preserve it
17
+ * verbatim. Per-dir files are sorted by `distanceFromCwd` ascending
18
+ * (closest first) so two equal `TraversedMarkdown` arrays produce
19
+ * identical blocks.
20
+ */
21
+ export function buildContextPrefix(input) {
22
+ const lines = [];
23
+ let bytes = 0;
24
+ let truncated = false;
25
+ // Walk a write-then-measure loop so we never overflow the byte cap.
26
+ // Each push checks budget; when full, set `truncated = true` and
27
+ // stop. The opener / closer tags always fit (small) — we reserve
28
+ // their byte cost up-front from the budget.
29
+ const opener = '<context>';
30
+ const closer = '</context>';
31
+ const reservedTagBytes = Buffer.byteLength(opener, 'utf8') + 1 + Buffer.byteLength(closer, 'utf8') + 1;
32
+ let budget = CONTEXT_PREFIX_MAX_BYTES - reservedTagBytes;
33
+ if (budget < 0) {
34
+ return {
35
+ block: '',
36
+ bytes: 0,
37
+ truncated: false,
38
+ counts: {
39
+ workingSetIncluded: 0,
40
+ workingSetTotal: input.workingSet?.length ?? 0,
41
+ markdownIncluded: 0,
42
+ markdownTotal: input.traversedMarkdown?.length ?? 0,
43
+ },
44
+ };
45
+ }
46
+ const pushLine = (line) => {
47
+ const lineBytes = Buffer.byteLength(line, 'utf8') + 1; // newline
48
+ if (lineBytes > budget) {
49
+ truncated = true;
50
+ return false;
51
+ }
52
+ lines.push(line);
53
+ budget -= lineBytes;
54
+ bytes += lineBytes;
55
+ return true;
56
+ };
57
+ // cwd — always cheap, always first.
58
+ pushLine(`cwd: ${input.cwdRelative || '.'}`);
59
+ // intent hint, if provided.
60
+ if (input.intentHint) {
61
+ pushLine(`intent: ${input.intentHint}`);
62
+ }
63
+ // Working set — render `open-files:` only when there is at least one entry.
64
+ const wsTotal = input.workingSet?.length ?? 0;
65
+ let wsIncluded = 0;
66
+ if (wsTotal > 0 && input.workingSet) {
67
+ const wsCap = Math.min(CONTEXT_PREFIX_MAX_WORKING_SET, wsTotal);
68
+ pushLine('open-files:');
69
+ for (let i = 0; i < wsCap; i += 1) {
70
+ const entry = input.workingSet[i];
71
+ if (!entry)
72
+ continue;
73
+ const ok = pushLine(` - ${entry.absPath}`);
74
+ if (!ok)
75
+ break;
76
+ wsIncluded += 1;
77
+ }
78
+ if (wsTotal > wsCap) {
79
+ pushLine(` ... (+${wsTotal - wsCap} more)`);
80
+ truncated = truncated || true;
81
+ }
82
+ }
83
+ // Per-dir markdown — closest-first, max 3, each capped to 1 KB excerpt.
84
+ const mdTotal = input.traversedMarkdown?.length ?? 0;
85
+ let mdIncluded = 0;
86
+ if (mdTotal > 0 && input.traversedMarkdown) {
87
+ const sorted = [...input.traversedMarkdown].sort((a, b) => a.distanceFromCwd - b.distanceFromCwd);
88
+ const top = sorted.slice(0, CONTEXT_PREFIX_MAX_MARKDOWN);
89
+ if (top.length > 0) {
90
+ pushLine('per-dir-conventions:');
91
+ for (const md of top) {
92
+ // Header line names the source file for traceability.
93
+ const header = ` [${md.resolvedPath}]`;
94
+ if (!pushLine(header))
95
+ break;
96
+ // Excerpt: cap to 1 KB, single-line collapse (replace newlines
97
+ // with " | " so the YAML-ish block stays parseable by humans).
98
+ const excerpt = excerpt1KB(md.content);
99
+ // Indent two spaces so it nests under the file header.
100
+ const indented = excerpt.split('\n').map((l) => ` ${l}`).join('\n');
101
+ if (!pushLine(indented))
102
+ break;
103
+ mdIncluded += 1;
104
+ }
105
+ if (mdTotal > top.length) {
106
+ pushLine(` ... (+${mdTotal - top.length} more files; closest-3 shown)`);
107
+ truncated = truncated || true;
108
+ }
109
+ }
110
+ }
111
+ if (lines.length === 0) {
112
+ return {
113
+ block: '',
114
+ bytes: 0,
115
+ truncated: false,
116
+ counts: {
117
+ workingSetIncluded: 0,
118
+ workingSetTotal: wsTotal,
119
+ markdownIncluded: 0,
120
+ markdownTotal: mdTotal,
121
+ },
122
+ };
123
+ }
124
+ const block = [opener, ...lines, closer].join('\n');
125
+ const totalBytes = Buffer.byteLength(block, 'utf8');
126
+ return {
127
+ block,
128
+ bytes: totalBytes,
129
+ truncated,
130
+ counts: {
131
+ workingSetIncluded: wsIncluded,
132
+ workingSetTotal: wsTotal,
133
+ markdownIncluded: mdIncluded,
134
+ markdownTotal: mdTotal,
135
+ },
136
+ };
137
+ }
138
+ /**
139
+ * Splice a built context block onto the front of a user message.
140
+ * Empty block → message returned verbatim (no leading blank line, no
141
+ * empty `<context>` tag).
142
+ */
143
+ export function spliceContextPrefix(block, userMessage) {
144
+ if (block.length === 0)
145
+ return userMessage;
146
+ return `${block}\n\n${userMessage}`;
147
+ }
148
+ function excerpt1KB(content) {
149
+ const capped = content.length <= CONTEXT_PREFIX_MAX_PER_MARKDOWN_BYTES
150
+ ? content
151
+ : content.slice(0, CONTEXT_PREFIX_MAX_PER_MARKDOWN_BYTES);
152
+ // Trim trailing newlines so the YAML-ish render stays tight.
153
+ return capped.replace(/\s+$/g, '');
154
+ }
155
+ //# sourceMappingURL=context-prefix.js.map