@north-light/crouter 0.3.251 → 0.3.252
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/builtin-memory/02-turn-lifecycle/00-ending-a-turn.md +1 -5
- package/dist/builtin-memory/05-kinds/review/security-findings.md +1 -1
- package/dist/clients/attach/__tests__/completion-frecency.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/completion-frecency.test.js +35 -0
- package/dist/clients/attach/__tests__/ref-autocomplete.test.js +3 -1
- package/dist/clients/attach/__tests__/titled-editor-preview.test.js +2 -1
- package/dist/clients/attach/input/completion-frecency.d.ts +45 -0
- package/dist/clients/attach/input/completion-frecency.js +141 -0
- package/dist/clients/attach/input/controller.d.ts +17 -0
- package/dist/clients/attach/input/controller.js +40 -0
- package/dist/clients/attach/input/ref-autocomplete.d.ts +3 -1
- package/dist/clients/attach/input/ref-autocomplete.js +49 -8
- package/dist/clients/attach/input/titled-editor.d.ts +3 -0
- package/dist/clients/attach/input/titled-editor.js +5 -0
- package/dist/clients/attach/session/editor-inventory.d.ts +3 -0
- package/dist/clients/attach/session/editor-inventory.js +1 -1
- package/dist/clients/attach/session/input-wiring.d.ts +3 -0
- package/dist/clients/attach/session/input-wiring.js +2 -0
- package/dist/clients/attach/viewer.js +578 -578
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +4 -2
- package/dist/core/__tests__/canvas-inbox-watcher-naming.test.js +4 -1
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +4 -1
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +3 -1
- package/dist/core/__tests__/fixtures/fake-engine.js +7 -1
- package/dist/core/__tests__/integration/deferred-no-wake.test.js +4 -1
- package/dist/core/__tests__/migration.test.js +57 -11
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +17 -2
- package/dist/core/__tests__/seam/dormancy-release.test.js +1 -0
- package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +9 -2
- package/dist/core/canvas/migrations.js +63 -24
- package/dist/core/runtime/broker/engine-drive.js +7 -1
- package/dist/core/runtime/broker/fault-retry.js +31 -13
- package/dist/core/runtime/broker/held-deferred-inbox.d.ts +4 -0
- package/dist/core/runtime/broker/held-deferred-inbox.js +7 -2
- package/dist/core/runtime/broker.js +19 -0
- package/dist/core/runtime/close.js +2 -2
- package/dist/core/runtime/fault.js +8 -5
- package/dist/core/runtime/recycle.js +63 -41
- package/dist/core/runtime/reset.d.ts +1 -1
- package/dist/core/runtime/reset.js +25 -2
- package/dist/daemon/__tests__/helpers/source-daemon.js +1 -0
- package/dist/daemon/api/handlers/messages.js +1 -1
- package/dist/daemon/api/handlers/reports.js +1 -1
- package/dist/daemon/cron/sinks.js +1 -1
- package/dist/daemon/messaging/node-message.js +1 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +5 -2
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -25,8 +25,4 @@ When your goal is sound but your next step is blocked on something that has not
|
|
|
25
25
|
Before you stop, check your last paragraph. If it is a plan, a list of next steps, or a promise about work you have not done ("I'll now…"), that is not an ending — do that work now with tool calls. End your turn only when the work is complete, you are waiting, or you are blocked on input only a person can provide.
|
|
26
26
|
|
|
27
27
|
## Yield for a fresh window
|
|
28
|
-
When your context is filling but the mandate isn't done, yield
|
|
29
|
-
|
|
30
|
-
crtr node yield # `crtr node yield -h` — refresh into a clean window, carrying a note forward
|
|
31
|
-
|
|
32
|
-
Never yield carrying an unasked question: put anything you're still wondering for the user through `crtr human send` BEFORE you yield — an in-flight ask survives the refresh, and its answer wakes your fresh window like any child's report.
|
|
28
|
+
When your context is filling but the mandate isn't done, run `crtr node yield -h`: you revive fresh as the same node with the same mandate, carrying a note to your future self.
|
|
@@ -10,4 +10,4 @@ surfaces:
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
12
|
## When a security concern is unproven
|
|
13
|
-
A security finding needs evidence that the scenario applies: trace the reachable exploit path against the actual trust boundary and deployment context, resolving that context from source and deployment evidence first. When a material security posture is unknown rather than defective, ask through `crtr human send` instead of rating a hypothetical — the observed facts in plain language, the actor/access scenario and asset that would make the tightening worthwhile, and whether that scenario applies and should be fixed. That question stays out of the severity-rated findings. When you have a parent, report the confirmed verdict and the non-blocking question upward before awaiting the answer, with
|
|
13
|
+
A security finding needs evidence that the scenario applies: trace the reachable exploit path against the actual trust boundary and deployment context, resolving that context from source and deployment evidence first. When a material security posture is unknown rather than defective, ask through `crtr human send` instead of rating a hypothetical — the observed facts in plain language, the actor/access scenario and asset that would make the tightening worthwhile, and whether that scenario applies and should be fixed. That question stays out of the severity-rated findings. When you have a parent, report the confirmed verdict and the non-blocking question upward before awaiting the answer, with `crtr push update --tier urgent` when work is waiting on this review, so an unresolved posture does not stall what is already proved.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { CombinedAutocompleteProvider } from '@earendil-works/pi-tui';
|
|
4
|
+
import { RefAwareAutocompleteProvider } from '../input/ref-autocomplete.js';
|
|
5
|
+
const noop = { record() { }, bonus: () => 0 };
|
|
6
|
+
function ref(name) {
|
|
7
|
+
return { name, kind: 'knowledge', scope: 'user', shortForm: `${name} docs`, gatewayVisible: true };
|
|
8
|
+
}
|
|
9
|
+
const inertDelegate = {
|
|
10
|
+
async getSuggestions() { return null; },
|
|
11
|
+
applyCompletion(lines, cursorLine, cursorCol) { return { lines, cursorLine, cursorCol }; },
|
|
12
|
+
shouldTriggerFileCompletion() { return false; },
|
|
13
|
+
};
|
|
14
|
+
test('acceptance: typing /devnor surfaces northlight:dev via the ref provider', async () => {
|
|
15
|
+
const refs = [ref('northlight/dev'), ref('northlight/prod'), ref('taste/writing')];
|
|
16
|
+
const provider = new RefAwareAutocompleteProvider(refs, inertDelegate, noop);
|
|
17
|
+
const line = 'see /devnor';
|
|
18
|
+
const caret = line.length;
|
|
19
|
+
const result = await provider.getSuggestions([line], 0, caret, { signal: new AbortController().signal });
|
|
20
|
+
assert.ok(result, 'a ref suggestion is returned');
|
|
21
|
+
const labels = result.items.map((i) => i.label);
|
|
22
|
+
assert.ok(labels.includes('/northlight:dev'), `expected /northlight:dev, got ${labels.join(', ')}`);
|
|
23
|
+
});
|
|
24
|
+
test('acceptance: typing /devn at the start of the line surfaces the northlight:dev command', async () => {
|
|
25
|
+
// A memory doc flagged `slash: true` reaches the editor as an ordinary chat
|
|
26
|
+
// command named with `:` segments. pi-tui filters those by a subsequence over
|
|
27
|
+
// the raw name, which `devn` cannot satisfy; leaf-first matching can.
|
|
28
|
+
const commands = [{ name: 'northlight:dev' }, { name: 'reload' }, { name: 'git:pr-loop' }];
|
|
29
|
+
const provider = new RefAwareAutocompleteProvider([], new CombinedAutocompleteProvider(commands, process.cwd(), null), noop);
|
|
30
|
+
const line = '/devn';
|
|
31
|
+
const result = await provider.getSuggestions([line], 0, line.length, { signal: new AbortController().signal });
|
|
32
|
+
assert.ok(result, 'a command suggestion is returned');
|
|
33
|
+
assert.equal(result.prefix, line, 'prefix stays the text before the cursor so applyCompletion slices correctly');
|
|
34
|
+
assert.equal(result.items[0]?.value, 'northlight:dev', `expected northlight:dev first, got ${result.items.map((i) => i.value).join(', ')}`);
|
|
35
|
+
});
|
|
@@ -4,6 +4,8 @@ import { findRefCompletionContext, RefAwareAutocompleteProvider } from '../input
|
|
|
4
4
|
function ref(name, shortForm = `${name} docs`) {
|
|
5
5
|
return { name, kind: 'knowledge', scope: 'user', shortForm, gatewayVisible: true };
|
|
6
6
|
}
|
|
7
|
+
/** No-op frecency store: never records, never biases. */
|
|
8
|
+
const noopFrecency = { record() { }, bonus: () => 0 };
|
|
7
9
|
/** The wrapped provider; ref handling must never reach it in a ref context. */
|
|
8
10
|
const inertDelegate = {
|
|
9
11
|
async getSuggestions() { return null; },
|
|
@@ -51,7 +53,7 @@ test('findRefCompletionContext classifies leading, non-leading, and non-token ca
|
|
|
51
53
|
assert.equal(findRefCompletionContext(['/dev x'], 0, 5), null);
|
|
52
54
|
});
|
|
53
55
|
test('applying a ref completion replaces only the token and adds no separator', () => {
|
|
54
|
-
const provider = new RefAwareAutocompleteProvider([ref('dev')], inertDelegate);
|
|
56
|
+
const provider = new RefAwareAutocompleteProvider([ref('dev')], inertDelegate, noopFrecency);
|
|
55
57
|
const line = 'before /de after';
|
|
56
58
|
const cursorCol = line.indexOf('/de') + '/de'.length;
|
|
57
59
|
const item = { value: 'dev', label: '/dev' };
|
|
@@ -56,7 +56,8 @@ test('Enter-confirming a non-leading ref completion accepts the token and return
|
|
|
56
56
|
return { lines, cursorLine, cursorCol };
|
|
57
57
|
},
|
|
58
58
|
};
|
|
59
|
-
|
|
59
|
+
const noopFrecency = { record() { }, bonus: () => 0 };
|
|
60
|
+
editor.setAutocompleteProvider(new RefAwareAutocompleteProvider(refs, delegate, noopFrecency));
|
|
60
61
|
editor.setText('hey check /d');
|
|
61
62
|
editor.handleInput('e'); // completes the typed prefix to '/de', triggering the non-leading bridge
|
|
62
63
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface FrecencyStore {
|
|
2
|
+
/** Bump an item's count and stamp it now. `id` is namespaced by the caller
|
|
3
|
+
* (`command:<name>` / `ref:<canonicalName>`) so a command and a ref of the
|
|
4
|
+
* same name never collide. */
|
|
5
|
+
record(id: string): void;
|
|
6
|
+
/** The non-negative bonus to SUBTRACT from an item's fuzzy score. */
|
|
7
|
+
bonus(id: string, now?: number): number;
|
|
8
|
+
}
|
|
9
|
+
/** Create the viewer's single frecency store. Loads once; writes are debounced
|
|
10
|
+
* and unref'd so they never hold the process open. */
|
|
11
|
+
export declare function createFrecencyStore(filePath?: string): FrecencyStore;
|
|
12
|
+
/**
|
|
13
|
+
* Filter `items` to fuzzy matches of `query` and sort by combined
|
|
14
|
+
* `fuzzyScore − frecencyBonus` (ascending; lower = better, matching pi-tui's
|
|
15
|
+
* convention). An empty query matches everything at fuzzy score 0, so the
|
|
16
|
+
* result is ordered purely by frecency — the bare-`/` "most-used first" list.
|
|
17
|
+
*
|
|
18
|
+
* `fuzzyMatch` is pi-tui's own exported matcher, so for a single-token query
|
|
19
|
+
* this produces exactly pi-tui's per-item score; the frecency term is the only
|
|
20
|
+
* perturbation.
|
|
21
|
+
*/
|
|
22
|
+
export declare function rankByFuzzyFrecency<T>(items: readonly T[], query: string, opts: {
|
|
23
|
+
searchableOf: (item: T) => string;
|
|
24
|
+
idOf: (item: T) => string;
|
|
25
|
+
store: FrecencyStore;
|
|
26
|
+
now?: number;
|
|
27
|
+
}): T[];
|
|
28
|
+
/**
|
|
29
|
+
* The string a namespaced name is fuzzy-matched against. Users reach for the
|
|
30
|
+
* meaningful LEAF ("dev"), so the leaf is placed first (earning pi-tui's
|
|
31
|
+
* word-boundary bonus) and the full name follows. This is what lets a
|
|
32
|
+
* cross-segment query like `devnor` match `northlight/dev` — `dev` from the
|
|
33
|
+
* leaf then `nor` from `northlight` — which a subsequence over the raw name
|
|
34
|
+
* cannot do.
|
|
35
|
+
*
|
|
36
|
+
* One helper serves both inventories: memory refs are namespaced with `/`
|
|
37
|
+
* (canonical `northlight/dev`) and slash commands with `:` (the same doc
|
|
38
|
+
* invoked as `/northlight:dev`), so the leaf is whatever follows the last
|
|
39
|
+
* separator of either kind.
|
|
40
|
+
*/
|
|
41
|
+
export declare function leafFirstSearchable(name: string): string;
|
|
42
|
+
/** Normalize a typed ref query for matching: drop the surface `:` separators so
|
|
43
|
+
* a typed `northlight:dev` matches the `/`-separated canonical searchable text,
|
|
44
|
+
* and so cross-segment queries are not blocked by a separator mismatch. */
|
|
45
|
+
export declare function normalizeRefQuery(surfacePrefixWithoutSlash: string): string;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// input/completion-frecency.ts — per-user frequency/recency ("frecency") bias
|
|
2
|
+
// for `/` completion, shared by the leading slash-command path and the inline
|
|
3
|
+
// memory-ref path. The store persists a small usage map to the user scope root
|
|
4
|
+
// (`~/.crouter/completion-frecency.json`) — user-wide state with no cwd
|
|
5
|
+
// dimension, which per the storage tiers belongs there and NOT in canvas.db.
|
|
6
|
+
// The viewer is a client and owns this plain JSON file directly; it never
|
|
7
|
+
// touches canvas state.
|
|
8
|
+
//
|
|
9
|
+
// The design intent is a SLIGHT bias: `bonus` is capped well below the spread
|
|
10
|
+
// of fuzzy scores, so it only breaks near-ties and fully orders the bare-`/`
|
|
11
|
+
// list (where every fuzzy score is 0) by most-recent/most-used. A clearly
|
|
12
|
+
// better fuzzy match always wins.
|
|
13
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { fuzzyMatch } from '@earendil-works/pi-tui';
|
|
16
|
+
import { userScopeRoot } from '../../../core/scope.js';
|
|
17
|
+
const FILE_NAME = 'completion-frecency.json';
|
|
18
|
+
/** Recency half-life: a use's recency weight halves every two weeks. */
|
|
19
|
+
const HALF_LIFE_MS = 14 * 24 * 60 * 60 * 1000;
|
|
20
|
+
/** Maximum bonus subtracted from a fuzzy score. Chosen well below typical
|
|
21
|
+
* good-match scores (−10…−30) so the bias never overrides a clearly better
|
|
22
|
+
* fuzzy match — it only breaks near-ties and orders the all-zero empty list. */
|
|
23
|
+
const BONUS_CAP = 8;
|
|
24
|
+
/** Coalesce bursts of records into one disk write. */
|
|
25
|
+
const WRITE_DEBOUNCE_MS = 1000;
|
|
26
|
+
/** Pure frecency curve. Every entry reaching it is well-formed — `loadMap` is
|
|
27
|
+
* the only untrusted source and validates there — so an unknown id is the only
|
|
28
|
+
* case to handle here. */
|
|
29
|
+
function frecencyBonus(entry, now) {
|
|
30
|
+
if (!entry)
|
|
31
|
+
return 0;
|
|
32
|
+
const ageMs = Math.max(0, now - entry.t);
|
|
33
|
+
const recency = Math.pow(0.5, ageMs / HALF_LIFE_MS); // (0, 1], 1 at age 0
|
|
34
|
+
const saturate = 1 - Math.exp(-entry.n / 3); // climbs fast, then flattens < 1
|
|
35
|
+
return BONUS_CAP * recency * saturate;
|
|
36
|
+
}
|
|
37
|
+
/** Load the usage map, keeping only well-formed entries. Anything else —
|
|
38
|
+
* a non-object container, an array, a non-finite or non-positive `n`, a
|
|
39
|
+
* non-finite `t` — is dropped, so a parseable-but-malformed file can never
|
|
40
|
+
* poison a sort or silently swallow later writes. */
|
|
41
|
+
function loadMap(filePath) {
|
|
42
|
+
const map = {};
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
45
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
46
|
+
return map;
|
|
47
|
+
for (const [id, value] of Object.entries(parsed)) {
|
|
48
|
+
if (!value || typeof value !== 'object')
|
|
49
|
+
continue;
|
|
50
|
+
const { n, t } = value;
|
|
51
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0)
|
|
52
|
+
continue;
|
|
53
|
+
if (typeof t !== 'number' || !Number.isFinite(t))
|
|
54
|
+
continue;
|
|
55
|
+
map[id] = { n, t };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Missing or corrupt → start empty. No fallback theater; a fresh map is the
|
|
60
|
+
// honest state and self-heals on the next successful write.
|
|
61
|
+
}
|
|
62
|
+
return map;
|
|
63
|
+
}
|
|
64
|
+
function saveMap(filePath, map) {
|
|
65
|
+
try {
|
|
66
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
67
|
+
writeFileSync(filePath, JSON.stringify(map), 'utf8');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Best-effort persistence — a failed write loses only this session's counts,
|
|
71
|
+
// never breaks completion. The in-memory map stays authoritative for now.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Create the viewer's single frecency store. Loads once; writes are debounced
|
|
75
|
+
* and unref'd so they never hold the process open. */
|
|
76
|
+
export function createFrecencyStore(filePath = join(userScopeRoot(), FILE_NAME)) {
|
|
77
|
+
const map = loadMap(filePath);
|
|
78
|
+
let timer;
|
|
79
|
+
const scheduleWrite = () => {
|
|
80
|
+
if (timer)
|
|
81
|
+
return;
|
|
82
|
+
timer = setTimeout(() => {
|
|
83
|
+
timer = undefined;
|
|
84
|
+
saveMap(filePath, map);
|
|
85
|
+
}, WRITE_DEBOUNCE_MS);
|
|
86
|
+
timer.unref?.();
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
record: (id) => {
|
|
90
|
+
const prev = map[id];
|
|
91
|
+
map[id] = { n: (prev?.n ?? 0) + 1, t: Date.now() };
|
|
92
|
+
scheduleWrite();
|
|
93
|
+
},
|
|
94
|
+
bonus: (id, now = Date.now()) => frecencyBonus(map[id], now),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Filter `items` to fuzzy matches of `query` and sort by combined
|
|
99
|
+
* `fuzzyScore − frecencyBonus` (ascending; lower = better, matching pi-tui's
|
|
100
|
+
* convention). An empty query matches everything at fuzzy score 0, so the
|
|
101
|
+
* result is ordered purely by frecency — the bare-`/` "most-used first" list.
|
|
102
|
+
*
|
|
103
|
+
* `fuzzyMatch` is pi-tui's own exported matcher, so for a single-token query
|
|
104
|
+
* this produces exactly pi-tui's per-item score; the frecency term is the only
|
|
105
|
+
* perturbation.
|
|
106
|
+
*/
|
|
107
|
+
export function rankByFuzzyFrecency(items, query, opts) {
|
|
108
|
+
const now = opts.now ?? Date.now();
|
|
109
|
+
const scored = [];
|
|
110
|
+
for (const item of items) {
|
|
111
|
+
const match = fuzzyMatch(query, opts.searchableOf(item));
|
|
112
|
+
if (!match.matches)
|
|
113
|
+
continue;
|
|
114
|
+
scored.push({ item, score: match.score - opts.store.bonus(opts.idOf(item), now) });
|
|
115
|
+
}
|
|
116
|
+
scored.sort((a, b) => a.score - b.score);
|
|
117
|
+
return scored.map((s) => s.item);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The string a namespaced name is fuzzy-matched against. Users reach for the
|
|
121
|
+
* meaningful LEAF ("dev"), so the leaf is placed first (earning pi-tui's
|
|
122
|
+
* word-boundary bonus) and the full name follows. This is what lets a
|
|
123
|
+
* cross-segment query like `devnor` match `northlight/dev` — `dev` from the
|
|
124
|
+
* leaf then `nor` from `northlight` — which a subsequence over the raw name
|
|
125
|
+
* cannot do.
|
|
126
|
+
*
|
|
127
|
+
* One helper serves both inventories: memory refs are namespaced with `/`
|
|
128
|
+
* (canonical `northlight/dev`) and slash commands with `:` (the same doc
|
|
129
|
+
* invoked as `/northlight:dev`), so the leaf is whatever follows the last
|
|
130
|
+
* separator of either kind.
|
|
131
|
+
*/
|
|
132
|
+
export function leafFirstSearchable(name) {
|
|
133
|
+
const cut = Math.max(name.lastIndexOf('/'), name.lastIndexOf(':'));
|
|
134
|
+
return cut < 0 ? name : `${name.slice(cut + 1)} ${name}`;
|
|
135
|
+
}
|
|
136
|
+
/** Normalize a typed ref query for matching: drop the surface `:` separators so
|
|
137
|
+
* a typed `northlight:dev` matches the `/`-separated canonical searchable text,
|
|
138
|
+
* and so cross-segment queries are not blocked by a separator mismatch. */
|
|
139
|
+
export function normalizeRefQuery(surfacePrefixWithoutSlash) {
|
|
140
|
+
return surfacePrefixWithoutSlash.replace(/:/g, '');
|
|
141
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { CustomEditor } from '@earendil-works/pi-coding-agent';
|
|
|
2
2
|
import type { Component, KeybindingsManager, TUI } from '@earendil-works/pi-tui';
|
|
3
3
|
import { type BrokerDataFrame, type BrokerSnapshot, type ClientToBroker, type RpcExtensionUIRequest, type RpcExtensionUIResponse } from '../../../core/runtime/broker-protocol.js';
|
|
4
4
|
import type { ReadOpRequest } from '../../../core/broker-client/index.js';
|
|
5
|
+
import type { FrecencyStore } from './completion-frecency.js';
|
|
5
6
|
import type { CopyTarget } from '../render/transcript-copy.js';
|
|
6
7
|
import { type ViewerCapabilities } from './capabilities.js';
|
|
7
8
|
import { InputOverlayOwner } from './overlay-owner.js';
|
|
@@ -31,6 +32,12 @@ export interface InputControllerHooks {
|
|
|
31
32
|
isDormant?: () => boolean;
|
|
32
33
|
onDormantSubmit?: (text: string) => void;
|
|
33
34
|
onPromptSent?: () => void;
|
|
35
|
+
/** Per-user completion frecency store — usage recorded here biases the sort
|
|
36
|
+
* order the next time `/` completion opens. */
|
|
37
|
+
frecency?: FrecencyStore;
|
|
38
|
+
/** Canonical names of currently-resolvable memory refs, so a submitted
|
|
39
|
+
* message records ONLY real refs, not arbitrary slash tokens. */
|
|
40
|
+
knownRefNames?: () => ReadonlySet<string>;
|
|
34
41
|
}
|
|
35
42
|
export declare class InputController {
|
|
36
43
|
private readonly tui;
|
|
@@ -57,6 +64,16 @@ export declare class InputController {
|
|
|
57
64
|
private readOnlyLabel;
|
|
58
65
|
private wire;
|
|
59
66
|
private slashContext;
|
|
67
|
+
/** Record a leading slash command as used. Called only once the command has
|
|
68
|
+
* actually run — after a successful local dispatch or a successful send — so
|
|
69
|
+
* refused, unsupported, and rejected submissions never bias completion. */
|
|
70
|
+
private recordCommandUse;
|
|
71
|
+
/** Record usage so `/` completion can bias toward frequently/recently used
|
|
72
|
+
* items next time. A leading slash token is a command; every resolvable
|
|
73
|
+
* memory ref found in submitted prose is a ref use. Called only on the
|
|
74
|
+
* success paths of `handleSubmit` — never for bash, a refused drive, or a
|
|
75
|
+
* submission that classified out. */
|
|
76
|
+
private recordCompletionUse;
|
|
60
77
|
private handleSubmit;
|
|
61
78
|
private handleFollowUp;
|
|
62
79
|
private handleDequeue;
|
|
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os';
|
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { BROKER_READ_CAPS, encodeFrame, } from '../../../core/runtime/broker-protocol.js';
|
|
9
9
|
import { classifySubmit } from '../../conversation/submit.js';
|
|
10
|
+
import { findRefTokens } from '../../../core/memory/inline-ref-grammar.js';
|
|
10
11
|
import { dispatchSlashCommand, isSlashCommand } from '../slash/dispatch.js';
|
|
11
12
|
import { viewerOperationForFrame } from './capabilities.js';
|
|
12
13
|
import { readClipboardImage, writeClipboardImageToFile } from './clipboard-image.js';
|
|
@@ -141,11 +142,48 @@ export class InputController {
|
|
|
141
142
|
openLogoutPicker: this.hooks.openLogoutPicker,
|
|
142
143
|
};
|
|
143
144
|
}
|
|
145
|
+
/** Record a leading slash command as used. Called only once the command has
|
|
146
|
+
* actually run — after a successful local dispatch or a successful send — so
|
|
147
|
+
* refused, unsupported, and rejected submissions never bias completion. */
|
|
148
|
+
recordCommandUse(trimmed) {
|
|
149
|
+
const store = this.hooks.frecency;
|
|
150
|
+
if (!store)
|
|
151
|
+
return;
|
|
152
|
+
const name = trimmed.slice(1).split(/\s/, 1)[0];
|
|
153
|
+
if (name)
|
|
154
|
+
store.record(`command:${name}`);
|
|
155
|
+
}
|
|
156
|
+
/** Record usage so `/` completion can bias toward frequently/recently used
|
|
157
|
+
* items next time. A leading slash token is a command; every resolvable
|
|
158
|
+
* memory ref found in submitted prose is a ref use. Called only on the
|
|
159
|
+
* success paths of `handleSubmit` — never for bash, a refused drive, or a
|
|
160
|
+
* submission that classified out. */
|
|
161
|
+
recordCompletionUse(text) {
|
|
162
|
+
const store = this.hooks.frecency;
|
|
163
|
+
if (!store)
|
|
164
|
+
return;
|
|
165
|
+
const trimmed = text.trim();
|
|
166
|
+
if (isSlashCommand(trimmed)) {
|
|
167
|
+
this.recordCommandUse(trimmed);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const known = this.hooks.knownRefNames?.();
|
|
171
|
+
if (!known || known.size === 0)
|
|
172
|
+
return;
|
|
173
|
+
const recorded = new Set();
|
|
174
|
+
for (const token of findRefTokens(text)) {
|
|
175
|
+
if (known.has(token.candidate) && !recorded.has(token.candidate)) {
|
|
176
|
+
recorded.add(token.candidate);
|
|
177
|
+
store.record(`ref:${token.candidate}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
144
181
|
handleSubmit(text) {
|
|
145
182
|
const trimmed = text.trim();
|
|
146
183
|
if (!trimmed)
|
|
147
184
|
return;
|
|
148
185
|
if (isSlashCommand(trimmed) && dispatchSlashCommand(trimmed, this.slashContext())) {
|
|
186
|
+
this.recordCommandUse(trimmed);
|
|
149
187
|
this.editor.setText('');
|
|
150
188
|
return;
|
|
151
189
|
}
|
|
@@ -162,6 +200,7 @@ export class InputController {
|
|
|
162
200
|
if (submit === undefined)
|
|
163
201
|
return;
|
|
164
202
|
submit(this.expandImagePlaceholders(classification.text));
|
|
203
|
+
this.recordCompletionUse(text);
|
|
165
204
|
this.editor.addToHistory(classification.trimmed);
|
|
166
205
|
this.editor.setText('');
|
|
167
206
|
this.clearPastedImages();
|
|
@@ -183,6 +222,7 @@ export class InputController {
|
|
|
183
222
|
const expanded = this.expandImagePlaceholders(classification.kind === 'slash' ? text : classification.text);
|
|
184
223
|
if (!this.emitDrive(delivery === 'steer' ? { type: 'steer', text: expanded } : { type: 'prompt', text: expanded }))
|
|
185
224
|
return;
|
|
225
|
+
this.recordCompletionUse(text);
|
|
186
226
|
this.editor.addToHistory(classification.trimmed);
|
|
187
227
|
this.editor.setText('');
|
|
188
228
|
this.clearPastedImages();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions } from '@earendil-works/pi-tui';
|
|
2
2
|
import type { RefMeta } from '../../../core/runtime/broker-protocol.js';
|
|
3
|
+
import { type FrecencyStore } from './completion-frecency.js';
|
|
3
4
|
/** The caret's classification for completion purposes. `null` means the caret
|
|
4
5
|
* is not touching any slash token at all (every other autocomplete mode —
|
|
5
6
|
* file, `@`, etc. — is unaffected and this provider fully delegates). */
|
|
@@ -52,7 +53,8 @@ export declare class RefAwareAutocompleteProvider implements AutocompleteProvide
|
|
|
52
53
|
private readonly refs;
|
|
53
54
|
private readonly refNames;
|
|
54
55
|
private readonly delegate;
|
|
55
|
-
|
|
56
|
+
private readonly frecency;
|
|
57
|
+
constructor(refs: ReadonlyArray<RefMeta>, delegate: AutocompleteProvider, frecency: FrecencyStore);
|
|
56
58
|
/** Pass through unchanged — pi-tui's editor explicitly discards `/` as a
|
|
57
59
|
* trigger character regardless, and `CombinedAutocompleteProvider` doesn't
|
|
58
60
|
* declare any of its own, so this is normally `undefined`. */
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// command context") or anywhere else ("non-leading ref context"). It does not
|
|
16
16
|
// reimplement `[A-Za-z0-9_:-]`, trimming, or boundary detection.
|
|
17
17
|
import { findRefTokenAtCaret, formatRefToken } from '../../../core/memory/inline-ref-grammar.js';
|
|
18
|
+
import { leafFirstSearchable, normalizeRefQuery, rankByFuzzyFrecency, } from './completion-frecency.js';
|
|
18
19
|
/**
|
|
19
20
|
* Classify the slash token (if any) touching the caret at
|
|
20
21
|
* `(cursorLine, cursorCol)` in the editor's logical `lines`. Joins the lines
|
|
@@ -67,10 +68,12 @@ export class RefAwareAutocompleteProvider {
|
|
|
67
68
|
refs;
|
|
68
69
|
refNames;
|
|
69
70
|
delegate;
|
|
70
|
-
|
|
71
|
+
frecency;
|
|
72
|
+
constructor(refs, delegate, frecency) {
|
|
71
73
|
this.refs = refs;
|
|
72
74
|
this.refNames = new Set(refs.map((ref) => ref.name));
|
|
73
75
|
this.delegate = delegate;
|
|
76
|
+
this.frecency = frecency;
|
|
74
77
|
}
|
|
75
78
|
/** Pass through unchanged — pi-tui's editor explicitly discards `/` as a
|
|
76
79
|
* trigger character regardless, and `CombinedAutocompleteProvider` doesn't
|
|
@@ -80,11 +83,49 @@ export class RefAwareAutocompleteProvider {
|
|
|
80
83
|
}
|
|
81
84
|
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
82
85
|
const context = findRefCompletionContext(lines, cursorLine, cursorCol);
|
|
83
|
-
if (!context
|
|
86
|
+
if (!context) {
|
|
84
87
|
return this.delegate.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
85
88
|
}
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
if (context.kind === 'leading') {
|
|
90
|
+
// Leading command-name completion. On a forced (Tab) trigger pi-tui runs
|
|
91
|
+
// file completion instead of command completion, so that must delegate
|
|
92
|
+
// verbatim.
|
|
93
|
+
if (options.force) {
|
|
94
|
+
return this.delegate.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
95
|
+
}
|
|
96
|
+
// The delegate still CONSTRUCTS the items (reusing its description and
|
|
97
|
+
// argumentHint formatting), but it filters them by a subsequence over the
|
|
98
|
+
// raw command name, which cannot match a namespaced command by its leaf:
|
|
99
|
+
// `devn` never matches `northlight:dev` because the `n` comes first in the
|
|
100
|
+
// raw string. So ask it for the whole list — an empty query matches
|
|
101
|
+
// everything — and apply the same leaf-first fuzzy match plus slight
|
|
102
|
+
// frecency bias the ref path uses. A one-token query over a name with no
|
|
103
|
+
// separator scores exactly as pi-tui scored it, so plain commands keep
|
|
104
|
+
// their previous order and frecency is the only perturbation there.
|
|
105
|
+
// `applyCompletion` slices back by `prefix.length`, so the query and the
|
|
106
|
+
// returned prefix are both the text before the cursor — exactly what pi-tui
|
|
107
|
+
// matches on and returns for this branch. A leading token starts at global
|
|
108
|
+
// offset 0, so that text always begins with the `/`.
|
|
109
|
+
const textBeforeCursor = (lines[cursorLine] ?? '').slice(0, cursorCol);
|
|
110
|
+
const all = await this.delegate.getSuggestions(['/'], 0, 1, options);
|
|
111
|
+
if (!all)
|
|
112
|
+
return null;
|
|
113
|
+
const items = rankByFuzzyFrecency(all.items, textBeforeCursor.slice(1), {
|
|
114
|
+
searchableOf: (item) => leafFirstSearchable(item.value),
|
|
115
|
+
idOf: (item) => `command:${item.value}`,
|
|
116
|
+
store: this.frecency,
|
|
117
|
+
});
|
|
118
|
+
if (items.length === 0)
|
|
119
|
+
return null;
|
|
120
|
+
return { items, prefix: textBeforeCursor };
|
|
121
|
+
}
|
|
122
|
+
// Non-leading ref completion: fuzzy match (leaf-first searchable) with a
|
|
123
|
+
// slight frecency bias, replacing the former case-sensitive prefix filter.
|
|
124
|
+
const matches = rankByFuzzyFrecency(this.refs, normalizeRefQuery(context.prefix.slice(1)), {
|
|
125
|
+
searchableOf: (ref) => leafFirstSearchable(ref.name),
|
|
126
|
+
idOf: (ref) => `ref:${ref.name}`,
|
|
127
|
+
store: this.frecency,
|
|
128
|
+
});
|
|
88
129
|
if (matches.length === 0)
|
|
89
130
|
return null;
|
|
90
131
|
const items = matches.map((ref) => ({
|
|
@@ -92,9 +133,9 @@ export class RefAwareAutocompleteProvider {
|
|
|
92
133
|
label: formatRefToken(ref.name),
|
|
93
134
|
description: ref.shortForm || undefined,
|
|
94
135
|
}));
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
136
|
+
// Matching above used a normalized query, but the RETURNED prefix is the
|
|
137
|
+
// typed surface form with its leading '/' stripped. pi-tui's `handleInput`
|
|
138
|
+
// (tui.select.confirm
|
|
98
139
|
// branch) falls through to `submitValue()` whenever `autocompletePrefix`
|
|
99
140
|
// (set verbatim from this return value) starts with '/' — that's its own
|
|
100
141
|
// leading-command-completion submit shortcut, and a non-leading ref
|
|
@@ -105,7 +146,7 @@ export class RefAwareAutocompleteProvider {
|
|
|
105
146
|
// its `prefix` argument (it recomputes the token position fresh each call
|
|
106
147
|
// via `findRefCompletionContext`), so this change has no effect on
|
|
107
148
|
// replacement correctness.
|
|
108
|
-
return { items, prefix: prefix.slice(1) };
|
|
149
|
+
return { items, prefix: context.prefix.slice(1) };
|
|
109
150
|
}
|
|
110
151
|
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
111
152
|
const context = findRefCompletionContext(lines, cursorLine, cursorCol);
|
|
@@ -198,6 +198,9 @@ export declare class TitledEditor extends CustomEditor {
|
|
|
198
198
|
* `list_memory_refs` reply, alongside `resolvedRefNames`. */
|
|
199
199
|
private engineCommandNames;
|
|
200
200
|
setResolvedRefNames(names: Iterable<string>): void;
|
|
201
|
+
/** The canonical names of memory refs currently resolvable in this session —
|
|
202
|
+
* used to record ref usage on submit without counting non-ref slash tokens. */
|
|
203
|
+
getResolvedRefNames(): ReadonlySet<string>;
|
|
201
204
|
setLocalCommandNames(names: Iterable<string>): void;
|
|
202
205
|
setEngineCommandNames(names: Iterable<string>): void;
|
|
203
206
|
/** Render-only decoration pass: underlines every resolved-ref span visible
|
|
@@ -740,6 +740,11 @@ export class TitledEditor extends CustomEditor {
|
|
|
740
740
|
setResolvedRefNames(names) {
|
|
741
741
|
this.resolvedRefNames = new Set(names);
|
|
742
742
|
}
|
|
743
|
+
/** The canonical names of memory refs currently resolvable in this session —
|
|
744
|
+
* used to record ref usage on submit without counting non-ref slash tokens. */
|
|
745
|
+
getResolvedRefNames() {
|
|
746
|
+
return this.resolvedRefNames;
|
|
747
|
+
}
|
|
743
748
|
setLocalCommandNames(names) {
|
|
744
749
|
this.localCommandNames = new Set(names);
|
|
745
750
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CombinedAutocompleteProvider } from '@earendil-works/pi-tui';
|
|
2
2
|
import type { BrokerDataFrame, RefMeta } from '../../../core/runtime/broker-protocol.js';
|
|
3
|
+
import type { FrecencyStore } from '../input/completion-frecency.js';
|
|
3
4
|
import type { ViewerCapabilities } from '../input/capabilities.js';
|
|
4
5
|
import { type PreviewableCommand } from '../slash/prompt-preview.js';
|
|
5
6
|
import type { AttachBindings } from './bindings.js';
|
|
@@ -51,6 +52,8 @@ export interface EditorInventoryHooks {
|
|
|
51
52
|
bindings: () => AttachBindings;
|
|
52
53
|
/** Resolved once in `runAttach`; never changes for the session. */
|
|
53
54
|
fdPath: string | null;
|
|
55
|
+
/** Per-user frecency store — biases command/ref completion order by usage. */
|
|
56
|
+
frecency: FrecencyStore;
|
|
54
57
|
}
|
|
55
58
|
/** Owns everything the editor can complete against — the merged slash-command
|
|
56
59
|
* list, the loaded-command preview, the broker's raw command metadata, and the
|
|
@@ -91,7 +91,7 @@ export function createEditorInventory(ctx, capabilities, hooks) {
|
|
|
91
91
|
// reads only `commands`, never `memoryRefs`).
|
|
92
92
|
const syncEditorInventories = () => {
|
|
93
93
|
const commandProvider = buildAttachAutocompleteProvider(meta.cwd, hooks.fdPath, brokerCommands, capabilities);
|
|
94
|
-
editor.setAutocompleteProvider(new RefAwareAutocompleteProvider(memoryRefs, commandProvider));
|
|
94
|
+
editor.setAutocompleteProvider(new RefAwareAutocompleteProvider(memoryRefs, commandProvider, hooks.frecency));
|
|
95
95
|
editor.setResolvedRefNames(memoryRefs.map((ref) => ref.name));
|
|
96
96
|
// Two independent suppression inventories: the viewer-local
|
|
97
97
|
// set (everything `dispatchSlashCommand` handles entirely locally, trimmed
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { InputController } from '../input/controller.js';
|
|
2
|
+
import type { FrecencyStore } from '../input/completion-frecency.js';
|
|
2
3
|
import { type ViewerCapabilities } from '../input/capabilities.js';
|
|
3
4
|
import type { ToolDisplayToggle } from '../render/chat-view.js';
|
|
4
5
|
import type { KeybindingsManager } from '@earendil-works/pi-tui';
|
|
@@ -40,6 +41,8 @@ export interface InputWiringHooks {
|
|
|
40
41
|
setChipColor: (color: string | null) => void;
|
|
41
42
|
/** A prompt or steer was accepted from the editor. */
|
|
42
43
|
onPromptSent: () => void;
|
|
44
|
+
/** Per-user completion frecency store, shared with the editor inventory. */
|
|
45
|
+
frecency: FrecencyStore;
|
|
43
46
|
}
|
|
44
47
|
/** Construct the viewer's one capability authority. */
|
|
45
48
|
export declare function createViewerCapabilitiesForInput(ctx: Pick<AttachSession, 'nodeId' | 'remote' | 'role' | 'meta'>): ViewerCapabilities;
|
|
@@ -74,6 +74,8 @@ export function createInputController(ctx, km, capabilities, hooks) {
|
|
|
74
74
|
},
|
|
75
75
|
onColor: hooks.setChipColor,
|
|
76
76
|
onPromptSent: hooks.onPromptSent,
|
|
77
|
+
frecency: hooks.frecency,
|
|
78
|
+
knownRefNames: () => editor.getResolvedRefNames(),
|
|
77
79
|
// Global display toggles (Ctrl+O tools / Ctrl+T thinking) — pure render
|
|
78
80
|
// state owned by ChatView, surfaced as a footer notice mirroring pi's
|
|
79
81
|
// status line.
|