@pugi/cli 0.1.0-alpha.10

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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/bin/run.js +2 -0
  4. package/dist/commands/jobs.js +245 -0
  5. package/dist/core/agents/loader.js +104 -0
  6. package/dist/core/agents/registry.js +69 -0
  7. package/dist/core/auto-open-browser.js +128 -0
  8. package/dist/core/bash-classifier.js +1001 -0
  9. package/dist/core/clipboard.js +70 -0
  10. package/dist/core/context/builder.js +114 -0
  11. package/dist/core/context/compaction-events.js +99 -0
  12. package/dist/core/context/compaction.js +602 -0
  13. package/dist/core/context/invariants.js +250 -0
  14. package/dist/core/context/markdown-loader.js +270 -0
  15. package/dist/core/credentials.js +355 -0
  16. package/dist/core/engine/adapter-runner.js +8 -0
  17. package/dist/core/engine/anvil-client.js +156 -0
  18. package/dist/core/engine/compaction-hook.js +154 -0
  19. package/dist/core/engine/index.js +12 -0
  20. package/dist/core/engine/native-pugi.js +369 -0
  21. package/dist/core/engine/noop.js +27 -0
  22. package/dist/core/engine/prompts.js +118 -0
  23. package/dist/core/engine/tool-bridge.js +313 -0
  24. package/dist/core/file-cache.js +29 -0
  25. package/dist/core/hooks.js +415 -0
  26. package/dist/core/index-store.js +260 -0
  27. package/dist/core/jobs/registry.js +462 -0
  28. package/dist/core/mcp/client.js +316 -0
  29. package/dist/core/mcp/registry.js +171 -0
  30. package/dist/core/mcp/trust.js +91 -0
  31. package/dist/core/path-security.js +63 -0
  32. package/dist/core/permission.js +309 -0
  33. package/dist/core/repl/cap-warning.js +91 -0
  34. package/dist/core/repl/clipboard-read.js +174 -0
  35. package/dist/core/repl/history-search.js +175 -0
  36. package/dist/core/repl/history.js +172 -0
  37. package/dist/core/repl/kill-ring.js +138 -0
  38. package/dist/core/repl/session.js +618 -0
  39. package/dist/core/repl/slash-commands.js +227 -0
  40. package/dist/core/repl/workspace-context.js +113 -0
  41. package/dist/core/session.js +258 -0
  42. package/dist/core/settings.js +59 -0
  43. package/dist/core/skills/loader.js +454 -0
  44. package/dist/core/skills/sources.js +480 -0
  45. package/dist/core/skills/trust.js +172 -0
  46. package/dist/core/subagents/dispatcher.js +258 -0
  47. package/dist/core/subagents/index.js +26 -0
  48. package/dist/core/subagents/spawn.js +86 -0
  49. package/dist/core/trust.js +109 -0
  50. package/dist/index.js +8 -0
  51. package/dist/runtime/cli.js +3405 -0
  52. package/dist/runtime/commands/agents.js +385 -0
  53. package/dist/runtime/commands/budget.js +192 -0
  54. package/dist/runtime/commands/config.js +231 -0
  55. package/dist/runtime/commands/privacy.js +107 -0
  56. package/dist/runtime/commands/skills.js +401 -0
  57. package/dist/runtime/commands/undo.js +329 -0
  58. package/dist/runtime/update-check.js +294 -0
  59. package/dist/tools/bash.js +660 -0
  60. package/dist/tools/file-tools.js +346 -0
  61. package/dist/tools/registry.js +25 -0
  62. package/dist/tools/web-fetch.js +535 -0
  63. package/dist/tui/agent-tree.js +66 -0
  64. package/dist/tui/conversation-pane.js +45 -0
  65. package/dist/tui/device-flow.js +142 -0
  66. package/dist/tui/input-box.js +474 -0
  67. package/dist/tui/login-picker.js +69 -0
  68. package/dist/tui/render.js +125 -0
  69. package/dist/tui/repl-render.js +240 -0
  70. package/dist/tui/repl-splash-art.js +64 -0
  71. package/dist/tui/repl-splash.js +111 -0
  72. package/dist/tui/repl.js +214 -0
  73. package/dist/tui/slash-palette.js +106 -0
  74. package/dist/tui/splash-data.js +61 -0
  75. package/dist/tui/splash.js +31 -0
  76. package/dist/tui/status-bar.js +71 -0
  77. package/dist/tui/update-banner.js +8 -0
  78. package/dist/tui/workspace-context.js +105 -0
  79. package/package.json +71 -0
