@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.
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/run.js +2 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/auto-open-browser.js +128 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/clipboard.js +70 -0
- package/dist/core/context/builder.js +114 -0
- package/dist/core/context/compaction-events.js +99 -0
- package/dist/core/context/compaction.js +602 -0
- package/dist/core/context/invariants.js +250 -0
- package/dist/core/context/markdown-loader.js +270 -0
- package/dist/core/credentials.js +355 -0
- package/dist/core/engine/adapter-runner.js +8 -0
- package/dist/core/engine/anvil-client.js +156 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +12 -0
- package/dist/core/engine/native-pugi.js +369 -0
- package/dist/core/engine/noop.js +27 -0
- package/dist/core/engine/prompts.js +118 -0
- package/dist/core/engine/tool-bridge.js +313 -0
- package/dist/core/file-cache.js +29 -0
- package/dist/core/hooks.js +415 -0
- package/dist/core/index-store.js +260 -0
- package/dist/core/jobs/registry.js +462 -0
- package/dist/core/mcp/client.js +316 -0
- package/dist/core/mcp/registry.js +171 -0
- package/dist/core/mcp/trust.js +91 -0
- package/dist/core/path-security.js +63 -0
- package/dist/core/permission.js +309 -0
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/clipboard-read.js +174 -0
- package/dist/core/repl/history-search.js +175 -0
- package/dist/core/repl/history.js +172 -0
- package/dist/core/repl/kill-ring.js +138 -0
- package/dist/core/repl/session.js +618 -0
- package/dist/core/repl/slash-commands.js +227 -0
- package/dist/core/repl/workspace-context.js +113 -0
- package/dist/core/session.js +258 -0
- package/dist/core/settings.js +59 -0
- package/dist/core/skills/loader.js +454 -0
- package/dist/core/skills/sources.js +480 -0
- package/dist/core/skills/trust.js +172 -0
- package/dist/core/subagents/dispatcher.js +258 -0
- package/dist/core/subagents/index.js +26 -0
- package/dist/core/subagents/spawn.js +86 -0
- package/dist/core/trust.js +109 -0
- package/dist/index.js +8 -0
- package/dist/runtime/cli.js +3405 -0
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/budget.js +192 -0
- package/dist/runtime/commands/config.js +231 -0
- package/dist/runtime/commands/privacy.js +107 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/runtime/update-check.js +294 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tools/file-tools.js +346 -0
- package/dist/tools/registry.js +25 -0
- package/dist/tools/web-fetch.js +535 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/device-flow.js +142 -0
- package/dist/tui/input-box.js +474 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +125 -0
- package/dist/tui/repl-render.js +240 -0
- package/dist/tui/repl-splash-art.js +64 -0
- package/dist/tui/repl-splash.js +111 -0
- package/dist/tui/repl.js +214 -0
- package/dist/tui/slash-palette.js +106 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +71 -0
- package/dist/tui/update-banner.js +8 -0
- package/dist/tui/workspace-context.js +105 -0
- 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,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
|