@pugi/cli 0.1.0-beta.21 → 0.1.0-beta.23

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 (63) hide show
  1. package/dist/core/auth/env-provider.js +238 -0
  2. package/dist/core/bare-mode/index.js +107 -0
  3. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  4. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  5. package/dist/core/engine/native-pugi.js +55 -11
  6. package/dist/core/engine/prompts.js +30 -2
  7. package/dist/core/engine/tool-bridge.js +32 -0
  8. package/dist/core/feedback/queue.js +177 -0
  9. package/dist/core/feedback/submitter.js +145 -0
  10. package/dist/core/onboarding/marker.js +111 -0
  11. package/dist/core/onboarding/telemetry-state.js +108 -0
  12. package/dist/core/output-style/presets.js +176 -0
  13. package/dist/core/output-style/state.js +185 -0
  14. package/dist/core/permissions/index.js +1 -1
  15. package/dist/core/permissions/state.js +55 -0
  16. package/dist/core/pugi-md/context-injector.js +76 -0
  17. package/dist/core/pugi-md/walk-up.js +207 -0
  18. package/dist/core/release-notes/parser.js +241 -0
  19. package/dist/core/release-notes/state.js +116 -0
  20. package/dist/core/repl/session.js +482 -12
  21. package/dist/core/repl/slash-commands.js +134 -1
  22. package/dist/core/repl/workspace-context.js +22 -0
  23. package/dist/core/share/formatter.js +271 -0
  24. package/dist/core/share/redactor.js +221 -0
  25. package/dist/core/share/uploader.js +267 -0
  26. package/dist/core/theme/context.js +91 -0
  27. package/dist/core/theme/presets.js +228 -0
  28. package/dist/core/theme/state.js +181 -0
  29. package/dist/core/todos/invariant.js +10 -0
  30. package/dist/core/todos/state.js +177 -0
  31. package/dist/core/vim/keymap.js +288 -0
  32. package/dist/core/vim/state.js +92 -0
  33. package/dist/runtime/cli.js +603 -15
  34. package/dist/runtime/commands/doctor.js +21 -0
  35. package/dist/runtime/commands/feedback.js +184 -0
  36. package/dist/runtime/commands/onboarding.js +275 -0
  37. package/dist/runtime/commands/plan.js +143 -0
  38. package/dist/runtime/commands/release-notes.js +229 -0
  39. package/dist/runtime/commands/share.js +316 -0
  40. package/dist/runtime/commands/stickers.js +82 -0
  41. package/dist/runtime/commands/style.js +194 -0
  42. package/dist/runtime/commands/theme.js +196 -0
  43. package/dist/runtime/commands/vim.js +140 -0
  44. package/dist/runtime/version.js +1 -1
  45. package/dist/tools/registry.js +8 -0
  46. package/dist/tools/todo-write.js +184 -0
  47. package/dist/tui/compact-banner.js +28 -1
  48. package/dist/tui/conversation-pane.js +13 -0
  49. package/dist/tui/doctor-table.js +32 -17
  50. package/dist/tui/feedback-prompt.js +156 -0
  51. package/dist/tui/onboarding-wizard.js +240 -0
  52. package/dist/tui/repl-render.js +26 -3
  53. package/dist/tui/repl.js +9 -1
  54. package/dist/tui/stickers-art.js +136 -0
  55. package/dist/tui/style-table.js +28 -0
  56. package/dist/tui/theme-table.js +29 -0
  57. package/dist/tui/vim-input.js +267 -0
  58. package/package.json +2 -2
  59. package/dist/core/engine/compaction-hook.js +0 -154
  60. package/dist/core/init/scaffold.js +0 -195
  61. package/dist/core/repl/codebase-survey.js +0 -308
  62. package/dist/core/repl/init-interview.js +0 -457
  63. package/dist/core/repl/onboarding-state.js +0 -297
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Leak L18 (2026-05-27) — Output-style presets.
3
+ *
4
+ * Mirror of Claude Code's `/output-style` surface: a small closed set of
5
+ * named voice presets the operator can flip between at session start so
6
+ * the model's prose lands in the register they prefer. The preset
7
+ * compiles into an `<output-style>` rule block appended to the engine
8
+ * system prompt; tool-use / code-block formatting / file edits are NOT
9
+ * affected — the preset only steers prose register.
10
+ *
11
+ * Design contract:
12
+ *
13
+ * - The catalogue is intentionally tiny (5 entries) so the operator
14
+ * can hold the full surface in working memory. Adding entries means
15
+ * adding a row in `OUTPUT_STYLES` plus a spec assertion; there is
16
+ * no plugin surface today.
17
+ *
18
+ * - `default` is the only preset that emits NO rule block. The
19
+ * "current Pugi voice" already lives in the base engine prompt
20
+ * (jargon ban, brand voice, terse register), so re-stating it
21
+ * under `<output-style>` would double the model's instruction load
22
+ * for the most-common case. Other presets emit the block.
23
+ *
24
+ * - Rule-block prose stays terse and operator-grade (brandbook §08).
25
+ * No friendly hedging, no AI-assistant framing. The bullets are
26
+ * the model's contract; the section title carries the preset name
27
+ * so the model can self-correct mid-turn if it drifts ("I am in
28
+ * terse mode → drop articles").
29
+ *
30
+ * - The Russian-formal preset uses вы-form explicitly. Russian/
31
+ * Ukrainian chat is permitted by the base voice contract; this
32
+ * preset hardens the register for B2B / enterprise demo flows
33
+ * where ты-form reads as too casual.
34
+ *
35
+ * - The Casual preset RELAXES the jargon ban — contractions, jokes,
36
+ * informal phrasing are allowed. It does NOT lift the brand-voice
37
+ * em-dash / emoji ban; those are typographic, not register, and
38
+ * remain off across every preset.
39
+ *
40
+ * Test surface: `test/commands/output-style-presets.spec.ts` exercises
41
+ * the catalogue invariants (5 entries, unique slugs, every non-default
42
+ * preset emits a non-empty block, the block starts with the expected
43
+ * marker so the engine prompt appender can locate it for stripping).
44
+ */
45
+ /**
46
+ * The closed list of preset slugs in catalogue order. Mirror used by
47
+ * the CLI surface (`/style` table, `pugi style --list`) so the
48
+ * operator sees presets in a stable order regardless of catalogue
49
+ * iteration order.
50
+ */
51
+ export const OUTPUT_STYLE_SLUGS = Object.freeze([
52
+ 'default',
53
+ 'terse',
54
+ 'explanatory',
55
+ 'russian-formal',
56
+ 'casual',
57
+ ]);
58
+ /**
59
+ * Default slug used when no workspace-/user-level preference is set.
60
+ * Exported so `state.ts` and the CLI handler share one constant.
61
+ */
62
+ export const DEFAULT_OUTPUT_STYLE = 'default';
63
+ /**
64
+ * Catalogue keyed by slug. Frozen so callers cannot mutate the
65
+ * shared rows; the CLI handler returns slugs, not preset references,
66
+ * to keep the boundary clean.
67
+ */
68
+ export const OUTPUT_STYLES = Object.freeze({
69
+ default: Object.freeze({
70
+ slug: 'default',
71
+ title: 'Default',
72
+ gloss: 'Current Pugi voice (no override). Base engine prompt rules apply unchanged.',
73
+ rules: Object.freeze([]),
74
+ }),
75
+ terse: Object.freeze({
76
+ slug: 'terse',
77
+ title: 'Terse',
78
+ gloss: 'Fragments, dropped articles, one short sentence per turn.',
79
+ rules: Object.freeze([
80
+ 'Drop articles, fillers, hedging',
81
+ '1 short sentence per turn for prose answers',
82
+ 'Code blocks unchanged — never abbreviate code',
83
+ 'Quote errors verbatim with no paraphrase',
84
+ ]),
85
+ }),
86
+ explanatory: Object.freeze({
87
+ slug: 'explanatory',
88
+ title: 'Explanatory',
89
+ gloss: 'Verbose, walks reasoning step by step, links concepts.',
90
+ rules: Object.freeze([
91
+ 'Explain reasoning, not just the conclusion',
92
+ 'Cite relevant files + line numbers when grounding claims',
93
+ 'Link adjacent concepts the operator may want to chase',
94
+ 'Code blocks unchanged — annotate around, not inside',
95
+ ]),
96
+ }),
97
+ 'russian-formal': Object.freeze({
98
+ slug: 'russian-formal',
99
+ title: 'Russian formal',
100
+ gloss: 'Russian вы-form, professional register, no slang.',
101
+ rules: Object.freeze([
102
+ 'Pisat\' otvety po-russki (Russian prose; ASCII transliteration permitted in this rule block only)',
103
+ 'Address the operator using вы-form, never ты',
104
+ 'No slang, no contractions of Russian forms',
105
+ 'Code blocks + identifiers stay in English unchanged',
106
+ 'Error messages quoted verbatim in the original language',
107
+ ]),
108
+ }),
109
+ casual: Object.freeze({
110
+ slug: 'casual',
111
+ title: 'Casual',
112
+ gloss: 'Informal register, contractions OK, dry jokes welcome.',
113
+ rules: Object.freeze([
114
+ 'Contractions allowed (it\'s, don\'t, you\'re)',
115
+ 'Dry, deadpan jokes welcome when they do not displace signal',
116
+ 'No em-dashes, no emoji — typographic rules unchanged',
117
+ 'Stay terse — casual is register, not verbosity',
118
+ ]),
119
+ }),
120
+ });
121
+ /**
122
+ * Type-narrowing predicate. Used by the slash-command parser + state
123
+ * loader so an unknown string from operator input or a stale config
124
+ * file degrades to the default preset instead of crashing.
125
+ */
126
+ export function isOutputStyleSlug(value) {
127
+ return (typeof value === 'string'
128
+ && OUTPUT_STYLE_SLUGS.includes(value));
129
+ }
130
+ /**
131
+ * Compile a preset into the `<output-style>` rule block injected at
132
+ * the tail of the engine system prompt.
133
+ *
134
+ * Returns empty string when the preset is `default` (or any preset
135
+ * with an empty rules array). Empty string is a load-bearing signal —
136
+ * the engine prompt appender uses it to skip injection entirely so
137
+ * the model sees a clean prompt for the default register.
138
+ *
139
+ * The block opens with `<output-style>` and closes with `</output-style>`
140
+ * (XML-shaped marker, matching the engine prompt's existing `<intent>`
141
+ * grammar). The `Active style:` line gives the model a self-correction
142
+ * anchor when it drifts mid-turn.
143
+ */
144
+ export function compileStyleBlock(slug) {
145
+ const preset = OUTPUT_STYLES[slug];
146
+ if (preset.rules.length === 0)
147
+ return '';
148
+ const lines = [];
149
+ lines.push('<output-style>');
150
+ lines.push(` Active style: ${preset.slug}`);
151
+ for (const rule of preset.rules) {
152
+ lines.push(` - ${rule}`);
153
+ }
154
+ lines.push('</output-style>');
155
+ return lines.join('\n');
156
+ }
157
+ /**
158
+ * Render the preset catalogue as a plain-text table for the `/style`
159
+ * + `pugi style` surfaces. Marks the active slug with `*` so the
160
+ * operator can see at a glance which preset is in effect.
161
+ *
162
+ * Pure renderer (no fs, no env). Identical text is emitted from both
163
+ * the slash dispatcher and the top-level CLI command so operators
164
+ * trained on one surface read the same table on the other.
165
+ */
166
+ export function renderStyleTable(active) {
167
+ const slugWidth = Math.max('NAME'.length, ...OUTPUT_STYLE_SLUGS.map((slug) => slug.length));
168
+ const header = `${'NAME'.padEnd(slugWidth)} GLOSS`;
169
+ const rows = OUTPUT_STYLE_SLUGS.map((slug) => {
170
+ const preset = OUTPUT_STYLES[slug];
171
+ const marker = slug === active ? '*' : ' ';
172
+ return `${marker} ${slug.padEnd(slugWidth)} ${preset.gloss}`;
173
+ });
174
+ return [header, ...rows].join('\n');
175
+ }
176
+ //# sourceMappingURL=presets.js.map
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Leak L18 (2026-05-27) — Output-style state persistence.
3
+ *
4
+ * Two-tier storage:
5
+ *
6
+ * 1. **Workspace** — `<workspaceRoot>/.pugi/config.json`. Set by
7
+ * `/style <name>` from inside the REPL or `pugi style <name>`
8
+ * without `--persist`. Overrides the user default for the
9
+ * current workspace only. Survives sessions because the same
10
+ * `.pugi/` survives sessions.
11
+ *
12
+ * 2. **User default** — `~/.pugi/config.json` (PUGI_HOME-aware).
13
+ * Set by `pugi style <name> --persist` or
14
+ * `/style <name> --persist`. Applies to every workspace that
15
+ * has no workspace-level override.
16
+ *
17
+ * Precedence (highest → lowest):
18
+ *
19
+ * workspace value > user value > DEFAULT_OUTPUT_STYLE ('default')
20
+ *
21
+ * Both files live under the same `pugi-config-v1` JSON envelope as
22
+ * other settings (permissionMode, privacy, model, preferredEndpoint).
23
+ * The schema is intentionally NOT shared with `runtime/commands/config.ts`'s
24
+ * strict Zod schema — `outputStyle` is read/written ONLY through this
25
+ * module so `pugi config set outputStyle=…` is NOT a supported path
26
+ * (it would silently bypass the slug validator). Operators get a
27
+ * single surface: `/style` + `pugi style`.
28
+ *
29
+ * File layout (one config.json, multiple keys; this module owns the
30
+ * `outputStyle` key only):
31
+ *
32
+ * {
33
+ * "permissionMode": "ask",
34
+ * "outputStyle": "terse",
35
+ * ...
36
+ * }
37
+ *
38
+ * The reader tolerates:
39
+ * - missing file (returns the default slug),
40
+ * - empty file (returns the default slug),
41
+ * - malformed JSON (returns the default slug — DO NOT crash REPL
42
+ * boot because of a hand-edited config),
43
+ * - unknown slug (returns the default slug + emits no error; the
44
+ * operator can `/style` to see the table and re-set).
45
+ *
46
+ * The writer is a read-modify-write to preserve neighbouring keys
47
+ * (permissionMode etc.) — overwriting the whole file would clobber
48
+ * the other tier's settings.
49
+ *
50
+ * Test surface: `test/commands/output-style-state.spec.ts` exercises
51
+ * precedence, malformed-config tolerance, persistence across reads,
52
+ * the `--persist` (user-default) path, and reset semantics.
53
+ */
54
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
55
+ import { homedir } from 'node:os';
56
+ import { dirname, resolve } from 'node:path';
57
+ import { DEFAULT_OUTPUT_STYLE, isOutputStyleSlug, } from './presets.js';
58
+ /**
59
+ * Env override for `~/.pugi` so the spec can sandbox both tiers
60
+ * without touching the developer's real config. Matches the existing
61
+ * `runtime/commands/config.ts` convention.
62
+ */
63
+ export const PUGI_HOME_ENV = 'PUGI_HOME';
64
+ /**
65
+ * Resolve the active output style for the workspace, applying the
66
+ * precedence ladder (workspace > user > default).
67
+ *
68
+ * Pure read. Never writes, never throws — every IO failure degrades
69
+ * to the default slug. The function returns the source label too so
70
+ * the CLI surface can show the operator where the value came from.
71
+ */
72
+ export function resolveOutputStyle(io) {
73
+ const workspaceSlug = readSlugFromFile(workspaceConfigPath(io.workspaceRoot));
74
+ if (workspaceSlug)
75
+ return { slug: workspaceSlug, source: 'workspace' };
76
+ const userSlug = readSlugFromFile(userConfigPath(io.env ?? process.env));
77
+ if (userSlug)
78
+ return { slug: userSlug, source: 'user' };
79
+ return { slug: DEFAULT_OUTPUT_STYLE, source: 'default' };
80
+ }
81
+ /**
82
+ * Write `slug` to the workspace tier. Creates `<workspaceRoot>/.pugi/`
83
+ * if missing. Preserves neighbouring config keys via read-modify-write.
84
+ */
85
+ export function setWorkspaceOutputStyle(slug, io) {
86
+ writeSlugToFile(workspaceConfigPath(io.workspaceRoot), slug);
87
+ }
88
+ /**
89
+ * Write `slug` to the user tier (`~/.pugi/config.json`).
90
+ *
91
+ * Mirrors the workspace writer's read-modify-write so the user's
92
+ * `permissionMode` / `privacy` / `model` keys survive a style flip.
93
+ */
94
+ export function setUserOutputStyle(slug, io) {
95
+ writeSlugToFile(userConfigPath(io.env ?? process.env), slug);
96
+ }
97
+ /**
98
+ * Clear the workspace tier's `outputStyle` key. The user tier (and
99
+ * therefore the eventual resolved style) is left untouched.
100
+ *
101
+ * Used by `/style --reset` so the operator can revert a workspace
102
+ * override without nuking the rest of their workspace config.
103
+ */
104
+ export function clearWorkspaceOutputStyle(io) {
105
+ clearSlugInFile(workspaceConfigPath(io.workspaceRoot));
106
+ }
107
+ /**
108
+ * Clear the user tier's `outputStyle` key. Lower-blast-radius reset
109
+ * for operators who want every workspace to fall back to `default`
110
+ * unless an explicit workspace value is set.
111
+ */
112
+ export function clearUserOutputStyle(io) {
113
+ clearSlugInFile(userConfigPath(io.env ?? process.env));
114
+ }
115
+ /**
116
+ * Workspace config path. Exported for the spec; production callers
117
+ * should use the `setWorkspace…` / `resolveOutputStyle` helpers.
118
+ */
119
+ export function workspaceConfigPath(workspaceRoot) {
120
+ return resolve(workspaceRoot, '.pugi', 'config.json');
121
+ }
122
+ /**
123
+ * User config path resolved against `PUGI_HOME` (or `~/.pugi`).
124
+ * Exported for the spec.
125
+ */
126
+ export function userConfigPath(env = process.env) {
127
+ const home = env[PUGI_HOME_ENV] ?? resolve(homedir(), '.pugi');
128
+ return resolve(home, 'config.json');
129
+ }
130
+ /**
131
+ * Read + parse a config file. Returns an empty object on any IO or
132
+ * parse error. Caller-provided JSON must be a plain object; arrays /
133
+ * scalars / null are treated as "no config" so a hand-edited file
134
+ * never crashes the REPL.
135
+ */
136
+ function readConfigFile(path) {
137
+ if (!existsSync(path))
138
+ return {};
139
+ let raw;
140
+ try {
141
+ raw = readFileSync(path, 'utf8');
142
+ }
143
+ catch {
144
+ return {};
145
+ }
146
+ if (raw.trim().length === 0)
147
+ return {};
148
+ let parsed;
149
+ try {
150
+ parsed = JSON.parse(raw);
151
+ }
152
+ catch {
153
+ return {};
154
+ }
155
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
156
+ return {};
157
+ return parsed;
158
+ }
159
+ function writeConfigFile(path, config) {
160
+ mkdirSync(dirname(path), { recursive: true });
161
+ // 0o600 mirrors `runtime/commands/config.ts` — the config file may
162
+ // hold `preferredEndpoint` URLs that should not be world-readable.
163
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, {
164
+ encoding: 'utf8',
165
+ mode: 0o600,
166
+ });
167
+ }
168
+ function readSlugFromFile(path) {
169
+ const config = readConfigFile(path);
170
+ const candidate = config.outputStyle;
171
+ return isOutputStyleSlug(candidate) ? candidate : null;
172
+ }
173
+ function writeSlugToFile(path, slug) {
174
+ const config = readConfigFile(path);
175
+ config.outputStyle = slug;
176
+ writeConfigFile(path, config);
177
+ }
178
+ function clearSlugInFile(path) {
179
+ const config = readConfigFile(path);
180
+ if (!('outputStyle' in config))
181
+ return;
182
+ delete config.outputStyle;
183
+ writeConfigFile(path, config);
184
+ }
185
+ //# sourceMappingURL=state.js.map
@@ -14,5 +14,5 @@
14
14
  export { DEFAULT_PERMISSION_MODE, PERMISSION_MODE_GLOSS, PERMISSION_MODES, isPermissionMode, parsePermissionMode, toLegacyMode, } from './mode.js';
