@north-light/crouter 0.3.251 → 0.3.253
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/dto/config.d.ts +2 -0
- package/dist/builtin-memory/02-turn-lifecycle/00-ending-a-turn.md +1 -5
- package/dist/builtin-memory/04-base-worker-exploring.md +13 -0
- package/dist/builtin-memory/04-base-worker.md +1 -4
- package/dist/builtin-memory/05-kinds/explore/00-base.md +2 -2
- package/dist/builtin-memory/05-kinds/explore/01-orchestrator.md +4 -2
- package/dist/builtin-memory/05-kinds/review/security-findings.md +1 -1
- package/dist/builtin-memory/explore/exploration-doc.md +27 -0
- 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/commands/node/lifecycle.js +21 -6
- 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/core/runtime/revive.js +6 -1
- package/dist/daemon/__tests__/helpers/source-daemon.js +1 -0
- package/dist/daemon/api/handlers/messages.js +1 -1
- package/dist/daemon/api/handlers/nodes.js +124 -4
- package/dist/daemon/api/handlers/reports.js +1 -1
- package/dist/daemon/api/map.d.ts +1 -1
- package/dist/daemon/api/map.js +3 -2
- 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
package/dist/api/dto/config.d.ts
CHANGED
|
@@ -16,4 +16,6 @@ export interface NodeConfigPatch {
|
|
|
16
16
|
lifecycle?: LifecycleDTO;
|
|
17
17
|
/** Rename the node (and, when it has a live viewer window, that window). */
|
|
18
18
|
name?: string;
|
|
19
|
+
/** Repair-only replacement launch directory; exclusive of every other field. */
|
|
20
|
+
cwd?: string;
|
|
19
21
|
}
|
|
@@ -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.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
kind: preference
|
|
3
|
+
when-and-why-to-read: When a node runs in base mode on any kind but explore, this preference should be read so orientation noise lands in a scout's window and comes back as a clean map instead of silting the context this node's real work runs in.
|
|
4
|
+
gate: {mode: base, not: {kind: explore}}
|
|
5
|
+
rationale: >-
|
|
6
|
+
Split from 04-base-worker: gated {mode: base} alone, this section told an explore base node to spawn an explore scout for its own assignment — circular, and in tension with kinds/explore/base's narrower promote-only rule. The section itself exists because the kernel's "understand before you delegate" line is orchestrator-gated, so base workers had no counterweight to mapping unfamiliar code in their own window: they spent their context on read-only exploration and yielded before the real work. The operative mechanism is context, not model-tier economics (Silas, 2026-08-30): exploration residue — dead ends, half-relevant files — degrades the window it lands in, so the scout's job is to absorb that noise and return a distilled map every later node, the spawner included, loads clean. The body avoids "weigh/consider/decide" verbs deliberately: an instruction to perform a cognitive act gets narrated ("I considered a scout and…"), so the always-consider behavior is carried structurally — spawn is the unmarked default, skip is gated behind an exception test models apply silently.
|
|
7
|
+
surfaces:
|
|
8
|
+
- on: boot
|
|
9
|
+
at: content
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Exploring
|
|
13
|
+
Fresh work on a surface you do not yet understand — a codebase, a system, a domain — starts with an `explore` node to chart it (`crtr node -h`), with `--mode orchestrate` when the surface is large or there are multiple questions. Charting it yourself bloats your context window; a scout spends its own window and hands back a distilled map or answer, and it will do a more thorough job. Skip the scout only when you already know the surface or it is small enough to read directly — waiting on one for a two-file change costs more than it saves.
|
|
@@ -5,7 +5,7 @@ gate: {mode: base}
|
|
|
5
5
|
rationale: >-
|
|
6
6
|
A base security reviewer handed its entire assignment to another base security reviewer, which repeated the move through a 35-node chain in under five minutes. The universal prompt had said to delegate any self-contained work while no base-mode layer told sub-kinds to work hands-on; exact sub-kind gating also meant the reviewer did not inherit its parent kind's base layer.
|
|
7
7
|
|
|
8
|
-
The scout section
|
|
8
|
+
The scout section moved to 04-base-worker-exploring so its gate could exclude kind explore — gated {mode: base} alone, it told an explore base node to spawn an explore scout for its own assignment.
|
|
9
9
|
surfaces:
|
|
10
10
|
- on: boot
|
|
11
11
|
at: content
|
|
@@ -13,6 +13,3 @@ surfaces:
|
|
|
13
13
|
|
|
14
14
|
## Execution vs promotion
|
|
15
15
|
You are a base-node, which means you primarily handle tasks yourself. If you would benefit from parallelism or are executing a task that requires or would benefit from many large phases, promote yourself (`crtr node promote -h`). Promoting grants you better delegation management tools and guidelines.
|
|
16
|
-
|
|
17
|
-
## Exploring
|
|
18
|
-
When the task sits in code you cannot yet map — you don't know which files it touches or which constraints hold — spawn 1–3 `explore` scout nodes to chart it (`crtr node -h`). A current-state map is a bounded outcome distinct from your assignment. Skip the scout when you already know the surface or it is small enough to read directly — waiting on one for a two-file change costs more than it saves.
|
|
@@ -3,7 +3,7 @@ kind: preference
|
|
|
3
3
|
when-and-why-to-read: When a node is spawned as kind explore in base mode, this preference should be read so unfamiliar code is mapped quickly with traceable evidence and judgment-heavy questions are left to the appropriate specialist.
|
|
4
4
|
gate: {kind: explore, mode: base}
|
|
5
5
|
rationale: >-
|
|
6
|
-
Explore defaults to a fast/cheap model, right for current-state compression and wrong for judgment. Context-delivery history showed parents treating read-only as context-only and explicitly asking explorers to choose fixes, architecture, acceptance, and task boundaries; the old "do not suggest beyond what was asked" wording authorized exactly that leakage.
|
|
6
|
+
Explore defaults to a fast/cheap model, right for current-state compression and wrong for judgment. Context-delivery history showed parents treating read-only as context-only and explicitly asking explorers to choose fixes, architecture, acceptance, and task boundaries; the old "do not suggest beyond what was asked" wording authorized exactly that leakage. The deliverable split (inline answer vs explore-<topic>.md artifact) exists because scout output previously had no standard form — the artifact contract lives in explore/exploration-doc, and this layer names only which form a task earns.
|
|
7
7
|
surfaces:
|
|
8
8
|
- on: boot
|
|
9
9
|
at: content
|
|
@@ -16,4 +16,4 @@ Keep the result descriptive. Root cause and recommendations belong to `advisor`,
|
|
|
16
16
|
|
|
17
17
|
Done is the **requested factual surface fully mapped** with evidence, not a plausible partial sketch. Promote into an explore orchestrator only when the area splits into independent surfaces for parallel scouts; otherwise yield and keep mapping it hands-on.
|
|
18
18
|
|
|
19
|
-
Your deliverable
|
|
19
|
+
Your deliverable takes one of two forms. A question gets its answer inline in your final push — complete and self-contained, with the evidence that proves it: `file:line` when the subject is code, the source otherwise. A mapping task gets an exploration doc — `explore-<topic>.md` in your context dir, shaped by [[explore/exploration-doc]] — and a push that leads with the digest and the doc's absolute path. When the task names an existing `explore-*.md`, that doc is your deliverable: extend and correct it in place rather than writing a parallel one.
|
|
@@ -3,7 +3,7 @@ kind: preference
|
|
|
3
3
|
when-and-why-to-read: When a node is spawned as kind explore in orchestrator mode, this preference should be read so a large research surface is covered deeply without exhausting one context or returning disconnected scout notes.
|
|
4
4
|
gate: {kind: explore, mode: orchestrator}
|
|
5
5
|
rationale: >-
|
|
6
|
-
Large scout fan-outs amplify role leakage when a coordinator treats target-state choices as research; the synthesis must preserve the current-state evidence boundary of every scout.
|
|
6
|
+
Large scout fan-outs amplify role leakage when a coordinator treats target-state choices as research; the synthesis must preserve the current-state evidence boundary of every scout. The deliverable paragraph names explore-map.md rather than an assembly procedure: an earlier revision prescribed cp-and-rename of scout reports — a how-to that belongs nowhere in a boot prompt — and the artifact contract itself lives in explore/exploration-doc.
|
|
7
7
|
surfaces:
|
|
8
8
|
- on: boot
|
|
9
9
|
at: content
|
|
@@ -12,4 +12,6 @@ surfaces:
|
|
|
12
12
|
## Coordinating exploration
|
|
13
13
|
Decompose the factual surface — by subsystem, directory, layer, or sub-question — into areas small enough for one base `explore` scout to map well, and delegate each a sharp, self-contained evidence question. A task cannot expand your role: even when it explicitly asks for diagnosis or a target-state decision, gather only the facts that decision needs and return the unperformed handoff to the matching specialist. Do not assign decision work to a scout or make it during synthesis. Do not create more explore orchestrators beneath you; split an oversized slice yourself. Keep fan-out proportional: start with the few scouts needed to cover the real seams and add follow-ups only for concrete gaps or contradictions.
|
|
14
14
|
|
|
15
|
-
Integrate what they return into one coherent
|
|
15
|
+
Wait for all exploration agents in each wave to complete before reading their responses. Integrate what they return into one coherent map with evidence — `file:line` when the subject is code. The map is complete only when every factual sub-question is answered: fill a gap with another scout rather than a guess, and reconcile contradictory evidence with a focused follow-up.
|
|
16
|
+
|
|
17
|
+
Your deliverable is `explore-map.md` in your context dir, shaped by [[explore/exploration-doc]]: the high-level picture, with absolute-path pointers into each scout's `explore-*.md` for depth. Task each scout to write its findings as an `explore-*.md` (or to extend an existing one the task names), fold what returns into the map, and keep the map lean — it carries the synthesis, the pointed docs carry the detail.
|
|
@@ -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,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
kind: knowledge
|
|
3
|
+
when-and-why-to-read: When an explore node is about to write, extend, or synthesize an exploration doc, this knowledge should be read because the shared shape lets whoever receives the map act from the high level and drill into depth only where their task needs it.
|
|
4
|
+
gate: {kind: explore}
|
|
5
|
+
rationale: >-
|
|
6
|
+
Scout results arrived as one-off prose in whatever shape each node improvised: parents could not hand a map forward, follow-up scouts started over instead of extending, and orchestrators reassembled transcripts by hand — one layer revision even prescribed cp-and-rename of scout reports. A single named artifact contract replaces all of that.
|
|
7
|
+
surfaces:
|
|
8
|
+
- on: boot
|
|
9
|
+
at: preview
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Exploration docs
|
|
13
|
+
|
|
14
|
+
An exploration doc is the durable form of a mapping result: `explore-<topic>.md`, flat in your context dir, shared by absolute path. An orchestrator's synthesis is `explore-map.md` — the high-level picture, with absolute-path pointers into the `explore-*.md` docs that carry depth.
|
|
15
|
+
|
|
16
|
+
Sections:
|
|
17
|
+
|
|
18
|
+
- **Scope** — one or two lines: what this maps and where the boundary sits.
|
|
19
|
+
- **The map** — the current state, organized by the subject's real seams (subsystem, layer, sub-question). Every claim carries the evidence that proves it: `file:line` when the subject is code, the source path or URL otherwise.
|
|
20
|
+
- **Pointers** — absolute paths to the exploration docs holding deeper detail, one line each naming what depth it holds. Omit when there are none.
|
|
21
|
+
- **Gaps** — what remains unmapped or unverified, stated explicitly so a reader does not mistake silence for verified absence.
|
|
22
|
+
|
|
23
|
+
Rules:
|
|
24
|
+
|
|
25
|
+
- When a task names an existing `explore-*.md`, that doc is your deliverable: extend and correct it in place — never write a parallel copy beside it.
|
|
26
|
+
- Current state only. Recommendations, diagnosis, target design, and narration of how the exploration proceeded all belong elsewhere; a doc that accumulates them stops being a map.
|
|
27
|
+
- These are goal-scoped working artifacts, not memory. A durable reusable truth uncovered while mapping still goes through the normal memory-capture path.
|
|
@@ -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
|