@north-light/crouter 0.3.161 → 0.3.163
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/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +6 -0
- package/dist/api/dto/modelauth.d.ts +32 -0
- package/dist/api/dto/modelauth.js +2 -1
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/00-runtime-base.md +1 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -0
- package/dist/builtin-memory/init.md +38 -0
- package/dist/builtin-memory/wedged-child-on-runaway-bash.md +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +88 -396
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +215 -419
- package/dist/clients/attach/__tests__/crtr-output.test.js +14 -9
- package/dist/clients/attach/__tests__/edit-diff.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/edit-diff.test.js +34 -0
- package/dist/clients/attach/__tests__/editor-frame-title.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/editor-frame-title.test.js +19 -0
- package/dist/clients/attach/render/chat-view.js +8 -3
- package/dist/clients/attach/render/crtr-output.js +31 -2
- package/dist/clients/attach/render/edit-diff.js +26 -2
- package/dist/clients/attach/render/tool-calls.d.ts +7 -2
- package/dist/clients/attach/render/tool-calls.js +25 -4
- package/dist/clients/attach/session/connection.d.ts +1 -1
- package/dist/clients/attach/session/editor-frame.d.ts +5 -1
- package/dist/clients/attach/session/editor-frame.js +21 -15
- package/dist/clients/attach/session/identity.d.ts +1 -1
- package/dist/clients/attach/viewer.js +441 -439
- package/dist/commands/__tests__/api-canvas-source.test.d.ts +1 -0
- package/dist/commands/__tests__/api-canvas-source.test.js +37 -0
- package/dist/commands/__tests__/human.test.js +2 -0
- package/dist/commands/__tests__/search-contents.test.d.ts +1 -0
- package/dist/commands/__tests__/search-contents.test.js +52 -0
- package/dist/commands/api-client.js +1 -0
- package/dist/commands/human/prompts.js +2 -2
- package/dist/commands/human/shared.d.ts +1 -0
- package/dist/commands/human/shared.js +7 -4
- package/dist/commands/memory/lint.d.ts +6 -0
- package/dist/commands/memory/lint.js +177 -35
- package/dist/commands/memory/read.js +22 -1
- package/dist/commands/memory/write.js +18 -4
- package/dist/commands/memory.js +1 -1
- package/dist/commands/search/contents.js +2 -2
- package/dist/commands/sys/__tests__/sync-deps.test.js +12 -21
- package/dist/commands/sys/__tests__/sync-import.test.js +30 -29
- package/dist/commands/sys/setup-wizard.d.ts +8 -1
- package/dist/commands/sys/setup-wizard.js +209 -58
- package/dist/commands/sys/sync-deps.js +14 -18
- package/dist/commands/sys/sync-project-guidance.js +16 -19
- package/dist/core/__tests__/broker-launch-candidates.test.js +13 -3
- package/dist/core/__tests__/broker-sdk-wiring.test.js +53 -25
- package/dist/core/__tests__/full/broker-provider-retry.test.js +6 -122
- package/dist/core/__tests__/migration.test.js +8 -3
- package/dist/core/__tests__/model-pin-durability.test.js +7 -5
- package/dist/core/__tests__/model-routes.test.d.ts +1 -0
- package/dist/core/__tests__/model-routes.test.js +41 -0
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +7 -10
- package/dist/core/__tests__/on-read-dedup-resume.test.js +13 -25
- package/dist/core/__tests__/on-read-identity.test.js +8 -15
- package/dist/core/__tests__/review-model-floor.test.js +2 -2
- package/dist/core/__tests__/revive.test.js +2 -2
- package/dist/core/__tests__/tmux-surface.test.js +10 -4
- package/dist/core/__tests__/worktree.test.js +3 -3
- package/dist/core/auth-file.d.ts +3 -2
- package/dist/core/auth-file.js +5 -3
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +18 -11
- package/dist/core/canvas/labels.d.ts +4 -5
- package/dist/core/canvas/labels.js +9 -9
- package/dist/core/canvas/types.d.ts +6 -1
- package/dist/core/command-manifests/schema.js +18 -5
- package/dist/core/command.js +17 -7
- package/dist/core/config.d.ts +3 -1
- package/dist/core/config.js +88 -2
- package/dist/core/configured-clis/invoker.js +2 -0
- package/dist/core/help.d.ts +3 -1
- package/dist/core/help.js +5 -2
- package/dist/core/keybindings/__tests__/resolve.test.js +5 -2
- package/dist/core/keybindings/catalog.d.ts +3 -2
- package/dist/core/keybindings/catalog.js +34 -25
- package/dist/core/keybindings/index.d.ts +1 -1
- package/dist/core/keybindings/resolve.js +8 -0
- package/dist/core/keybindings/types.d.ts +6 -1
- package/dist/core/memory/doc-link-grammar.d.ts +20 -0
- package/dist/core/memory/doc-link-grammar.js +110 -0
- package/dist/core/memory-resolver.d.ts +5 -0
- package/dist/core/memory-resolver.js +12 -1
- package/dist/core/model-routes.d.ts +37 -0
- package/dist/core/model-routes.js +140 -0
- package/dist/core/runtime/bearings.d.ts +8 -8
- package/dist/core/runtime/bearings.js +20 -16
- package/dist/core/runtime/broker.d.ts +0 -4
- package/dist/core/runtime/broker.js +119 -385
- package/dist/core/runtime/canvas-extensions.d.ts +1 -0
- package/dist/core/runtime/canvas-extensions.js +2 -0
- package/dist/core/runtime/launch.d.ts +10 -12
- package/dist/core/runtime/launch.js +16 -22
- package/dist/core/runtime/managed-provider-cooling.d.ts +0 -14
- package/dist/core/runtime/managed-provider-cooling.js +0 -19
- package/dist/core/runtime/model-swap.js +7 -1
- package/dist/core/runtime/promote.js +1 -0
- package/dist/core/runtime/tmux.js +136 -100
- package/dist/core/scope.js +4 -5
- package/dist/core/subscription-state.d.ts +12 -36
- package/dist/core/subscription-state.js +19 -203
- package/dist/core/substrate/ceiling.d.ts +3 -6
- package/dist/core/substrate/ceiling.js +13 -15
- package/dist/core/substrate/index.d.ts +2 -3
- package/dist/core/substrate/index.js +2 -2
- package/dist/core/substrate/on-read.d.ts +4 -13
- package/dist/core/substrate/on-read.js +142 -262
- package/dist/core/substrate/render.js +4 -4
- package/dist/core/substrate/schema.d.ts +3 -2
- package/dist/core/substrate/schema.js +5 -5
- package/dist/daemon/api/__tests__/full/api-server.test.js +9 -6
- package/dist/daemon/api/__tests__/full/b10-attach-modelauth.test.js +123 -1
- package/dist/daemon/api/handlers/modelauth.js +51 -2
- package/dist/daemon/api/handlers/nodes.js +2 -0
- package/dist/pi-extensions/__tests__/canvas-tool-guide.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-tool-guide.test.js +96 -0
- package/dist/pi-extensions/canvas-doc-substrate.js +7 -8
- package/dist/pi-extensions/canvas-tool-guide.d.ts +14 -0
- package/dist/pi-extensions/canvas-tool-guide.js +74 -0
- package/dist/types.d.ts +17 -0
- package/dist/web-client/assets/{index-B76ZKfT_.js → index-NIuSCOHM.js} +1 -1
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crouter-help.ts +0 -95
|
@@ -34,7 +34,8 @@ import assert from 'node:assert/strict';
|
|
|
34
34
|
import { existsSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
35
35
|
import { tmpdir } from 'node:os';
|
|
36
36
|
import { join } from 'node:path';
|
|
37
|
-
import { setAuthCredential } from '../../../../core/auth-file.js';
|
|
37
|
+
import { readAuthCredentials, setAuthCredential } from '../../../../core/auth-file.js';
|
|
38
|
+
import { readSubscriptionPool, writeSubscriptionPool } from '../../../../core/subscription-state.js';
|
|
38
39
|
import { createHeadlessHarness } from '../../../../core/__tests__/helpers/harness.js';
|
|
39
40
|
import { createAttachKit } from '../../../../core/__tests__/helpers/broker-clients.js';
|
|
40
41
|
import { CrtrClient } from '../../../../api/index.js';
|
|
@@ -218,3 +219,124 @@ test('B2-modelauth-oauth: installCredential(oauth) preserves extra fields (enter
|
|
|
218
219
|
rmSync(apiDir, { recursive: true, force: true });
|
|
219
220
|
}
|
|
220
221
|
});
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Credential truth/removal — GET exposes only connection state and DELETE
|
|
224
|
+
// clears exactly its provider's durable store.
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
test('B2-modelauth-status: list is sanitized and delete clears only the selected provider', async () => {
|
|
227
|
+
const savedPiDir = process.env['PI_CODING_AGENT_DIR'];
|
|
228
|
+
const apiDir = mkdtempSync(join(tmpdir(), 'crtr-b10-status-'));
|
|
229
|
+
process.env['PI_CODING_AGENT_DIR'] = apiDir;
|
|
230
|
+
const expires = 1_900_000_000_000;
|
|
231
|
+
const cooldown = 1_900_000_100_000;
|
|
232
|
+
try {
|
|
233
|
+
await writeSubscriptionPool('anthropic', [
|
|
234
|
+
{
|
|
235
|
+
label: 'Claude One',
|
|
236
|
+
refresh: 'anthropic-refresh-secret',
|
|
237
|
+
access: 'anthropic-access-secret',
|
|
238
|
+
expires,
|
|
239
|
+
rateLimitedUntil: cooldown,
|
|
240
|
+
lastAttemptAt: 0,
|
|
241
|
+
lastRateLimitedAt: 0,
|
|
242
|
+
authFailure: 'invalid_grant',
|
|
243
|
+
accountId: 'anthropic-account-id',
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
label: 'Claude Two',
|
|
247
|
+
refresh: 'second-refresh-secret',
|
|
248
|
+
access: 'second-access-secret',
|
|
249
|
+
expires,
|
|
250
|
+
rateLimitedUntil: 0,
|
|
251
|
+
lastAttemptAt: 0,
|
|
252
|
+
lastRateLimitedAt: 0,
|
|
253
|
+
},
|
|
254
|
+
]);
|
|
255
|
+
await writeSubscriptionPool('openai-codex', [
|
|
256
|
+
{
|
|
257
|
+
label: 'ChatGPT',
|
|
258
|
+
refresh: 'codex-refresh-secret',
|
|
259
|
+
access: 'codex-access-secret',
|
|
260
|
+
expires,
|
|
261
|
+
rateLimitedUntil: 0,
|
|
262
|
+
lastAttemptAt: 0,
|
|
263
|
+
lastRateLimitedAt: 0,
|
|
264
|
+
accountId: 'codex-account-id',
|
|
265
|
+
},
|
|
266
|
+
]);
|
|
267
|
+
await setAuthCredential('github-copilot', {
|
|
268
|
+
type: 'oauth',
|
|
269
|
+
access: 'copilot-access-secret',
|
|
270
|
+
refresh: 'copilot-refresh-secret',
|
|
271
|
+
expires,
|
|
272
|
+
}, join(apiDir, 'auth.json'));
|
|
273
|
+
// A managed provider may also have a hand-configured API key. It remains
|
|
274
|
+
// intentionally absent from status, but DELETE must remove it too.
|
|
275
|
+
await setAuthCredential('anthropic', { type: 'api_key', key: 'anthropic-api-key-secret' }, join(apiDir, 'auth.json'));
|
|
276
|
+
const status = await client.listModelAuth();
|
|
277
|
+
assert.deepEqual(status.providers, [
|
|
278
|
+
{
|
|
279
|
+
provider: 'anthropic',
|
|
280
|
+
managed: true,
|
|
281
|
+
configured: true,
|
|
282
|
+
accounts: [
|
|
283
|
+
{
|
|
284
|
+
label: 'Claude One',
|
|
285
|
+
expires_at: new Date(expires).toISOString(),
|
|
286
|
+
rate_limited_until: new Date(cooldown).toISOString(),
|
|
287
|
+
auth_failure: 'invalid_grant',
|
|
288
|
+
account_id: 'anthropic-account-id',
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
label: 'Claude Two',
|
|
292
|
+
expires_at: new Date(expires).toISOString(),
|
|
293
|
+
rate_limited_until: new Date(0).toISOString(),
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
provider: 'openai-codex',
|
|
299
|
+
managed: true,
|
|
300
|
+
configured: true,
|
|
301
|
+
accounts: [
|
|
302
|
+
{
|
|
303
|
+
label: 'ChatGPT',
|
|
304
|
+
expires_at: new Date(expires).toISOString(),
|
|
305
|
+
rate_limited_until: new Date(0).toISOString(),
|
|
306
|
+
account_id: 'codex-account-id',
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
},
|
|
310
|
+
{ provider: 'github-copilot', managed: false, configured: true, kind: 'oauth' },
|
|
311
|
+
]);
|
|
312
|
+
const wire = JSON.stringify(status);
|
|
313
|
+
for (const secret of [
|
|
314
|
+
'anthropic-refresh-secret',
|
|
315
|
+
'anthropic-access-secret',
|
|
316
|
+
'second-refresh-secret',
|
|
317
|
+
'second-access-secret',
|
|
318
|
+
'codex-refresh-secret',
|
|
319
|
+
'codex-access-secret',
|
|
320
|
+
'copilot-refresh-secret',
|
|
321
|
+
'copilot-access-secret',
|
|
322
|
+
'anthropic-api-key-secret',
|
|
323
|
+
]) {
|
|
324
|
+
assert.ok(!wire.includes(secret), `status never exposes ${secret}`);
|
|
325
|
+
}
|
|
326
|
+
const removedManaged = await client.removeCredential('anthropic');
|
|
327
|
+
assert.deepEqual(removedManaged, { provider: 'anthropic', removed_accounts: 3 });
|
|
328
|
+
assert.deepEqual(readSubscriptionPool('anthropic'), [], 'selected managed pool is empty');
|
|
329
|
+
assert.ok(!Object.hasOwn(await readAuthCredentials(join(apiDir, 'auth.json')), 'anthropic'), 'managed API key is removed');
|
|
330
|
+
assert.equal(readSubscriptionPool('openai-codex').length, 1, 'other managed pool is unchanged');
|
|
331
|
+
const removedOauth = await client.removeCredential('github-copilot');
|
|
332
|
+
assert.deepEqual(removedOauth, { provider: 'github-copilot', removed_accounts: 1 });
|
|
333
|
+
assert.deepEqual((await client.listModelAuth()).providers.map((provider) => provider.provider), ['anthropic', 'openai-codex'], 'deleted auth.json credential no longer appears in status');
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
if (savedPiDir === undefined)
|
|
337
|
+
delete process.env['PI_CODING_AGENT_DIR'];
|
|
338
|
+
else
|
|
339
|
+
process.env['PI_CODING_AGENT_DIR'] = savedPiDir;
|
|
340
|
+
rmSync(apiDir, { recursive: true, force: true });
|
|
341
|
+
}
|
|
342
|
+
});
|
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
// fans `reload_auth` to live brokers — a write here is picked up by that watcher
|
|
9
9
|
// (do NOT re-implement the fan).
|
|
10
10
|
//
|
|
11
|
+
// GET /v1/model-auth sanitized credential presence
|
|
11
12
|
// PUT /v1/model-auth/:provider install/rotate a credential (idempotent)
|
|
13
|
+
// DELETE /v1/model-auth/:provider remove all credentials for one provider
|
|
12
14
|
import { homedir } from 'node:os';
|
|
13
15
|
import { join } from 'node:path';
|
|
14
|
-
import { setAuthCredential } from '../../../core/auth-file.js';
|
|
16
|
+
import { deleteAuthCredential, readAuthCredentials, setAuthCredential } from '../../../core/auth-file.js';
|
|
15
17
|
import { ApiError } from '../../../api/index.js';
|
|
16
|
-
import { ANTHROPIC_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, commitManagedLogin, readSubscriptionPool, } from '../../../core/subscription-state.js';
|
|
18
|
+
import { ANTHROPIC_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, commitManagedLogin, readSubscriptionPool, removeAllManagedAccounts, } from '../../../core/subscription-state.js';
|
|
17
19
|
/** Resolve the shared pi credentials file, matching the daemon's auth.json
|
|
18
20
|
* mtime watcher exactly (`PI_CODING_AGENT_DIR` env, else `~/.pi/agent`) so a
|
|
19
21
|
* write here is the file that watcher fans a `reload_auth` for. */
|
|
@@ -43,6 +45,37 @@ function requireField(kind, field, value) {
|
|
|
43
45
|
}
|
|
44
46
|
return value;
|
|
45
47
|
}
|
|
48
|
+
function toManagedAccountStatus(account) {
|
|
49
|
+
return {
|
|
50
|
+
label: account.label,
|
|
51
|
+
expires_at: new Date(account.expires).toISOString(),
|
|
52
|
+
rate_limited_until: new Date(account.rateLimitedUntil).toISOString(),
|
|
53
|
+
...(account.authFailure ? { auth_failure: account.authFailure } : {}),
|
|
54
|
+
...(account.accountId ? { account_id: account.accountId } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function managedStatus(provider) {
|
|
58
|
+
const accounts = readSubscriptionPool(provider).map(toManagedAccountStatus);
|
|
59
|
+
return { provider, managed: true, configured: accounts.length > 0, accounts };
|
|
60
|
+
}
|
|
61
|
+
/** `GET /v1/model-auth` — credential truth projected without tokens, keys, or
|
|
62
|
+
* other auth-file material. Managed providers always reflect their complete
|
|
63
|
+
* pool rows, while non-managed presence reflects supported auth.json kinds. */
|
|
64
|
+
async function handleList() {
|
|
65
|
+
const credentials = await readAuthCredentials(authPath());
|
|
66
|
+
const providers = [
|
|
67
|
+
managedStatus(ANTHROPIC_PROVIDER_ID),
|
|
68
|
+
managedStatus(OPENAI_CODEX_PROVIDER_ID),
|
|
69
|
+
];
|
|
70
|
+
for (const [provider, credential] of Object.entries(credentials)) {
|
|
71
|
+
if (isManagedProvider(provider))
|
|
72
|
+
continue;
|
|
73
|
+
if (credential.type !== 'api_key' && credential.type !== 'oauth')
|
|
74
|
+
continue;
|
|
75
|
+
providers.push({ provider, managed: false, configured: true, kind: credential.type });
|
|
76
|
+
}
|
|
77
|
+
return { status: 200, body: { providers } };
|
|
78
|
+
}
|
|
46
79
|
/** `PUT /v1/model-auth/:provider` — idempotent upsert/rotate keyed by provider
|
|
47
80
|
* (and `account_id` for a managed account). */
|
|
48
81
|
async function handleInstall(ctx) {
|
|
@@ -111,6 +144,22 @@ async function handleInstall(ctx) {
|
|
|
111
144
|
throw new ApiError(400, 'invalid_request', `unknown credential kind: ${String(req.kind)}`);
|
|
112
145
|
}
|
|
113
146
|
}
|
|
147
|
+
/** `DELETE /v1/model-auth/:provider` — remove all durable user credentials for
|
|
148
|
+
* one provider. Managed OAuth accounts are cleared through their pool lock and
|
|
149
|
+
* any managed-provider API key through the auth.json lock; other providers use
|
|
150
|
+
* the auth.json owner alone. */
|
|
151
|
+
async function handleDelete(ctx) {
|
|
152
|
+
const provider = ctx.params['provider'];
|
|
153
|
+
if (isManagedProvider(provider)) {
|
|
154
|
+
const removedAccounts = await removeAllManagedAccounts(provider);
|
|
155
|
+
const removedApiKey = await deleteAuthCredential(provider, authPath());
|
|
156
|
+
return { status: 200, body: { provider, removed_accounts: removedAccounts + (removedApiKey ? 1 : 0) } };
|
|
157
|
+
}
|
|
158
|
+
const removedAccounts = await deleteAuthCredential(provider, authPath()) ? 1 : 0;
|
|
159
|
+
return { status: 200, body: { provider, removed_accounts: removedAccounts } };
|
|
160
|
+
}
|
|
114
161
|
export const modelAuthRoutes = [
|
|
162
|
+
{ method: 'GET', pattern: '/v1/model-auth', handler: handleList },
|
|
115
163
|
{ method: 'PUT', pattern: '/v1/model-auth/:provider', handler: handleInstall },
|
|
164
|
+
{ method: 'DELETE', pattern: '/v1/model-auth/:provider', handler: handleDelete },
|
|
116
165
|
];
|
|
@@ -365,6 +365,7 @@ function handleDemote(ctx) {
|
|
|
365
365
|
...(meta.launch?.modelProviderPinned !== undefined
|
|
366
366
|
? { modelProviderPinned: meta.launch.modelProviderPinned }
|
|
367
367
|
: {}),
|
|
368
|
+
...(meta.launch?.modelIntent !== undefined ? { modelIntent: meta.launch.modelIntent } : {}),
|
|
368
369
|
cwd: meta.cwd,
|
|
369
370
|
profileId: meta.profile_id ?? null,
|
|
370
371
|
});
|
|
@@ -485,6 +486,7 @@ async function handleConfig(ctx) {
|
|
|
485
486
|
hasManager: meta.parent !== null,
|
|
486
487
|
...(meta.model_override != null ? { model: meta.model_override } : {}),
|
|
487
488
|
...(meta.launch?.modelProviderPinned !== undefined ? { modelProviderPinned: meta.launch.modelProviderPinned } : {}),
|
|
489
|
+
...(meta.launch?.modelIntent !== undefined ? { modelIntent: meta.launch.modelIntent } : {}),
|
|
488
490
|
cwd: meta.cwd,
|
|
489
491
|
profileId: meta.profile_id ?? null,
|
|
490
492
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/pi-extensions/__tests__/canvas-tool-guide.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Regression: canvas brokers must load and inject the root crtr guide themselves;
|
|
4
|
+
// optional pi packages are not part of the canvas runtime contract.
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { delimiter, join } from 'node:path';
|
|
10
|
+
import { CANVAS_EXTENSIONS, CANVAS_TOOL_GUIDE_PATH } from '../../core/runtime/canvas-extensions.js';
|
|
11
|
+
import registerCanvasToolGuide from '../canvas-tool-guide.js';
|
|
12
|
+
function fakePi() {
|
|
13
|
+
return {
|
|
14
|
+
on(event, handler) {
|
|
15
|
+
if (event === 'before_agent_start')
|
|
16
|
+
this.beforeAgentStart = handler;
|
|
17
|
+
if (event === 'session_start')
|
|
18
|
+
this.sessionStart = handler;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
test('canvas nodes load the cwd-specific root guide, share its boot cache, and never capture during a turn', async () => {
|
|
23
|
+
assert.ok(CANVAS_EXTENSIONS.includes(CANVAS_TOOL_GUIDE_PATH), 'the guide is a mandatory canvas extension');
|
|
24
|
+
const root = mkdtempSync(join(tmpdir(), 'crtr-tool-guide-'));
|
|
25
|
+
const bin = join(root, 'bin');
|
|
26
|
+
const cwd = join(root, 'configured-project');
|
|
27
|
+
const countFile = join(root, 'capture-count');
|
|
28
|
+
const capturedCwdFile = join(root, 'capture-cwd');
|
|
29
|
+
const previous = {
|
|
30
|
+
cwd: process.cwd(),
|
|
31
|
+
home: process.env['HOME'],
|
|
32
|
+
nodeId: process.env['CRTR_NODE_ID'],
|
|
33
|
+
path: process.env['PATH'],
|
|
34
|
+
};
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(bin);
|
|
37
|
+
mkdirSync(cwd);
|
|
38
|
+
const command = join(bin, 'crtr');
|
|
39
|
+
writeFileSync(command, `#!/bin/sh
|
|
40
|
+
count=0
|
|
41
|
+
if [ -f "$CAPTURE_COUNT" ]; then count=$(cat "$CAPTURE_COUNT"); fi
|
|
42
|
+
printf '%s' "$((count + 1))" > "$CAPTURE_COUNT"
|
|
43
|
+
printf '%s' "$PWD" > "$CAPTURE_CWD"
|
|
44
|
+
printf '%s\\n' 'configured root guide'
|
|
45
|
+
`);
|
|
46
|
+
chmodSync(command, 0o755);
|
|
47
|
+
process.chdir(cwd);
|
|
48
|
+
process.env['HOME'] = root;
|
|
49
|
+
process.env['CRTR_NODE_ID'] = 'canvas-tool-guide-test';
|
|
50
|
+
process.env['PATH'] = `${bin}${delimiter}${previous.path ?? ''}`;
|
|
51
|
+
process.env['CAPTURE_COUNT'] = countFile;
|
|
52
|
+
process.env['CAPTURE_CWD'] = capturedCwdFile;
|
|
53
|
+
const first = fakePi();
|
|
54
|
+
registerCanvasToolGuide(first);
|
|
55
|
+
const firstSessionStart = first.sessionStart;
|
|
56
|
+
assert.ok(firstSessionStart, 'session-start capture is registered');
|
|
57
|
+
await firstSessionStart();
|
|
58
|
+
const second = fakePi();
|
|
59
|
+
registerCanvasToolGuide(second);
|
|
60
|
+
const secondSessionStart = second.sessionStart;
|
|
61
|
+
assert.ok(secondSessionStart, 'session-start capture is registered for every broker');
|
|
62
|
+
await secondSessionStart();
|
|
63
|
+
assert.equal(readFileSync(countFile, 'utf8'), '1', 'a sibling broker uses the 15-second disk cache');
|
|
64
|
+
assert.equal(readFileSync(capturedCwdFile, 'utf8'), realpathSync(cwd), 'root help discovers commands from the node cwd');
|
|
65
|
+
const beforeAgentStart = second.beforeAgentStart;
|
|
66
|
+
assert.ok(beforeAgentStart, 'turn-time guide injection is registered');
|
|
67
|
+
const prompt = 'Available tools\n\nGuidelines:\nUse the available tools.';
|
|
68
|
+
assert.equal(beforeAgentStart({ systemPrompt: prompt })?.systemPrompt, 'Available tools\n\n<crtr-tool-guide>\nconfigured root guide\n</crtr-tool-guide>\n\nGuidelines:\nUse the available tools.', 'the captured guide sits in the tool-selection frame');
|
|
69
|
+
rmSync(join(root, '.crouter'), { recursive: true, force: true });
|
|
70
|
+
const unbooted = fakePi();
|
|
71
|
+
registerCanvasToolGuide(unbooted);
|
|
72
|
+
const unbootedBeforeAgentStart = unbooted.beforeAgentStart;
|
|
73
|
+
assert.ok(unbootedBeforeAgentStart, 'turn-time guide injection is registered before boot capture');
|
|
74
|
+
assert.equal(unbootedBeforeAgentStart({ systemPrompt: prompt }), undefined, 'a node without a boot capture starts without a guide');
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
76
|
+
assert.equal(readFileSync(countFile, 'utf8'), '1', 'a turn never starts a root-help subprocess');
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
process.chdir(previous.cwd);
|
|
80
|
+
if (previous.home === undefined)
|
|
81
|
+
delete process.env['HOME'];
|
|
82
|
+
else
|
|
83
|
+
process.env['HOME'] = previous.home;
|
|
84
|
+
if (previous.nodeId === undefined)
|
|
85
|
+
delete process.env['CRTR_NODE_ID'];
|
|
86
|
+
else
|
|
87
|
+
process.env['CRTR_NODE_ID'] = previous.nodeId;
|
|
88
|
+
if (previous.path === undefined)
|
|
89
|
+
delete process.env['PATH'];
|
|
90
|
+
else
|
|
91
|
+
process.env['PATH'] = previous.path;
|
|
92
|
+
delete process.env['CAPTURE_COUNT'];
|
|
93
|
+
delete process.env['CAPTURE_CWD'];
|
|
94
|
+
rmSync(root, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
@@ -12,16 +12,15 @@
|
|
|
12
12
|
// into `event.systemPrompt`, right after pi's native "Available tools" list
|
|
13
13
|
// (before the `\n\nGuidelines:` anchor), so the substrate's standing
|
|
14
14
|
// directives sit in the tool-selection frame the agent reads while choosing
|
|
15
|
-
// a capability
|
|
16
|
-
// back to appending when the anchor is absent. (The `<memory kind="knowledge">`
|
|
15
|
+
// a capability. Falls back to appending when the anchor is absent. (The `<memory kind="knowledge">`
|
|
17
16
|
// catalog rides the bearings message instead — see core/substrate/render.ts.)
|
|
18
17
|
//
|
|
19
18
|
// 2. tool_result (gated on `read`) — the ON-READ half. For each non-error
|
|
20
|
-
// `read`,
|
|
21
|
-
//
|
|
22
|
-
// file-read
|
|
23
|
-
// before the file contents. A
|
|
24
|
-
//
|
|
19
|
+
// `read`, evaluates explicit `applies-to` globs (plus `read-when` metadata)
|
|
20
|
+
// across the resolved corpus and any project stores enclosing the read
|
|
21
|
+
// file. Matches render at their file-read rung in `<auto-loaded-context>`
|
|
22
|
+
// before the file contents. A transcript-scoped realpath set deduplicates
|
|
23
|
+
// repeated reads and resumed broker processes.
|
|
25
24
|
//
|
|
26
25
|
// Both handler bodies are wrapped in try/catch and degrade to INERT on error —
|
|
27
26
|
// a substrate bug must never brick node boot or break a `read`, only quietly
|
|
@@ -42,7 +41,7 @@ import { loadInjectedDocs, saveInjectedDocs } from '../core/substrate/injected-s
|
|
|
42
41
|
/** The stable seam that closes pi's native "Available tools" list. We splice the
|
|
43
42
|
* substrate's boot sections right before it — the agent decides which capability
|
|
44
43
|
* to reach for while reading the tools, so memory docs/preferences must sit
|
|
45
|
-
* there, not far below.
|
|
44
|
+
* there, not far below. Falls back to appending if absent. */
|
|
46
45
|
const TOOLS_ANCHOR = '\n\nGuidelines:';
|
|
47
46
|
const AUTO_CONTEXT_RE = /<auto-loaded-context(?:\s[^>]*)?>\n([\s\S]*?)\n<\/auto-loaded-context>/;
|
|
48
47
|
function mergeAutoLoadedContext(injected, content) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
declare function injectToolGuide(systemPrompt: string, helpText: string): string;
|
|
2
|
+
interface BeforeAgentStartEventLike {
|
|
3
|
+
systemPrompt: string;
|
|
4
|
+
}
|
|
5
|
+
interface PiLike {
|
|
6
|
+
on(event: 'session_start', handler: () => void | Promise<void>): void;
|
|
7
|
+
on(event: 'before_agent_start', handler: (event: BeforeAgentStartEventLike) => {
|
|
8
|
+
systemPrompt: string;
|
|
9
|
+
} | void): void;
|
|
10
|
+
}
|
|
11
|
+
/** Register the root crtr tool-guide injection for a canvas node. */
|
|
12
|
+
export declare function registerCanvasToolGuide(pi: PiLike): void;
|
|
13
|
+
export { injectToolGuide };
|
|
14
|
+
export default registerCanvasToolGuide;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// canvas-tool-guide.ts — injects the root crtr CLI guide into every canvas node.
|
|
2
|
+
//
|
|
3
|
+
// Capturing `crtr -h` builds the full configured command tree, including commands
|
|
4
|
+
// discovered from the node's cwd. It runs only at session start; before_agent_start
|
|
5
|
+
// injects the last successful capture without starting work or waiting.
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { promisify } from 'node:util';
|
|
12
|
+
const run = promisify(execFile);
|
|
13
|
+
const TTL_MS = 15_000;
|
|
14
|
+
const TOOLS_ANCHOR = '\n\nGuidelines:';
|
|
15
|
+
function helpCacheFile(cwd) {
|
|
16
|
+
const hash = createHash('sha1').update(cwd).digest('hex').slice(0, 16);
|
|
17
|
+
return join(homedir(), '.crouter', 'cache', `crtr-tool-guide-${hash}.json`);
|
|
18
|
+
}
|
|
19
|
+
function injectToolGuide(systemPrompt, helpText) {
|
|
20
|
+
const block = `<crtr-tool-guide>\n${helpText}\n</crtr-tool-guide>`;
|
|
21
|
+
const anchor = systemPrompt.indexOf(TOOLS_ANCHOR);
|
|
22
|
+
if (anchor === -1)
|
|
23
|
+
return `${systemPrompt}\n\n${block}`;
|
|
24
|
+
return `${systemPrompt.slice(0, anchor)}\n\n${block}${systemPrompt.slice(anchor)}`;
|
|
25
|
+
}
|
|
26
|
+
/** Register the root crtr tool-guide injection for a canvas node. */
|
|
27
|
+
export function registerCanvasToolGuide(pi) {
|
|
28
|
+
const nodeId = process.env['CRTR_NODE_ID'];
|
|
29
|
+
if (nodeId === undefined || nodeId.trim() === '')
|
|
30
|
+
return;
|
|
31
|
+
let helpText = '';
|
|
32
|
+
let capturedAt = 0;
|
|
33
|
+
async function refresh() {
|
|
34
|
+
if (helpText !== '' && Date.now() - capturedAt < TTL_MS)
|
|
35
|
+
return;
|
|
36
|
+
const file = helpCacheFile(process.cwd());
|
|
37
|
+
try {
|
|
38
|
+
const cached = JSON.parse(readFileSync(file, 'utf8'));
|
|
39
|
+
if (typeof cached.text === 'string' && cached.text !== '' && typeof cached.at === 'number' && Date.now() - cached.at < TTL_MS) {
|
|
40
|
+
helpText = cached.text;
|
|
41
|
+
capturedAt = cached.at;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// A missing or invalid cache is refreshed below.
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const { stdout } = await run('crtr', ['-h'], { timeout: 10_000 });
|
|
50
|
+
helpText = stdout.trim();
|
|
51
|
+
capturedAt = Date.now();
|
|
52
|
+
try {
|
|
53
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
54
|
+
writeFileSync(file, JSON.stringify({ text: helpText, at: capturedAt }), 'utf8');
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// The guide remains available in this broker even when its cache cannot persist.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Keep the last successful guide and never delay a turn for a failed capture.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Node startup is the only point a capture may wait. Warm spares pay it before
|
|
65
|
+
// a user claims them; resumed nodes refresh before their next turn.
|
|
66
|
+
pi.on('session_start', refresh);
|
|
67
|
+
pi.on('before_agent_start', (event) => {
|
|
68
|
+
if (helpText === '')
|
|
69
|
+
return;
|
|
70
|
+
return { systemPrompt: injectToolGuide(event.systemPrompt, helpText) };
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export { injectToolGuide };
|
|
74
|
+
export default registerCanvasToolGuide;
|
package/dist/types.d.ts
CHANGED
|
@@ -99,6 +99,19 @@ export interface ModelLaddersConfig {
|
|
|
99
99
|
anthropic: Record<ModelStrength, string>;
|
|
100
100
|
openai: Record<ModelStrength, string>;
|
|
101
101
|
}
|
|
102
|
+
/** A declared provider/model route. Route ids are the keys of `modelRoutes` so
|
|
103
|
+
* scopes can override one route without restating the registry. */
|
|
104
|
+
export interface ModelRouteConfig {
|
|
105
|
+
family: string;
|
|
106
|
+
credentialSource: string;
|
|
107
|
+
providerId: string;
|
|
108
|
+
models: Partial<Record<ModelStrength, string>>;
|
|
109
|
+
}
|
|
110
|
+
export interface ModelRoutingConfig {
|
|
111
|
+
credentialSourceOrder?: string[];
|
|
112
|
+
familyOrder?: string[];
|
|
113
|
+
strengthFallback?: ModelStrength[];
|
|
114
|
+
}
|
|
102
115
|
/** Launch metadata for one kind (a top-level kind like `developer`, or a full
|
|
103
116
|
* sub-kind string like `plan/reviewers/security`) — the settings-file
|
|
104
117
|
* replacement for per-kind persona frontmatter. `model`/`tools`/`extensions`
|
|
@@ -159,6 +172,10 @@ export interface ScopeConfig {
|
|
|
159
172
|
fold_finished_tools: boolean;
|
|
160
173
|
keybindings: KeybindingOverrides;
|
|
161
174
|
modelLadders: ModelLaddersConfig;
|
|
175
|
+
/** Declarative provider/model routes. Absent uses the synthesized ladder registry. */
|
|
176
|
+
modelRoutes?: Record<string, ModelRouteConfig>;
|
|
177
|
+
/** Ordering overrides for declared model routes. */
|
|
178
|
+
modelRouting?: ModelRoutingConfig;
|
|
162
179
|
/** The kind registry (spec §1.5): kind existence + launch knobs, keyed by
|
|
163
180
|
* full kind string (top-level e.g. `developer`, or sub-kind e.g.
|
|
164
181
|
* `plan/reviewers/security`). Builtins ship a default registry via
|