15
15
  export { getToolClass, listBuiltInToolClasses, } from './tool-class.js';
16
16
  export { ASK_OPTIONS, PermissionDenied, applyAskAnswer, createAskAlwaysCache, gate, } from './gate.js';
17
- export { getCurrentMode, getGlobalDefaultMode, globalConfigPath, resolveMode, sessionStatePath, setCurrentMode, setGlobalDefaultMode, } from './state.js';
17
+ export { getCurrentMode, getGlobalDefaultMode, getPreviousMode, globalConfigPath, resolveMode, sessionStatePath, setCurrentMode, setGlobalDefaultMode, setPreviousMode, } from './state.js';
18
18
  //# sourceMappingURL=index.js.map
@@ -27,6 +27,13 @@ const permissionModeEnum = z.enum(['plan', 'ask', 'allow', 'bypass']);
27
27
  const sessionStateSchema = z
28
28
  .object({
29
29
  permissionMode: permissionModeEnum.optional(),
30
+ /**
31
+ * Leak L7: snapshot of the mode that was active immediately BEFORE
32
+ * the operator typed `/plan` (or `/plan <prompt>`). `/plan --back`
33
+ * pops this snapshot and restores it. Cleared after a successful
34
+ * pop so a second `/plan --back` does not double-revert.
35
+ */
36
+ previousPermissionMode: permissionModeEnum.optional(),
30
37
  })
