@north-light/crouter 0.3.219 → 0.3.221
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 +9 -0
- package/dist/api/client.js +10 -0
- package/dist/api/dto/chat-inventory.d.ts +43 -0
- package/dist/api/dto/chat-inventory.js +11 -0
- package/dist/api/dto/profiles.d.ts +19 -5
- package/dist/api/dto/profiles.js +2 -1
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/build-root.d.ts +2 -6
- package/dist/build-root.js +51 -4
- package/dist/builtin-memory/internal/agent-shaping.md +3 -1
- package/dist/builtin-memory/internal/memory-loading.md +4 -0
- package/dist/builtin-memory/plan/roadmap.md +7 -1
- package/dist/builtin-memory/spec/guide.md +7 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/claude-plugin-commands.ts +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crtr-commands/index.ts +7 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +7 -2
- package/dist/clients/attach/__tests__/ref-autocomplete.test.js +1 -1
- package/dist/clients/attach/__tests__/titled-editor-preview.test.js +1 -1
- package/dist/clients/attach/overlays/file-review.js +2 -2
- package/dist/clients/attach/session/keys.d.ts +1 -1
- package/dist/clients/attach/session/profile-files.js +1 -1
- package/dist/clients/attach/viewer.js +690 -690
- package/dist/clients/inbox/review/launch.d.ts +8 -4
- package/dist/clients/inbox/review/launch.js +55 -5
- package/dist/clients/inbox/review/review-client.d.ts +1 -0
- package/dist/clients/inbox/review/review-client.js +4 -0
- package/dist/clients/inbox/review-adapter.d.ts +1 -8
- package/dist/clients/inbox/review-adapter.js +4 -52
- package/dist/commands/memory/lint.js +2 -1
- package/dist/commands/memory/read.js +1 -0
- package/dist/commands/memory.js +1 -1
- package/dist/commands/pkg/market-manage.js +165 -75
- package/dist/commands/pkg/plugin-inspect.js +19 -2
- package/dist/commands/pkg/plugin-manage.d.ts +8 -3
- package/dist/commands/pkg/plugin-manage.js +72 -24
- package/dist/commands/profile/default.js +6 -10
- package/dist/commands/profile/list.js +5 -3
- package/dist/commands/profile/new.js +21 -8
- package/dist/commands/profile/project.js +25 -19
- package/dist/commands/profile/show.js +3 -3
- package/dist/commands/surface-inbox.js +1 -0
- package/dist/commands/sys/__tests__/migrate.test.js +16 -5
- package/dist/commands/sys/doctor.js +35 -5
- package/dist/commands/sys/migrate.js +38 -19
- package/dist/commands/sys/panels/broker-limits-panel.js +3 -3
- package/dist/commands/sys/setup-core.js +1 -1
- package/dist/commands/sys/sync-project-guidance.js +1 -1
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +1 -1
- package/dist/core/__tests__/fixtures/c5-command-boundary-ext.js +24 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +24 -18
- package/dist/core/__tests__/fixtures/fake-engine.js +8 -1
- package/dist/core/__tests__/helpers/broker-clients.d.ts +1 -0
- package/dist/core/__tests__/helpers/broker-clients.js +1 -0
- package/dist/core/__tests__/inline-memory-refs.test.js +36 -2
- package/dist/core/__tests__/profile-project-memory-delivery.test.js +217 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +37 -1
- package/dist/core/__tests__/serial/broker-sdk-wiring.test.js +102 -2
- package/dist/core/bootstrap.js +6 -0
- package/dist/core/canvas/browse/app.js +5 -2
- package/dist/core/canvas/browse/model.d.ts +25 -15
- package/dist/core/canvas/browse/model.js +86 -65
- package/dist/core/canvas/render-source.d.ts +6 -0
- package/dist/core/canvas/render-source.js +7 -1
- package/dist/core/canvas/render.js +10 -2
- package/dist/core/command-hooks/artifact.d.ts +10 -0
- package/dist/core/command-hooks/artifact.js +129 -0
- package/dist/core/command-hooks/catalog.d.ts +14 -0
- package/dist/core/command-hooks/catalog.js +38 -0
- package/dist/core/command-hooks/compose.d.ts +15 -0
- package/dist/core/command-hooks/compose.js +99 -0
- package/dist/core/command-hooks/discovery.d.ts +87 -0
- package/dist/core/command-hooks/discovery.js +174 -0
- package/dist/core/command-hooks/help.d.ts +5 -0
- package/dist/core/command-hooks/help.js +18 -0
- package/dist/core/command-hooks/index.d.ts +6 -0
- package/dist/core/command-hooks/index.js +6 -0
- package/dist/core/command-hooks/report.d.ts +23 -0
- package/dist/core/command-hooks/report.js +19 -0
- package/dist/core/command-hooks/schema.d.ts +27 -0
- package/dist/core/command-hooks/schema.js +68 -0
- package/dist/core/command-hooks/transport/exec-invoke.d.ts +22 -0
- package/dist/core/command-hooks/transport/exec-invoke.js +274 -0
- package/dist/core/command-plugins/presence.d.ts +2 -0
- package/dist/core/command-plugins/presence.js +17 -0
- package/dist/core/command-plugins/transport/exec-invoke.d.ts +5 -0
- package/dist/core/command-plugins/transport/exec-invoke.js +58 -5
- package/dist/core/command.d.ts +8 -1
- package/dist/core/command.js +12 -10
- package/dist/core/help.d.ts +7 -1
- package/dist/core/io.d.ts +9 -1
- package/dist/core/io.js +44 -2
- package/dist/core/memory/inline-ref-inventory.d.ts +2 -1
- package/dist/core/memory/inline-ref-inventory.js +15 -8
- package/dist/core/memory-resolver.d.ts +13 -1
- package/dist/core/memory-resolver.js +25 -19
- package/dist/core/profiles/manifest.d.ts +13 -2
- package/dist/core/profiles/manifest.js +84 -18
- package/dist/core/profiles/select.js +9 -9
- package/dist/core/render.js +11 -0
- package/dist/core/runtime/advertised-command-invocation.d.ts +20 -0
- package/dist/core/runtime/advertised-command-invocation.js +233 -0
- package/dist/core/runtime/bearings.js +1 -1
- package/dist/core/runtime/broker/client-registry.d.ts +6 -3
- package/dist/core/runtime/broker/client-registry.js +6 -4
- package/dist/core/runtime/broker/event-projection.js +7 -0
- package/dist/core/runtime/broker/frame-dispatch.d.ts +1 -1
- package/dist/core/runtime/broker/frame-dispatch.js +16 -10
- package/dist/core/runtime/broker/read-ops.d.ts +4 -0
- package/dist/core/runtime/broker/read-ops.js +6 -2
- package/dist/core/runtime/broker-extension-render.js +1 -1
- package/dist/core/runtime/broker-inventory.d.ts +5 -0
- package/dist/core/runtime/broker-inventory.js +191 -0
- package/dist/core/runtime/broker-protocol.d.ts +13 -2
- package/dist/core/runtime/broker.js +10 -1
- package/dist/core/runtime/command-surface.d.ts +33 -0
- package/dist/core/runtime/command-surface.js +81 -0
- package/dist/core/runtime/node-read.js +5 -0
- package/dist/core/scope.d.ts +26 -1
- package/dist/core/scope.js +52 -12
- package/dist/core/substrate/on-read.d.ts +7 -1
- package/dist/core/substrate/on-read.js +13 -4
- package/dist/core/substrate/render.js +14 -5
- package/dist/core/substrate/schema.d.ts +11 -1
- package/dist/core/substrate/schema.js +11 -2
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +4 -4
- package/dist/daemon/api/handlers/chat-inventory.d.ts +2 -0
- package/dist/daemon/api/handlers/chat-inventory.js +25 -0
- package/dist/daemon/api/handlers/profiles.js +7 -1
- package/dist/daemon/api/map.d.ts +2 -1
- package/dist/daemon/api/map.js +3 -2
- package/dist/daemon/api/server.js +2 -0
- package/dist/hook-authoring.d.ts +75 -0
- package/dist/hook-authoring.js +358 -0
- package/dist/hook-process.d.ts +7 -0
- package/dist/hook-process.js +34 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/migrations/002-profile-project-memory.d.ts +2 -0
- package/dist/migrations/002-profile-project-memory.js +71 -0
- package/dist/migrations/profile-manifests.d.ts +30 -0
- package/dist/migrations/profile-manifests.js +70 -0
- package/dist/migrations/registry.js +10 -5
- package/dist/migrations/types.d.ts +28 -1
- package/dist/migrations/types.js +15 -9
- package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +21 -4
- package/dist/pi-extensions/canvas-structured-output.js +85 -2
- package/dist/types.d.ts +15 -6
- package/dist/types.js +5 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/clients/attach/__tests__/file-review-focus.test.js +0 -49
- /package/dist/{clients/attach/__tests__/file-review-focus.test.d.ts → core/__tests__/profile-project-memory-delivery.test.d.ts} +0 -0
|
@@ -160,24 +160,18 @@ export function matchIndices(query, text) {
|
|
|
160
160
|
}
|
|
161
161
|
/** Chars of leading context kept before the match start when windowing. */
|
|
162
162
|
const SNIPPET_LEAD = 12;
|
|
163
|
-
/**
|
|
164
|
-
*
|
|
165
|
-
* scattered subsequence indices, else empty (empty query). This is a different,
|
|
166
|
-
* stricter model than the subsequence super-search on purpose — a contiguous span
|
|
167
|
-
* is what reads as a highlight. */
|
|
163
|
+
/** The literal case-insensitive substring span in a conversation preview.
|
|
164
|
+
* Scattered letters are not a message match and never receive a highlight. */
|
|
168
165
|
export function highlightIndices(query, text) {
|
|
169
166
|
if (query === '')
|
|
170
167
|
return new Set();
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return out;
|
|
179
|
-
}
|
|
180
|
-
return matchIndices(query, text);
|
|
168
|
+
const sub = text.toLowerCase().indexOf(query.toLowerCase());
|
|
169
|
+
if (sub < 0)
|
|
170
|
+
return new Set();
|
|
171
|
+
const out = new Set();
|
|
172
|
+
for (let i = sub; i < sub + query.length; i++)
|
|
173
|
+
out.add(i);
|
|
174
|
+
return out;
|
|
181
175
|
}
|
|
182
176
|
/** Greedy word-wrap of single-spaced `s` (caller normalizes whitespace) to `width`
|
|
183
177
|
* cols × `maxLines` lines, returning each line's text AND its start offset in `s`.
|
|
@@ -233,17 +227,7 @@ export function previewSnippet(query, text, width, maxLines) {
|
|
|
233
227
|
return [];
|
|
234
228
|
// 1. Locate the match in the normalized text + choose a window start that keeps
|
|
235
229
|
// a little leading context, snapped to a word boundary.
|
|
236
|
-
|
|
237
|
-
if (query !== '') {
|
|
238
|
-
const sub = norm.toLowerCase().indexOf(query.toLowerCase());
|
|
239
|
-
if (sub >= 0)
|
|
240
|
-
matchStart = sub;
|
|
241
|
-
else {
|
|
242
|
-
const ix = matchIndices(query, norm);
|
|
243
|
-
if (ix.size > 0)
|
|
244
|
-
matchStart = Math.min(...ix);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
230
|
+
const matchStart = query === '' ? -1 : norm.toLowerCase().indexOf(query.toLowerCase());
|
|
247
231
|
let windowStart = 0;
|
|
248
232
|
if (matchStart > SNIPPET_LEAD) {
|
|
249
233
|
let s = matchStart - SNIPPET_LEAD;
|
|
@@ -274,52 +258,69 @@ function shortId(id) {
|
|
|
274
258
|
export function promptText(row) {
|
|
275
259
|
return row.prompts ?? row.goal ?? '';
|
|
276
260
|
}
|
|
277
|
-
/** Does this row match the live query?
|
|
278
|
-
*
|
|
279
|
-
* in the conversation (`promptText`). Empty query matches everything. */
|
|
261
|
+
/** Does this row match the live query? Empty query matches everything; a
|
|
262
|
+
* non-empty query uses the same fields and match tiers as relevance ranking. */
|
|
280
263
|
export function queryMatch(query, row) {
|
|
281
|
-
|
|
282
|
-
return true;
|
|
283
|
-
const prompt = promptText(row);
|
|
284
|
-
return (fuzzyMatch(query, row.name) ||
|
|
285
|
-
fuzzyMatch(query, row.kind) ||
|
|
286
|
-
fuzzyMatch(query, shortId(row.node_id)) ||
|
|
287
|
-
(prompt !== '' && fuzzyMatch(query, prompt)));
|
|
264
|
+
return query === '' || scoreRow(query, row).tier > 0;
|
|
288
265
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
266
|
+
const NO_MATCH = { tier: 0, quality: 0 };
|
|
267
|
+
const WORD_CHAR = /[\p{L}\p{N}]/u;
|
|
268
|
+
function isWordChar(char) {
|
|
269
|
+
return char !== undefined && WORD_CHAR.test(char);
|
|
270
|
+
}
|
|
271
|
+
/** One field's best match. Match class is deliberately separate from quality:
|
|
272
|
+
* a bounded literal always outranks a contiguous partial, which always outranks
|
|
273
|
+
* a scattered subsequence, regardless of field weights. */
|
|
274
|
+
export function fieldMatch(query, text) {
|
|
296
275
|
if (query === '' || text === '')
|
|
297
|
-
return
|
|
276
|
+
return NO_MATCH;
|
|
298
277
|
const q = query.toLowerCase();
|
|
299
278
|
const t = text.toLowerCase();
|
|
300
|
-
const
|
|
301
|
-
if (
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
279
|
+
const firstIdx = t.indexOf(q);
|
|
280
|
+
if (firstIdx >= 0) {
|
|
281
|
+
for (let idx = firstIdx; idx >= 0; idx = t.indexOf(q, idx + 1)) {
|
|
282
|
+
const end = idx + q.length;
|
|
283
|
+
const leftBounded = !isWordChar(q[0]) || !isWordChar(t[idx - 1]);
|
|
284
|
+
const rightBounded = !isWordChar(q[q.length - 1]) || !isWordChar(t[end]);
|
|
285
|
+
if (leftBounded && rightBounded)
|
|
286
|
+
return { tier: 3, quality: t === q ? 1 : 0.95 };
|
|
287
|
+
}
|
|
288
|
+
const position = Math.min(0.2, firstIdx / 400);
|
|
289
|
+
return { tier: 2, quality: (firstIdx === 0 ? 1 : 0.8) - position };
|
|
307
290
|
}
|
|
308
|
-
|
|
291
|
+
const indices = [...matchIndices(q, t)];
|
|
292
|
+
if (indices.length === 0)
|
|
293
|
+
return NO_MATCH;
|
|
294
|
+
const span = indices[indices.length - 1] - indices[0] + 1;
|
|
295
|
+
return { tier: 1, quality: q.length / span };
|
|
296
|
+
}
|
|
297
|
+
/** Explicit handles and user prompts are primary. The generated description is
|
|
298
|
+
* searchable but lower-weight; assistant replies are preview-only. */
|
|
299
|
+
const FIELD_WEIGHTS = { handle: 4, prompt: 4, kind: 2, description: 1.5, id: 1 };
|
|
300
|
+
function explicitHandle(row) {
|
|
301
|
+
const handle = row.handle ?? row.name;
|
|
302
|
+
return handle === row.kind ? '' : handle;
|
|
309
303
|
}
|
|
310
|
-
/**
|
|
311
|
-
*
|
|
312
|
-
const FIELD_WEIGHTS = { name: 4, kind: 2, id: 1, prompt: 1.5 };
|
|
313
|
-
/** Weighted relevance of a row to the query across all searched fields. 0 means
|
|
314
|
-
* no field matched (excluded from relevance results, same as `queryMatch`). */
|
|
304
|
+
/** Rank a row by its strongest match class, then by weighted evidence only from
|
|
305
|
+
* that class. Weaker classes cannot add up to beat one stronger literal match. */
|
|
315
306
|
export function scoreRow(query, row) {
|
|
316
307
|
if (query === '')
|
|
317
|
-
return 0;
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
308
|
+
return { tier: 0, strength: 0 };
|
|
309
|
+
const fields = [
|
|
310
|
+
{ text: explicitHandle(row), weight: FIELD_WEIGHTS.handle, minimumTier: 1 },
|
|
311
|
+
// Scattered subsequences in long messages are random noise, not matches.
|
|
312
|
+
{ text: promptText(row), weight: FIELD_WEIGHTS.prompt, minimumTier: 2 },
|
|
313
|
+
{ text: row.kind, weight: FIELD_WEIGHTS.kind, minimumTier: 1 },
|
|
314
|
+
{ text: row.description ?? '', weight: FIELD_WEIGHTS.description, minimumTier: 1 },
|
|
315
|
+
{ text: shortId(row.node_id), weight: FIELD_WEIGHTS.id, minimumTier: 1 },
|
|
316
|
+
];
|
|
317
|
+
const matches = fields.map(({ text, weight, minimumTier }) => {
|
|
318
|
+
const match = fieldMatch(query, text);
|
|
319
|
+
return { match: match.tier >= minimumTier ? match : NO_MATCH, weight };
|
|
320
|
+
});
|
|
321
|
+
const tier = Math.max(...matches.map(({ match }) => match.tier));
|
|
322
|
+
const strength = matches.reduce((sum, { match, weight }) => sum + (match.tier === tier ? match.quality * weight : 0), 0);
|
|
323
|
+
return { tier, strength };
|
|
323
324
|
}
|
|
324
325
|
// ---------------------------------------------------------------------------
|
|
325
326
|
// Attention tiering (default sort)
|
|
@@ -339,6 +340,14 @@ export function attentionMtime(row) {
|
|
|
339
340
|
const t = Date.parse(row.created);
|
|
340
341
|
return Number.isFinite(t) ? t : 0;
|
|
341
342
|
}
|
|
343
|
+
const RELEVANCE_RECENCY_MAX_BOOST = 0.1;
|
|
344
|
+
const RELEVANCE_RECENCY_HALF_LIFE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
345
|
+
/** Up to a 10% relevance lift for recent conversation activity, halving after a
|
|
346
|
+
* week. Match tier remains separate, so freshness never promotes fuzzy over literal. */
|
|
347
|
+
export function relevanceFreshness(row, now) {
|
|
348
|
+
const age = Math.max(0, now - attentionMtime(row));
|
|
349
|
+
return 1 + RELEVANCE_RECENCY_MAX_BOOST / (1 + age / RELEVANCE_RECENCY_HALF_LIFE_MS);
|
|
350
|
+
}
|
|
342
351
|
/** Attention tier (lower = higher priority, shown first):
|
|
343
352
|
* T0 — attached AND streaming (`viewed && streaming`)
|
|
344
353
|
* T1 — attached, not streaming (`viewed`)
|
|
@@ -427,9 +436,21 @@ export function flatten(tree, opts) {
|
|
|
427
436
|
}
|
|
428
437
|
else {
|
|
429
438
|
const score = new Map();
|
|
439
|
+
const rowOf = (id) => tree.nodes.get(id).row;
|
|
440
|
+
const now = Date.now();
|
|
430
441
|
for (const id of ids)
|
|
431
|
-
score.set(id, scoreRow(query,
|
|
432
|
-
ids.sort((a, b) =>
|
|
442
|
+
score.set(id, scoreRow(query, rowOf(id)));
|
|
443
|
+
ids.sort((a, b) => {
|
|
444
|
+
const aScore = score.get(a);
|
|
445
|
+
const bScore = score.get(b);
|
|
446
|
+
const aStrength = aScore.strength * relevanceFreshness(rowOf(a), now);
|
|
447
|
+
const bStrength = bScore.strength * relevanceFreshness(rowOf(b), now);
|
|
448
|
+
return (pinRank(a) - pinRank(b))
|
|
449
|
+
|| (bScore.tier - aScore.tier)
|
|
450
|
+
|| (bStrength - aStrength)
|
|
451
|
+
|| (attentionMtime(rowOf(b)) - attentionMtime(rowOf(a)))
|
|
452
|
+
|| byRecency(a, b);
|
|
453
|
+
});
|
|
433
454
|
}
|
|
434
455
|
return ids.map((id) => ({ id, depth: 0, hasChildren: false, collapsed: false, matched: true }));
|
|
435
456
|
}
|
|
@@ -16,7 +16,13 @@ export declare function renderTreeFromSource(source: CanvasSource, rootId: strin
|
|
|
16
16
|
export declare function renderForestFromSource(source: CanvasSource): Promise<string>;
|
|
17
17
|
export interface DashboardRow {
|
|
18
18
|
node_id: string;
|
|
19
|
+
/** Full display label: explicit handle plus generated description. */
|
|
19
20
|
name: string;
|
|
21
|
+
/** Explicit node handle, kept separate so search does not treat generated text
|
|
22
|
+
* as part of the higher-weight name field. */
|
|
23
|
+
handle?: string;
|
|
24
|
+
/** Generated first-task description. Searchable below user-authored prompts. */
|
|
25
|
+
description?: string;
|
|
20
26
|
status: NodeStatus;
|
|
21
27
|
kind: string;
|
|
22
28
|
mode: string;
|
|
@@ -366,6 +366,7 @@ export function dashboardRowFromNodeRow(row, focusedNodeIds, remote) {
|
|
|
366
366
|
return {
|
|
367
367
|
node_id: row.node_id,
|
|
368
368
|
name: row.name, // handle only; enrichRow upgrades to fullName (meta.description)
|
|
369
|
+
handle: row.name,
|
|
369
370
|
status: row.status,
|
|
370
371
|
kind: row.kind,
|
|
371
372
|
mode: row.mode,
|
|
@@ -399,6 +400,8 @@ export async function dashboardRowsFromSource(source, rootId) {
|
|
|
399
400
|
rows.push({
|
|
400
401
|
node_id: id,
|
|
401
402
|
name: fullName(node),
|
|
403
|
+
handle: node.name,
|
|
404
|
+
description: node.description,
|
|
402
405
|
status: node.status,
|
|
403
406
|
kind: node.kind,
|
|
404
407
|
mode: node.mode,
|
|
@@ -440,8 +443,11 @@ export async function enrichRowsFromSource(source, rows, asks) {
|
|
|
440
443
|
// the client's cold-daemon path even though the daemon is still running.
|
|
441
444
|
for (const row of todo) {
|
|
442
445
|
const meta = await source.getNode(row.node_id);
|
|
443
|
-
if (meta !== null)
|
|
446
|
+
if (meta !== null) {
|
|
444
447
|
row.name = fullName(meta);
|
|
448
|
+
row.handle = meta.name;
|
|
449
|
+
row.description = meta.description;
|
|
450
|
+
}
|
|
445
451
|
row.ctx_tokens = remote ? 0 : (readNodeTelemetry(row.node_id).tokens_in ?? 0);
|
|
446
452
|
row.asks = askMap[row.node_id] ?? 0;
|
|
447
453
|
row.enriched = true;
|
|
@@ -155,6 +155,8 @@ export function dashboardRows(rootId) {
|
|
|
155
155
|
return [{
|
|
156
156
|
node_id: id,
|
|
157
157
|
name: fullName(node),
|
|
158
|
+
handle: node.name,
|
|
159
|
+
description: node.description,
|
|
158
160
|
status: node.status,
|
|
159
161
|
kind: node.kind,
|
|
160
162
|
mode: node.mode,
|
|
@@ -189,8 +191,11 @@ export function enrichRow(row) {
|
|
|
189
191
|
if (row.enriched === true)
|
|
190
192
|
return row;
|
|
191
193
|
const meta = getNode(row.node_id);
|
|
192
|
-
if (meta !== null)
|
|
194
|
+
if (meta !== null) {
|
|
193
195
|
row.name = fullName(meta);
|
|
196
|
+
row.handle = meta.name;
|
|
197
|
+
row.description = meta.description;
|
|
198
|
+
}
|
|
194
199
|
row.ctx_tokens = readNodeTelemetry(row.node_id).tokens_in ?? 0;
|
|
195
200
|
row.asks = countTickets(row.node_id);
|
|
196
201
|
row.enriched = true;
|
|
@@ -206,8 +211,11 @@ export function enrichRows(rows) {
|
|
|
206
211
|
const asks = ticketCountsForNodes(todo.map((r) => r.node_id));
|
|
207
212
|
for (const row of todo) {
|
|
208
213
|
const meta = getNode(row.node_id);
|
|
209
|
-
if (meta !== null)
|
|
214
|
+
if (meta !== null) {
|
|
210
215
|
row.name = fullName(meta);
|
|
216
|
+
row.handle = meta.name;
|
|
217
|
+
row.description = meta.description;
|
|
218
|
+
}
|
|
211
219
|
row.ctx_tokens = readNodeTelemetry(row.node_id).tokens_in ?? 0;
|
|
212
220
|
row.asks = asks[row.node_id] ?? 0;
|
|
213
221
|
row.enriched = true;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PluginManifest } from '../../types.js';
|
|
2
|
+
import { type HookManifestIssue, type ValidatedHookManifest } from './schema.js';
|
|
3
|
+
export interface HookArtifactValidation {
|
|
4
|
+
manifestPath?: string;
|
|
5
|
+
executablePath?: string;
|
|
6
|
+
manifest?: ValidatedHookManifest;
|
|
7
|
+
issues: HookManifestIssue[];
|
|
8
|
+
}
|
|
9
|
+
/** Statically validates the local hook artifact a source plugin ships. */
|
|
10
|
+
export declare function validatePluginHookArtifact(root: string, manifest: Pick<PluginManifest, 'hooks' | 'hookExecutable'>): HookArtifactValidation;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, resolve, sep } from 'node:path';
|
|
3
|
+
import { validateHookManifest } from './schema.js';
|
|
4
|
+
function safeFilePath(root, pointer) {
|
|
5
|
+
if (isAbsolute(pointer))
|
|
6
|
+
return null;
|
|
7
|
+
try {
|
|
8
|
+
const rootReal = realpathSync(root);
|
|
9
|
+
const path = realpathSync(resolve(root, pointer));
|
|
10
|
+
return (path === rootReal || path.startsWith(rootReal + sep)) && statSync(path).isFile() ? path : null;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Statically validates the local hook artifact a source plugin ships. */
|
|
17
|
+
export function validatePluginHookArtifact(root, manifest) {
|
|
18
|
+
const hooks = manifest.hooks;
|
|
19
|
+
const executable = manifest.hookExecutable;
|
|
20
|
+
const issues = [];
|
|
21
|
+
const hasHooks = hooks !== undefined;
|
|
22
|
+
const hasExecutable = executable !== undefined;
|
|
23
|
+
if (hasHooks !== hasExecutable) {
|
|
24
|
+
issues.push({
|
|
25
|
+
code: 'hook_manifest_invalid',
|
|
26
|
+
path: hasHooks ? 'hookExecutable' : 'hooks',
|
|
27
|
+
message: 'hooks and hookExecutable must be declared together',
|
|
28
|
+
received: hasHooks ? 'hooks without hookExecutable' : 'hookExecutable without hooks',
|
|
29
|
+
expected: 'both hooks and hookExecutable',
|
|
30
|
+
next: hasHooks ? 'Add plugin.json.hookExecutable or remove hooks.' : 'Add plugin.json.hooks or remove hookExecutable.',
|
|
31
|
+
});
|
|
32
|
+
return { issues };
|
|
33
|
+
}
|
|
34
|
+
if (!hasHooks)
|
|
35
|
+
return { issues };
|
|
36
|
+
if (typeof hooks !== 'string' || hooks.length === 0) {
|
|
37
|
+
issues.push({
|
|
38
|
+
code: 'hook_manifest_invalid',
|
|
39
|
+
path: 'hooks',
|
|
40
|
+
message: 'hooks must be a non-empty plugin-root-relative path',
|
|
41
|
+
received: typeof hooks === 'string' ? '(empty)' : Array.isArray(hooks) ? 'array' : typeof hooks,
|
|
42
|
+
expected: 'a non-empty relative path to hooks.json',
|
|
43
|
+
next: 'Fix plugin.json.hooks.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (typeof executable !== 'string' || executable.length === 0) {
|
|
47
|
+
issues.push({
|
|
48
|
+
code: 'hook_manifest_invalid',
|
|
49
|
+
path: 'hookExecutable',
|
|
50
|
+
message: 'hookExecutable must be a non-empty plugin-root-relative path',
|
|
51
|
+
received: typeof executable === 'string' ? '(empty)' : Array.isArray(executable) ? 'array' : typeof executable,
|
|
52
|
+
expected: 'a non-empty relative path to an executable file',
|
|
53
|
+
next: 'Fix plugin.json.hookExecutable.',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (issues.length > 0)
|
|
57
|
+
return { issues };
|
|
58
|
+
const hooksPointer = hooks;
|
|
59
|
+
const executablePointer = executable;
|
|
60
|
+
const manifestPath = safeFilePath(root, hooksPointer);
|
|
61
|
+
if (manifestPath === null) {
|
|
62
|
+
issues.push({
|
|
63
|
+
code: 'hook_path_unsafe',
|
|
64
|
+
path: 'hooks',
|
|
65
|
+
message: 'hooks pointer escapes the plugin root or is not a regular file',
|
|
66
|
+
received: hooksPointer,
|
|
67
|
+
expected: 'a relative path to a regular file inside the plugin root',
|
|
68
|
+
next: 'Fix plugin.json.hooks.',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const executablePath = safeFilePath(root, executablePointer);
|
|
72
|
+
if (executablePath === null) {
|
|
73
|
+
issues.push({
|
|
74
|
+
code: 'hook_path_unsafe',
|
|
75
|
+
path: 'hookExecutable',
|
|
76
|
+
message: 'hookExecutable escapes the plugin root or is not a regular file',
|
|
77
|
+
received: executablePointer,
|
|
78
|
+
expected: 'a relative path to a regular file inside the plugin root',
|
|
79
|
+
next: 'Fix plugin.json.hookExecutable.',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (executablePath !== null && (statSync(executablePath).mode & 0o111) === 0) {
|
|
83
|
+
issues.push({
|
|
84
|
+
code: 'hook_not_executable',
|
|
85
|
+
path: 'hookExecutable',
|
|
86
|
+
message: 'hookExecutable lacks the POSIX exec bit',
|
|
87
|
+
received: executablePointer,
|
|
88
|
+
expected: 'a file with an executable permission bit',
|
|
89
|
+
next: 'chmod +x the hook executable, then update the plugin.',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (manifestPath === null)
|
|
93
|
+
return { ...(executablePath === null ? {} : { executablePath }), issues };
|
|
94
|
+
let text;
|
|
95
|
+
try {
|
|
96
|
+
text = readFileSync(manifestPath, 'utf8');
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
issues.push({
|
|
100
|
+
code: 'hook_manifest_unreadable',
|
|
101
|
+
message: 'hooks.json is missing or unreadable',
|
|
102
|
+
received: manifestPath,
|
|
103
|
+
expected: 'a readable JSON file',
|
|
104
|
+
next: 'Reinstall or update the plugin.',
|
|
105
|
+
});
|
|
106
|
+
return { manifestPath, ...(executablePath === null ? {} : { executablePath }), issues };
|
|
107
|
+
}
|
|
108
|
+
let raw;
|
|
109
|
+
try {
|
|
110
|
+
raw = JSON.parse(text);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
issues.push({
|
|
114
|
+
code: 'hook_manifest_invalid',
|
|
115
|
+
message: 'hooks.json is not valid JSON',
|
|
116
|
+
received: manifestPath,
|
|
117
|
+
expected: 'a JSON object',
|
|
118
|
+
next: 'Regenerate hooks.json.',
|
|
119
|
+
});
|
|
120
|
+
return { manifestPath, ...(executablePath === null ? {} : { executablePath }), issues };
|
|
121
|
+
}
|
|
122
|
+
const validation = validateHookManifest(raw);
|
|
123
|
+
return {
|
|
124
|
+
manifestPath,
|
|
125
|
+
...(executablePath === null ? {} : { executablePath }),
|
|
126
|
+
...(validation.manifest === undefined ? {} : { manifest: validation.manifest }),
|
|
127
|
+
issues: [...issues, ...validation.issues],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { BranchDef, LeafDef } from '../command.js';
|
|
2
|
+
export interface CoreHookTarget {
|
|
3
|
+
/** Exact space-joined core leaf path named by hooks.json. */
|
|
4
|
+
path: string;
|
|
5
|
+
segments: readonly string[];
|
|
6
|
+
leaf: LeafDef;
|
|
7
|
+
}
|
|
8
|
+
export interface CoreHookCatalog {
|
|
9
|
+
targets: readonly CoreHookTarget[];
|
|
10
|
+
byPath: ReadonlyMap<string, CoreHookTarget>;
|
|
11
|
+
has(path: string): boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Projects every composed core subtree to leaves whose objects flow through central rendering. */
|
|
14
|
+
export declare function createCoreHookCatalog(subtrees: readonly BranchDef[]): CoreHookCatalog;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const NON_RETURNING_OBJECT_LEAVES = new Set([
|
|
2
|
+
'pkg browse',
|
|
3
|
+
'sys settings',
|
|
4
|
+
'sys setup',
|
|
5
|
+
'canvas browse',
|
|
6
|
+
'surface inspect',
|
|
7
|
+
]);
|
|
8
|
+
function excluded(path) {
|
|
9
|
+
return path === 'sys doctor'
|
|
10
|
+
|| path === 'pkg plugin'
|
|
11
|
+
|| path.startsWith('pkg plugin ')
|
|
12
|
+
|| NON_RETURNING_OBJECT_LEAVES.has(path);
|
|
13
|
+
}
|
|
14
|
+
/** Projects every composed core subtree to leaves whose objects flow through central rendering. */
|
|
15
|
+
export function createCoreHookCatalog(subtrees) {
|
|
16
|
+
const targets = [];
|
|
17
|
+
const visit = (node, segments) => {
|
|
18
|
+
const path = segments.join(' ');
|
|
19
|
+
if (node.kind === 'leaf') {
|
|
20
|
+
if (node.help.outputKind === 'object' && node.hookEligible !== false && !excluded(path)) {
|
|
21
|
+
targets.push({ path, segments: Object.freeze([...segments]), leaf: node });
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (node.passthrough !== undefined || excluded(path))
|
|
26
|
+
return;
|
|
27
|
+
for (const child of node.children)
|
|
28
|
+
visit(child, [...segments, child.name]);
|
|
29
|
+
};
|
|
30
|
+
for (const subtree of subtrees)
|
|
31
|
+
visit(subtree, [subtree.name]);
|
|
32
|
+
const byPath = new Map(targets.map((target) => [target.path, target]));
|
|
33
|
+
return Object.freeze({
|
|
34
|
+
targets: Object.freeze(targets),
|
|
35
|
+
byPath,
|
|
36
|
+
has: (path) => byPath.has(path),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { BranchDef } from '../command.js';
|
|
2
|
+
import type { HookRegistry } from './discovery.js';
|
|
3
|
+
/** Registry compilation never executes hook code. */
|
|
4
|
+
export type HookRegistryLoader = () => Promise<HookRegistry>;
|
|
5
|
+
/** Wrap every hook-eligible core leaf so its run is the before → primary →
|
|
6
|
+
* after pipeline. Nothing else about the leaf changes: path, params, frozen
|
|
7
|
+
* help, renderer, and output declaration are carried through untouched, and
|
|
8
|
+
* ineligible leaves (JSONL, UI/void, `pkg plugin`, `sys doctor`, passthrough)
|
|
9
|
+
* keep their original run function so recovery dispatch never depends on hook
|
|
10
|
+
* discovery.
|
|
11
|
+
*
|
|
12
|
+
* Eligibility is per-path, so projecting the passed subtrees answers it for
|
|
13
|
+
* those subtrees exactly as the complete catalog would; the complete catalog
|
|
14
|
+
* is what the loaded registry validates hook targets against. */
|
|
15
|
+
export declare function composeCoreHooks(subtrees: readonly BranchDef[], loadRegistry: HookRegistryLoader): BranchDef[];
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ExitCode } from '../../types.js';
|
|
2
|
+
import { CrtrError } from '../errors.js';
|
|
3
|
+
import { operationIdContext } from '../events/operation-id.js';
|
|
4
|
+
import { createCoreHookCatalog } from './catalog.js';
|
|
5
|
+
/** Wrap every hook-eligible core leaf so its run is the before → primary →
|
|
6
|
+
* after pipeline. Nothing else about the leaf changes: path, params, frozen
|
|
7
|
+
* help, renderer, and output declaration are carried through untouched, and
|
|
8
|
+
* ineligible leaves (JSONL, UI/void, `pkg plugin`, `sys doctor`, passthrough)
|
|
9
|
+
* keep their original run function so recovery dispatch never depends on hook
|
|
10
|
+
* discovery.
|
|
11
|
+
*
|
|
12
|
+
* Eligibility is per-path, so projecting the passed subtrees answers it for
|
|
13
|
+
* those subtrees exactly as the complete catalog would; the complete catalog
|
|
14
|
+
* is what the loaded registry validates hook targets against. */
|
|
15
|
+
export function composeCoreHooks(subtrees, loadRegistry) {
|
|
16
|
+
const eligible = createCoreHookCatalog(subtrees);
|
|
17
|
+
const visit = (node, segments) => {
|
|
18
|
+
if (node.kind === 'leaf') {
|
|
19
|
+
const path = segments.join(' ');
|
|
20
|
+
if (!eligible.has(path))
|
|
21
|
+
return node;
|
|
22
|
+
return Object.freeze({
|
|
23
|
+
...node,
|
|
24
|
+
effectiveHelp: projectedHelp(node, path, loadRegistry),
|
|
25
|
+
run: hookedRun(node, segments, loadRegistry),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return { ...node, children: node.children.map((child) => visit(child, [...segments, child.name])) };
|
|
29
|
+
};
|
|
30
|
+
return subtrees.map((subtree) => visit(subtree, [subtree.name]));
|
|
31
|
+
}
|
|
32
|
+
function projectedHelp(leaf, path, loadRegistry) {
|
|
33
|
+
return async () => {
|
|
34
|
+
const { projectEffectiveLeafHelp } = await import('./help.js');
|
|
35
|
+
try {
|
|
36
|
+
const registry = await loadRegistry();
|
|
37
|
+
// Invalid configuration fails closed on dispatch, but help remains the
|
|
38
|
+
// bounded static core contract so recovery is never stranded.
|
|
39
|
+
return projectEffectiveLeafHelp(leaf.help, registry.valid ? registry.plans.get(path) : undefined);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return projectEffectiveLeafHelp(leaf.help);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function hookedRun(leaf, segments, loadRegistry) {
|
|
47
|
+
const core = leaf.run;
|
|
48
|
+
const path = segments.join(' ');
|
|
49
|
+
return async (input, context) => {
|
|
50
|
+
const registry = await loadRegistry();
|
|
51
|
+
if (!registry.valid)
|
|
52
|
+
throw registryError(path, registry.issues);
|
|
53
|
+
const plan = registry.plans.get(path);
|
|
54
|
+
if (plan === undefined)
|
|
55
|
+
return core(input, context);
|
|
56
|
+
// The one fresh operation identity runCli already entered surrounds this
|
|
57
|
+
// whole pipeline; hooks and the core primary share that ambient ID.
|
|
58
|
+
const operationId = operationIdContext.current();
|
|
59
|
+
if (operationId === undefined)
|
|
60
|
+
throw new CrtrError('internal', `\`${path}\` ran outside a crouter operation identity, so its plugin hooks cannot be dispatched.`, ExitCode.GENERAL, { command: [...segments] });
|
|
61
|
+
const { invokeExecHook } = await import('./transport/exec-invoke.js');
|
|
62
|
+
const spec = (hook) => ({
|
|
63
|
+
plugin: hook.plugin.name,
|
|
64
|
+
executable: hook.executable,
|
|
65
|
+
op: hook.op,
|
|
66
|
+
phase: hook.phase,
|
|
67
|
+
commandPath: segments,
|
|
68
|
+
...(hook.phase === 'replace' ? { output: leaf.help.output } : {}),
|
|
69
|
+
});
|
|
70
|
+
const providedParams = [...(context?.providedParams ?? [])];
|
|
71
|
+
for (const hook of plan.before)
|
|
72
|
+
await invokeExecHook(spec(hook), { operationId, input, providedParams });
|
|
73
|
+
const replacement = plan.replacements[0];
|
|
74
|
+
const result = replacement === undefined
|
|
75
|
+
? await core(input, context)
|
|
76
|
+
: await invokeExecHook(spec(replacement), { operationId, input, providedParams });
|
|
77
|
+
// `after` is reverse discovery order, preserving middleware nesting.
|
|
78
|
+
for (const hook of plan.after)
|
|
79
|
+
await invokeExecHook(spec(hook), { operationId, input, providedParams, ...(result === undefined ? {} : { result }) });
|
|
80
|
+
return result;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Fail closed on an invalid manifest, stale target, or replacement collision:
|
|
84
|
+
* silently dropping a declared hook would run the command without behavior the
|
|
85
|
+
* caller expects. Every issue is spelled out in the message, because the
|
|
86
|
+
* central error payload carries only code/message/received/field/next — an
|
|
87
|
+
* issue list parked in `details` would never reach the caller. */
|
|
88
|
+
function registryError(path, issues) {
|
|
89
|
+
const first = issues[0];
|
|
90
|
+
const detail = issues
|
|
91
|
+
.map((issue) => `${issue.plugin} (${issue.scope}) ${issue.path ?? 'hooks'}: ${issue.message} — received ${issue.received}, expected ${issue.expected}`)
|
|
92
|
+
.join('; ');
|
|
93
|
+
const plugins = [...new Set(issues.map((issue) => issue.plugin))];
|
|
94
|
+
return new CrtrError(first.code, `\`${path}\` cannot run: ${String(issues.length)} unusable hook declaration${issues.length === 1 ? '' : 's'} — ${detail}.`, ExitCode.GENERAL, {
|
|
95
|
+
received: first.received,
|
|
96
|
+
...(first.path === undefined ? {} : { field: first.path }),
|
|
97
|
+
next: `${first.next} ${plugins.map((plugin) => `\`crtr pkg plugin show ${plugin}\``).join(' and ')} or \`crtr sys doctor\` reports the same issues; disabling the plugin also clears them.`,
|
|
98
|
+
});
|
|
99
|
+
}
|