@@ -0,0 +1,355 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { resolve } from 'node:path';
4
+ import { z } from 'zod';
5
+ /**
6
+ * Local credentials store for the Pugi CLI.
7
+ *
8
+ * Stored at `~/.pugi/credentials.json` (mode 0o600). Mirrors the convention
9
+ * Codex CLI uses (`~/.codex/auth.json`) and matches gh CLI's per-host
10
+ * token model. The store is intentionally file-based, not OS keychain —
11
+ * adding the native `keytar` dep would force per-platform native builds
12
+ * across npm distribution and complicate the install path. The 0600
13
+ * file mode plus a strictly typed Zod schema is the minimum-viable
14
+ * substitute for the keychain path.
15
+ *
16
+ * Multi-host: each host (apiUrl) has its own record. `pugi login` adds
17
+ * or replaces the record for the active host; `pugi logout` removes it.
18
+ * `loadActiveCredential` returns the record for the active host (the one
19
+ * matching `PUGI_API_URL` or `apiUrl` from `~/.pugi/config.json`).
20
+ *
21
+ * Future device-flow tokens land in the same `tokens` array with a
22
+ * `kind: 'oauth-device'` discriminator and a refresh-on-use rotation.
23
+ */
24
+ const CREDENTIALS_SCHEMA_VERSION = 1;
25
+ /**
26
+ * How the credential was obtained. Surfaced by `pugi whoami` so the user
27
+ * can confirm at a glance whether a stored record came from an
28
+ * interactive device flow, a paste-in PAT, or an env-var prime in CI.
29
+ *
30
+ * Older credential files written before this discriminator was added do
31
+ * not carry `source` — the field is optional and `pugi whoami` falls
32
+ * back to `unknown` so we never crash on a legacy file.
33
+ */
34
+ export const pugiTokenSourceSchema = z.enum(['token', 'device-flow', 'env']);
35
+ export const pugiTokenRecordSchema = z.object({
36
+ apiUrl: z.string().url(),
37
+ apiKey: z.string().min(1),
38
+ label: z.string().min(1).optional(),
39
+ createdAt: z.string().datetime(),
40
+ /**
41
+ * When the credential was last refreshed in the store. Today the
42
+ * field is set on `storeApiKey` (create / replace) and on
43
+ * `switchActiveAccount`. Read paths (`pugi whoami`, every API call)
44
+ * intentionally do NOT touch the file to keep disk IO off the hot
45
+ * path — a recency value that lags is acceptable, a 0o600 write per
46
+ * `pugi <anything>` is not.
47
+ */
48
+ lastUsedAt: z.string().datetime().optional(),
49
+ /**
50
+ * Provenance discriminator — see `pugiTokenSourceSchema`. Optional so
51
+ * legacy records (pre-0048) still parse.
52
+ */
53
+ source: pugiTokenSourceSchema.optional(),
54
+ });
55
+ export const pugiCredentialsFileSchema = z.object({
56
+ schema: z.literal(CREDENTIALS_SCHEMA_VERSION),
57
+ tokens: z.array(pugiTokenRecordSchema).default([]),
58
+ /**
59
+ * URL of the host the user most recently logged into. `pugi whoami`
60
+ * and `resolveActiveCredential` read this when `PUGI_API_URL` is unset
61
+ * so a self-hosted user who runs `pugi login --api-url https://anvil.acme.corp`
62
+ * doesn't have to also export PUGI_API_URL for follow-up commands.
63
+ * Env still wins when set (CI fast path).
64
+ */
65
+ activeApiUrl: z.string().url().optional(),
66
+ });
67
+ export const DEFAULT_API_URL = 'https://api.pugi.io';
68
+ export function credentialsPaths(home = homedir()) {
69
+ const pugiDir = resolve(home, '.pugi');
70
+ return {
71
+ homeDir: home,
72
+ pugiDir,
73
+ filePath: resolve(pugiDir, 'credentials.json'),
74
+ };
75
+ }
76
+ export function readCredentialsFile(home = homedir()) {
77
+ const { filePath } = credentialsPaths(home);
78
+ if (!existsSync(filePath)) {
79
+ return { schema: CREDENTIALS_SCHEMA_VERSION, tokens: [] };
80
+ }
81
+ const text = readFileSync(filePath, 'utf8');
82
+ let raw;
83
+ try {
84
+ raw = JSON.parse(text);
85
+ }
86
+ catch {
87
+ // Corrupt JSON: a partial/empty write or hand-edit. Rename the bad
88
+ // file aside so the next write does not silently produce an empty
89
+ // token list (silent data loss). Returning empty here is acceptable
90
+ // because the corrupt copy is preserved for forensic recovery.
91
+ quarantineCorruptCredentials(filePath, 'json-parse');
92
+ return { schema: CREDENTIALS_SCHEMA_VERSION, tokens: [] };
93
+ }
94
+ const parsed = pugiCredentialsFileSchema.safeParse(raw);
95
+ if (parsed.success)
96
+ return parsed.data;
97
+ quarantineCorruptCredentials(filePath, 'schema-mismatch');
98
+ return { schema: CREDENTIALS_SCHEMA_VERSION, tokens: [] };
99
+ }
100
+ function quarantineCorruptCredentials(filePath, reason) {
101
+ try {
102
+ const backup = `${filePath}.corrupt-${reason}-${Date.now()}`;
103
+ renameSync(filePath, backup);
104
+ if (process.stderr?.write) {
105
+ process.stderr.write(`pugi: warning — corrupt credentials file preserved at ${backup}; starting from empty token list\n`);
106
+ }
107
+ }
108
+ catch {
109
+ // Best-effort. If we cannot rename (e.g. read-only fs), the next
110
+ // write will overwrite the corrupt file anyway.
111
+ }
112
+ }
113
+ export function writeCredentialsFile(file, home = homedir()) {
114
+ const paths = credentialsPaths(home);
115
+ if (!existsSync(paths.pugiDir)) {
116
+ mkdirSync(paths.pugiDir, { recursive: true, mode: 0o700 });
117
+ }
118
+ // Defensively tighten existing dir permissions — `mkdirSync(mode)` only
119
+ // applies on creation, not on pre-existing dirs that another tool may
120
+ // have created with 0o755.
121
+ try {
122
+ chmodSync(paths.pugiDir, 0o700);
123
+ }
124
+ catch {
125
+ // Best-effort; FAT/CI filesystems may not support chmod.
126
+ }
127
+ // Validate before persisting so a corrupt object never lands on disk.
128
+ const validated = pugiCredentialsFileSchema.parse(file);
129
+ // Atomic write: tmp + rename. `rename(2)` is atomic on POSIX same-fs,
130
+ // so a concurrent reader either sees the old file or the new one — never
131
+ // a truncated mid-write state that the corrupt-recovery path would
132
+ // quarantine as data loss. Two concurrent writers still race (no advisory
133
+ // lock yet), but neither corrupts the target file.
134
+ const tmp = `${paths.filePath}.${process.pid}.${Date.now()}.tmp`;
135
+ writeFileSync(tmp, `${JSON.stringify(validated, null, 2)}\n`, {
136
+ encoding: 'utf8',
137
+ mode: 0o600,
138
+ });
139
+ try {
140
+ chmodSync(tmp, 0o600);
141
+ }
142
+ catch {
143
+ // Best-effort on filesystems that don't support chmod (FAT, some CIs).
144
+ }
145
+ renameSync(tmp, paths.filePath);
146
+ try {
147
+ chmodSync(paths.filePath, 0o600);
148
+ }
149
+ catch {
150
+ // Best-effort — rename preserves the tmp file's mode on POSIX, this
151
+ // is belt-and-braces.
152
+ }
153
+ }
154
+ /**
155
+ * Add or replace the credential record for the given apiUrl. Returns the
156
+ * stored record (without the `apiKey` echoed back at the caller — the
157
+ * caller already has it). Idempotent.
158
+ */
159
+ export function storeApiKey(input) {
160
+ const home = input.home ?? homedir();
161
+ const file = readCredentialsFile(home);
162
+ const apiUrl = normalizeApiUrl(input.apiUrl);
163
+ const now = new Date().toISOString();
164
+ const record = pugiTokenRecordSchema.parse({
165
+ apiUrl,
166
+ apiKey: input.apiKey,
167
+ label: input.label,
168
+ createdAt: now,
169
+ lastUsedAt: now,
170
+ source: input.source,
171
+ });
172
+ const others = file.tokens.filter((token) => normalizeApiUrl(token.apiUrl) !== apiUrl);
173
+ writeCredentialsFile({
174
+ schema: CREDENTIALS_SCHEMA_VERSION,
175
+ tokens: [...others, record],
176
+ // Promote the just-logged-in host to active so subsequent `whoami`
177
+ // and `review --remote` find it without the user re-exporting
178
+ // PUGI_API_URL. Env still wins via resolveActiveCredential.
179
+ activeApiUrl: apiUrl,
180
+ }, home);
181
+ return record;
182
+ }
183
+ export function clearApiKey(apiUrl, home = homedir()) {
184
+ const file = readCredentialsFile(home);
185
+ const target = normalizeApiUrl(apiUrl);
186
+ const before = file.tokens.length;
187
+ const tokens = file.tokens.filter((token) => normalizeApiUrl(token.apiUrl) !== target);
188
+ if (tokens.length === before)
189
+ return false;
190
+ // If the cleared host was the active one, demote `activeApiUrl` to the
191
+ // most recently-stored remaining record (or undefined when the store
192
+ // is now empty) so subsequent `whoami` doesn't point at a vanished host.
193
+ const nextActive = file.activeApiUrl && normalizeApiUrl(file.activeApiUrl) === target
194
+ ? tokens.at(-1)?.apiUrl
195
+ : file.activeApiUrl;
196
+ writeCredentialsFile({
197
+ schema: CREDENTIALS_SCHEMA_VERSION,
198
+ tokens,
199
+ ...(nextActive ? { activeApiUrl: nextActive } : {}),
200
+ }, home);
201
+ return true;
202
+ }
203
+ export function loadApiKey(apiUrl, home = homedir()) {
204
+ const file = readCredentialsFile(home);
205
+ const target = normalizeApiUrl(apiUrl);
206
+ return file.tokens.find((token) => normalizeApiUrl(token.apiUrl) === target) ?? null;
207
+ }
208
+ export function resolveActiveCredential(env = process.env, home = homedir()) {
209
+ // Resolve the active apiUrl with this precedence:
210
+ // 1. PUGI_API_URL env (lets CI / self-hosted users force a specific endpoint)
211
+ // 2. credentials.json `activeApiUrl` (set by `pugi login` to the host the
212
+ // user most recently authenticated against — covers self-hosted Anvil
213
+ // without re-exporting env between commands)
214
+ // 3. DEFAULT_API_URL (`https://api.pugi.io`)
215
+ const file = readCredentialsFile(home);
216
+ const apiUrl = normalizeApiUrl(env.PUGI_API_URL ?? file.activeApiUrl ?? DEFAULT_API_URL);
217
+ if (env.PUGI_API_KEY) {
218
+ return { apiUrl, apiKey: env.PUGI_API_KEY, source: 'env' };
219
+ }
220
+ const record = file.tokens.find((token) => normalizeApiUrl(token.apiUrl) === apiUrl);
221
+ if (record) {
222
+ return {
223
+ apiUrl: record.apiUrl,
224
+ apiKey: record.apiKey,
225
+ source: 'file',
226
+ fileSource: record.source ?? null,
227
+ ...(record.label ? { label: record.label } : {}),
228
+ ...(record.createdAt ? { createdAt: record.createdAt } : {}),
229
+ ...(record.lastUsedAt ? { lastUsedAt: record.lastUsedAt } : {}),
230
+ };
231
+ }
232
+ return null;
233
+ }
234
+ /**
235
+ * Re-point the credentials store at a different already-stored host or
236
+ * label. Used by `pugi accounts switch <label>` so subsequent commands
237
+ * authenticate against the chosen account without forcing the user to
238
+ * re-export PUGI_API_URL between commands.
239
+ *
240
+ * Match precedence:
241
+ * 1. exact `label` match (case-insensitive, label is user-chosen so
242
+ * we forgive casing — same convention as `gh auth switch`)
243
+ * 2. exact `apiUrl` match (canonicalised) — lets `pugi accounts
244
+ * switch https://api.acme.com` work without a label.
245
+ *
246
+ * Returns the now-active record, or null when nothing matched.
247
+ */
248
+ export function switchActiveAccount(selector, home = homedir()) {
249
+ const file = readCredentialsFile(home);
250
+ const normalisedSelector = selector.trim();
251
+ if (!normalisedSelector)
252
+ return null;
253
+ const lower = normalisedSelector.toLowerCase();
254
+ const targetApiUrl = (() => {
255
+ try {
256
+ return normalizeApiUrl(normalisedSelector);
257
+ }
258
+ catch {
259
+ return null;
260
+ }
261
+ })();
262
+ const match = file.tokens.find((token) => {
263
+ if (token.label && token.label.toLowerCase() === lower)
264
+ return true;
265
+ if (targetApiUrl && normalizeApiUrl(token.apiUrl) === targetApiUrl)
266
+ return true;
267
+ return false;
268
+ });
269
+ if (!match)
270
+ return null;
271
+ const apiUrl = normalizeApiUrl(match.apiUrl);
272
+ const now = new Date().toISOString();
273
+ const updated = pugiTokenRecordSchema.parse({
274
+ ...match,
275
+ apiUrl,
276
+ lastUsedAt: now,
277
+ });
278
+ const others = file.tokens.filter((token) => normalizeApiUrl(token.apiUrl) !== apiUrl);
279
+ writeCredentialsFile({
280
+ schema: CREDENTIALS_SCHEMA_VERSION,
281
+ tokens: [...others, updated],
282
+ activeApiUrl: apiUrl,
283
+ }, home);
284
+ return updated;
285
+ }
286
+ /**
287
+ * List every stored credential in stable display order — most recently
288
+ * used first, falling back to creation order. Returned records carry
289
+ * the raw `apiKey`; callers must mask before printing.
290
+ */
291
+ export function listStoredCredentials(home = homedir()) {
292
+ const file = readCredentialsFile(home);
293
+ const activeUrl = file.activeApiUrl ? normalizeApiUrl(file.activeApiUrl) : null;
294
+ return file.tokens
295
+ .map((token) => ({
296
+ ...token,
297
+ isActive: activeUrl !== null && normalizeApiUrl(token.apiUrl) === activeUrl,
298
+ }))
299
+ .sort((a, b) => {
300
+ // Active record always pinned to the top — it's the one the user
301
+ // is asking about whenever they run `pugi accounts list`.
302
+ if (a.isActive && !b.isActive)
303
+ return -1;
304
+ if (b.isActive && !a.isActive)
305
+ return 1;
306
+ const aWhen = a.lastUsedAt ?? a.createdAt;
307
+ const bWhen = b.lastUsedAt ?? b.createdAt;
308
+ return bWhen.localeCompare(aWhen);
309
+ });
310
+ }
311
+ /**
312
+ * Canonicalize an apiUrl so two equivalent inputs always resolve to the
313
+ * same record:
314
+ * - lowercase scheme + host (URL spec: scheme/host are case-insensitive)
315
+ * - strip trailing slashes
316
+ * - preserve path/query/fragment case (those ARE case-sensitive)
317
+ *
318
+ * Falls back to the trimmed input when the URL is not parseable, so a
319
+ * caller that manages to pass a non-URL string still sees a stable key
320
+ * instead of throwing.
321
+ */
322
+ export function normalizeApiUrl(input) {
323
+ const trimmed = input.trim();
324
+ try {
325
+ const url = new URL(trimmed);
326
+ const path = url.pathname + url.search + url.hash;
327
+ const stripped = path.replace(/\/+$/, '');
328
+ return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${stripped}`;
329
+ }
330
+ catch {
331
+ return trimmed.replace(/\/+$/, '');
332
+ }
333
+ }
334
+ /**
335
+ * Best-effort masked token for log lines and `pugi whoami` output.
336
+ * Never returns the raw secret. Keeps the first 4 + last 4 characters so
337
+ * the user can correlate against an issued key without exposing it.
338
+ */
339
+ export function maskApiKey(apiKey) {
340
+ if (apiKey.length <= 12)
341
+ return `${'*'.repeat(apiKey.length)}`;
342
+ return `${apiKey.slice(0, 4)}…${apiKey.slice(-4)}`;
343
+ }
344
+ /**
345
+ * Delete the entire credentials file. Used by `pugi logout --all` and
346
+ * by tests that want a clean slate.
347
+ */
348
+ export function purgeAllCredentials(home = homedir()) {
349
+ const paths = credentialsPaths(home);
350
+ if (!existsSync(paths.filePath))
351
+ return false;
352
+ rmSync(paths.filePath);
353
+ return true;
354
+ }
355
+ //# sourceMappingURL=credentials.js.map
@@ -0,0 +1,8 @@
1
+ export async function collectEngineEvents(adapter, task, ctx) {
2
+ const events = [];
3
+ for await (const event of adapter.run(task, ctx)) {
4
+ events.push(event);
5
+ }
6
+ return events;
7
+ }
8
+ //# sourceMappingURL=adapter-runner.js.map
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Anvil-backed engine loop client.
3
+ *
4
+ * Wire format: OpenAI-compatible `/v1/chat/completions` shape proxied
5
+ * through the admin-api Pugi runtime endpoint. The CLI POSTs:
6
+ *
7
+ * POST {apiUrl}/api/pugi/engine
8
+ * Authorization: Bearer {apiKey}
9
+ * {
10
+ * "personaSlug": "oes-dev",
11
+ * "messages": [...],
12
+ * "tools": [...],
13
+ * "maxTokens": 4096,
14
+ * "temperature": 0.2
15
+ * }
16
+ *
17
+ * and expects:
18
+ *
19
+ * 200 OK
20
+ * {
21
+ * "stop": "tool_use" | "text",
22
+ * "content": "...", // present when stop=text
23
+ * "toolCalls": [{id, name, arguments}], // present when stop=tool_use
24
+ * "tokensUsed": 1234,
25
+ * "model": "deepseek-chat-v3.1"
26
+ * }
27
+ *
28
+ * 401/403 -> auth_missing
29
+ * 404 -> endpoint_missing
30
+ * 429 -> rate_limited
31
+ * other -> failed
32
+ *
33
+ * The endpoint itself ships in Sprint 2 (Track 2A). Until then the CLI
34
+ * surfaces `endpoint_missing` cleanly and the operator runs `pugi code`
35
+ * with `PUGI_ENGINE_FIXTURE` to point at a fixture client.
36
+ */
37
+ export class AnvilEngineLoopClient {
38
+ config;
39
+ constructor(config) {
40
+ this.config = config;
41
+ }
42
+ async send(messages, tools, options) {
43
+ // Use `new URL(path, base)` so an `apiUrl` that already carries a
44
+ // path prefix (rare, but possible for self-hosted deployments)
45
+ // composes correctly instead of double-pathing via raw string
46
+ // concatenation. The leading `/` anchors resolution to the base
47
+ // host. Self-hosted operators who need their engine endpoint
48
+ // nested under a prefix should bake the prefix into `apiUrl`
49
+ // itself and drop the leading slash here.
50
+ const url = new URL('/api/pugi/engine', this.config.apiUrl).toString();
51
+ const controller = new AbortController();
52
+ const onAbort = () => controller.abort();
53
+ if (options.signal)
54
+ options.signal.addEventListener('abort', onAbort);
55
+ const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
56
+ try {
57
+ const res = await fetch(url, {
58
+ method: 'POST',
59
+ headers: {
60
+ 'content-type': 'application/json',
61
+ authorization: `Bearer ${this.config.apiKey}`,
62
+ 'user-agent': 'pugi-cli/0.0.1',
63
+ },
64
+ body: JSON.stringify({
65
+ personaSlug: options.personaSlug,
66
+ messages,
67
+ tools,
68
+ maxTokens: options.maxTokens,
69
+ temperature: options.temperature,
70
+ }),
71
+ signal: controller.signal,
72
+ });
73
+ const text = await res.text();
74
+ if (res.status === 200) {
75
+ try {
76
+ const json = JSON.parse(text);
77
+ if (json.stop === 'text') {
78
+ return {
79
+ stop: 'text',
80
+ assistantMessage: {
81
+ role: 'assistant',
82
+ content: json.content ?? '',
83
+ },
84
+ content: json.content ?? '',
85
+ tokensUsed: json.tokensUsed ?? 0,
86
+ };
87
+ }
88
+ if (json.stop === 'tool_use') {
89
+ const calls = json.toolCalls ?? [];
90
+ return {
91
+ stop: 'tool_use',
92
+ assistantMessage: {
93
+ role: 'assistant',
94
+ content: json.content ?? '',
95
+ toolCalls: calls,
96
+ },
97
+ tokensUsed: json.tokensUsed ?? 0,
98
+ };
99
+ }
100
+ return {
101
+ stop: 'error',
102
+ code: 'failed',
103
+ message: `runtime returned 200 with unknown stop=${String(json.stop)}`,
104
+ };
105
+ }
106
+ catch (error) {
107
+ return {
108
+ stop: 'error',
109
+ code: 'failed',
110
+ message: `runtime returned 200 with non-JSON body: ${error.message}`,
111
+ };
112
+ }
113
+ }
114
+ if (res.status === 404) {
115
+ return {
116
+ stop: 'error',
117
+ code: 'endpoint_missing',
118
+ message: 'POST /api/pugi/engine not deployed on this runtime',
119
+ };
120
+ }
121
+ if (res.status === 401 || res.status === 403) {
122
+ return {
123
+ stop: 'error',
124
+ code: 'auth_missing',
125
+ message: `runtime rejected credentials (HTTP ${res.status})`,
126
+ };
127
+ }
128
+ if (res.status === 429) {
129
+ return {
130
+ stop: 'error',
131
+ code: 'rate_limited',
132
+ message: 'runtime rate limit reached for this tenant',
133
+ };
134
+ }
135
+ return {
136
+ stop: 'error',
137
+ code: 'failed',
138
+ message: `runtime returned HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`,
139
+ };
140
+ }
141
+ catch (error) {
142
+ const message = error instanceof Error
143
+ ? error.name === 'AbortError'
144
+ ? `runtime call timed out after ${this.config.timeoutMs}ms`
145
+ : error.message
146
+ : 'unknown error';
147
+ return { stop: 'error', code: 'failed', message };
148
+ }
149
+ finally {
150
+ clearTimeout(timeout);
151
+ if (options.signal)
152
+ options.signal.removeEventListener('abort', onAbort);
153
+ }
154
+ }
155
+ }
156
+ //# sourceMappingURL=anvil-client.js.map
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Engine loop integration point for the six-tier compaction engine.
3
+ *
4
+ * `maybeCompactAfterTool` is the single function the engine loop calls
5
+ * after each tool result has been appended to the transcript. It:
6
+ *
7
+ * 1. Estimates current context-window pressure (transcript bytes
8
+ * against the model's budget, plus the static blocks).
9
+ * 2. Calls `selectTier` on the snapshot.
10
+ * 3. Runs the tier. Microcompact / cached_microcompact are sync;
11
+ * reactive_summary / session_memory / full_compaction / reset
12
+ * are async-shaped (the call returns before commit when run
13
+ * against a long transcript) but currently run inline — the
14
+ * engine loop is single-threaded today, so true backgrounding
15
+ * waits for the SSE consumer refactor in α5.7.
16
+ * 4. Runs invariant checks against the result. On any violation,
17
+ * emits `compaction.invariant_violated` and returns the
18
+ * pre-compaction transcript untouched.
19
+ * 5. On success, emits `compaction.completed` with reclaim numbers
20
+ * and returns the new transcript for the caller to adopt.
21
+ * 6. On no-op, emits `compaction.skipped` and returns the original.
22
+ *
23
+ * Why a separate file (not inlined into `native-pugi.ts`):
24
+ *
25
+ * Sprint α5.3 (feat/pugi-cli-hooks-lifecycle-m1-gap-c) is in flight
26
+ * and already modifies session.ts + tool-bridge + permission. Editing
27
+ * native-pugi.ts in this PR risks a merge conflict against α5.3's
28
+ * landing PR. Keeping the wiring as an exported helper means the
29
+ * one-line callsite in native-pugi.ts can be added in a tiny
30
+ * follow-up after both α5.3 and α5.5 have landed.
31
+ *
32
+ * Expected callsite in `apps/pugi-cli/src/core/engine/native-pugi.ts`,
33
+ * inside `onToolResult`:
34
+ *
35
+ * ```ts
36
+ * const compactionOutcome = await maybeCompactAfterTool({
37
+ * session,
38
+ * transcript: currentTranscript,
39
+ * toolOutputs: recentToolOutputs,
40
+ * contextBudgetUsed: estimatedTokens,
41
+ * contextBudgetMax: budget.maxTokens,
42
+ * workspaceRoot: root,
43
+ * contextStaticHash: {
44
+ * instructionsHash,
45
+ * toolSchemaHash,
46
+ * },
47
+ * });
48
+ * if (compactionOutcome.committed) {
49
+ * currentTranscript = compactionOutcome.newTranscript;
50
+ * }
51
+ * ```
52
+ */
53
+ import { runCompaction, selectTier, } from '../context/compaction.js';
54
+ import { checkInvariants } from '../context/invariants.js';
55
+ import { emitCompactionCompleted, emitCompactionInvariantViolated, emitCompactionSkipped, emitCompactionStarted, } from '../context/compaction-events.js';
56
+ /**
57
+ * Engine-loop callback. See file header for the expected callsite shape.
58
+ *
59
+ * Contract:
60
+ * - Never throws. All errors degrade to `committed: false` with the
61
+ * original transcript and an event record.
62
+ * - On `committed: true`, the caller MUST adopt `newTranscript` as
63
+ * the live working transcript for the next model turn.
64
+ * - On `committed: false`, the caller MUST keep the input transcript
65
+ * and try again on the next tool turn (compaction will retry once
66
+ * pressure stays above threshold).
67
+ */
68
+ export async function maybeCompactAfterTool(input) {
69
+ const compactionInput = {
70
+ sessionId: input.session.id,
71
+ contextBudgetUsed: input.contextBudgetUsed,
72
+ contextBudgetMax: input.contextBudgetMax,
73
+ toolOutputs: input.toolOutputs,
74
+ transcript: input.transcript,
75
+ workspaceRoot: input.workspaceRoot,
76
+ };
77
+ const tier = selectTier(compactionInput);
78
+ emitCompactionStarted(input.session, tier, {
79
+ budgetUsed: input.contextBudgetUsed,
80
+ budgetMax: input.contextBudgetMax,
81
+ });
82
+ let result;
83
+ try {
84
+ result = await runCompaction(compactionInput, tier);
85
+ }
86
+ catch (error) {
87
+ const reason = error instanceof Error ? error.message : String(error);
88
+ emitCompactionSkipped(input.session, tier, `compaction crashed: ${reason}`);
89
+ return {
90
+ committed: false,
91
+ tier,
92
+ newTranscript: input.transcript,
93
+ bytesReclaimed: 0,
94
+ newContextSize: byteSize(input.transcript),
95
+ violations: [],
96
+ skipped: true,
97
+ skipReason: `crashed: ${reason}`,
98
+ };
99
+ }
100
+ if (result.skipped) {
101
+ emitCompactionSkipped(input.session, tier, result.skipReason || 'no work');
102
+ return {
103
+ committed: false,
104
+ tier,
105
+ newTranscript: input.transcript,
106
+ bytesReclaimed: 0,
107
+ newContextSize: byteSize(input.transcript),
108
+ violations: [],
109
+ skipped: true,
110
+ skipReason: result.skipReason,
111
+ };
112
+ }
113
+ // Invariant gate: static-hash-unchanged is enforced by passing the
114
+ // same hashes in for `before` and `after` — compaction never touches
115
+ // static blocks, so the hashes are equal by construction. We pass
116
+ // both so the contract is explicit; if a future tier introduces a
117
+ // bug that overwrites static state, the check still catches it.
118
+ const violations = checkInvariants({
119
+ before: compactionInput,
120
+ after: result,
121
+ summaryText: result.summaryText,
122
+ staticHashBefore: input.contextStaticHash,
123
+ staticHashAfter: input.contextStaticHash,
124
+ });
125
+ if (violations.length > 0) {
126
+ for (const v of violations)
127
+ emitCompactionInvariantViolated(input.session, v);
128
+ return {
129
+ committed: false,
130
+ tier,
131
+ newTranscript: input.transcript,
132
+ bytesReclaimed: 0,
133
+ newContextSize: byteSize(input.transcript),
134
+ violations,
135
+ skipped: false,
136
+ skipReason: '',
137
+ };
138
+ }
139
+ emitCompactionCompleted(input.session, tier, result.bytesReclaimed, result.newContextSize, result.artifactsCreated);
140
+ return {
141
+ committed: true,
142
+ tier,
143
+ newTranscript: result.newTranscript,
144
+ bytesReclaimed: result.bytesReclaimed,
145
+ newContextSize: result.newContextSize,
146
+ violations: [],
147
+ skipped: false,
148
+ skipReason: '',
149
+ };
150
+ }
151
+ function byteSize(transcript) {
152
+ return transcript.reduce((sum, t) => sum + Buffer.byteLength(t.content, 'utf8'), 0);
153
+ }
154
+ //# sourceMappingURL=compaction-hook.js.map
@@ -0,0 +1,12 @@
1
+ export * from './adapter-runner.js';
2
+ export * from './anvil-client.js';
3
+ export * from './native-pugi.js';
4
+ export * from './noop.js';
5
+ export * from './prompts.js';
6
+ export * from './tool-bridge.js';
7
+ // Subagent dispatch helper. Engine adapter code calls this from a
8
+ // future `task_dispatch` tool (alpha-5.7 REPL); the re-export keeps the
9
+ // import path stable so adapter code does not have to reach into the
10
+ // subagents submodule.
11
+ export { spawnSubagent } from '../subagents/spawn.js';
12
+ //# sourceMappingURL=index.js.map