31
38
  .partial()
32
39
  .passthrough();
@@ -90,6 +97,54 @@ export function setCurrentMode(workspaceRoot, mode) {
90
97
  writeFileSync(tmpPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
91
98
  renameSync(tmpPath, path);
92
99
  }
100
+ /**
101
+ * Leak L7 — read the snapshot of the mode that was active before the
102
+ * most-recent `/plan` (or `pugi plan`) entry. Returns null when the
103
+ * file is absent OR the field is unset. Same defensive behaviour as
104
+ * `getCurrentMode`: a malformed session file never breaks the slash
105
+ * command — the worst case is `/plan --back` reports "no previous
106
+ * mode to restore" and the operator picks the target mode explicitly.
107
+ */
108
+ export function getPreviousMode(workspaceRoot) {
109
+ const path = sessionStatePath(workspaceRoot);
110
+ if (!existsSync(path))
111
+ return null;
112
+ try {
113
+ const raw = readFileSync(path, 'utf8');
114
+ const parsed = sessionStateSchema.parse(JSON.parse(raw));
115
+ return isPermissionMode(parsed.previousPermissionMode)
116
+ ? parsed.previousPermissionMode
117
+ : null;
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
123
+ /**
124
+ * Leak L7 — record the mode that was active immediately before the
125
+ * operator switched to plan. The runtime calls this AT `/plan` entry
126
+ * with the current mode (whatever `resolveMode` returned). Atomic
127
+ * tmp+rename keeps the snapshot consistent if the process is killed
128
+ * mid-write. Pass `null` to clear the snapshot (used after a
129
+ * successful `/plan --back` so a second `--back` does not loop).
130
+ */
131
+ export function setPreviousMode(workspaceRoot, mode) {
132
+ const path = sessionStatePath(workspaceRoot);
133
+ mkdirSync(dirname(path), { recursive: true });
134
+ const existing = existsSync(path)
135
+ ? safeParseObject(readFileSync(path, 'utf8'))
136
+ : {};
137
+ const next = { ...existing };
138
+ if (mode === null) {
139
+ delete next.previousPermissionMode;
140
+ }
141
+ else {
142
+ next.previousPermissionMode = mode;
143
+ }
144
+ const tmpPath = `${path}.tmp`;
145
+ writeFileSync(tmpPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
146
+ renameSync(tmpPath, path);
147
+ }
93
148
  /**
94
149
  * Read `~/.pugi/config.json::defaultPermissionMode`. Returns null when
95
150
  * the file is absent / the field is unset; same defensive behaviour
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Aggregate byte cap on the full rendered block. 96 KB = 3 files at
3
+ * the per-file cap, which is enough for cwd + parent + homedir while
4
+ * leaving plenty of prompt budget for the rest of the system prompt.
5
+ * Anything beyond is replaced with a truncation marker.
6
+ */
7
+ export const MAX_INJECT_BYTES = 96 * 1024;
8
+ /**
9
+ * Marker line emitted when the aggregate cap is hit. Visible to the
10
+ * model so it knows ambient context was clipped; visible to the
11
+ * operator via the doctor probe so they can decide whether to trim
12
+ * their `PUGI.md` hierarchy.
13
+ */
14
+ export const TRUNCATION_MARKER = '<ambient-context-truncated reason="aggregate-cap" />';
15
+ /**
16
+ * Render a HierarchyFile array into the system-prompt block. Returns
17
+ * `''` when `files` is empty. Each file becomes one
18
+ * `<ambient-context source="..." level="...">...</ambient-context>`
19
+ * stanza separated by a single newline.
20
+ *
21
+ * Determinism: same input always produces byte-identical output.
22
+ */
23
+ export function renderAmbientContext(files) {
24
+ if (files.length === 0)
25
+ return '';
26
+ const stanzas = [];
27
+ let bytes = 0;
28
+ let truncated = false;
29
+ for (const file of files) {
30
+ const stanza = renderStanza(file);
31
+ const stanzaBytes = Buffer.byteLength(stanza, 'utf8') + 1; // newline join cost
32
+ if (bytes + stanzaBytes > MAX_INJECT_BYTES) {
33
+ truncated = true;
34
+ break;
35
+ }
36
+ stanzas.push(stanza);
37
+ bytes += stanzaBytes;
38
+ }
39
+ if (truncated)
40
+ stanzas.push(TRUNCATION_MARKER);
41
+ return stanzas.join('\n');
42
+ }
43
+ /**
44
+ * Build a single `<ambient-context>` stanza for one HierarchyFile.
45
+ * The `source` attribute carries the absolute path (after realpath)
46
+ * so the model can cite which file a piece of guidance came from
47
+ * when it explains its decisions to the operator.
48
+ */
49
+ function renderStanza(file) {
50
+ const sourceAttr = escapeAttr(file.path);
51
+ const levelAttr = String(file.level);
52
+ // No trailing newline inside `content` — the join adds one between
53
+ // stanzas. Trimming the file's trailing whitespace keeps the tag
54
+ // close to the content for readability when an engineer dumps the
55
+ // assembled prompt for debugging.
56
+ const trimmed = file.content.replace(/\s+$/g, '');
57
+ return [
58
+ `<ambient-context source="${sourceAttr}" level="${levelAttr}">`,
59
+ trimmed,
60
+ `</ambient-context>`,
61
+ ].join('\n');
62
+ }
63
+ /**
64
+ * Escape an XML attribute value. We expect operator-controlled paths
65
+ * (not adversarial input) but `&`, `"` and `<` are still possible in
66
+ * symlinked / unicode paths so we escape them defensively. The model
67
+ * has been trained to read this attribute as opaque metadata.
68
+ */
69
+ function escapeAttr(value) {
70
+ return value
71
+ .replace(/&/g, '&amp;')
72
+ .replace(/"/g, '&quot;')
73
+ .replace(/</g, '&lt;')
74
+ .replace(/>/g, '&gt;');
75
+ }
76
+ //# sourceMappingURL=context-injector.js.map