@north-light/crouter 0.3.250 → 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.
Files changed (50) hide show
  1. package/dist/builtin-memory/02-turn-lifecycle/00-ending-a-turn.md +1 -5
  2. package/dist/builtin-memory/05-kinds/review/security-findings.md +1 -1
  3. package/dist/clients/attach/__tests__/completion-frecency.test.d.ts +1 -0
  4. package/dist/clients/attach/__tests__/completion-frecency.test.js +35 -0
  5. package/dist/clients/attach/__tests__/ref-autocomplete.test.js +3 -1
  6. package/dist/clients/attach/__tests__/titled-editor-preview.test.js +2 -1
  7. package/dist/clients/attach/input/completion-frecency.d.ts +45 -0
  8. package/dist/clients/attach/input/completion-frecency.js +141 -0
  9. package/dist/clients/attach/input/controller.d.ts +17 -0
  10. package/dist/clients/attach/input/controller.js +40 -0
  11. package/dist/clients/attach/input/ref-autocomplete.d.ts +3 -1
  12. package/dist/clients/attach/input/ref-autocomplete.js +49 -8
  13. package/dist/clients/attach/input/titled-editor.d.ts +3 -0
  14. package/dist/clients/attach/input/titled-editor.js +5 -0
  15. package/dist/clients/attach/session/editor-inventory.d.ts +3 -0
  16. package/dist/clients/attach/session/editor-inventory.js +1 -1
  17. package/dist/clients/attach/session/input-wiring.d.ts +3 -0
  18. package/dist/clients/attach/session/input-wiring.js +2 -0
  19. package/dist/clients/attach/viewer.js +578 -578
  20. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +98 -1
  21. package/dist/core/__tests__/canvas-inbox-watcher-naming.test.js +4 -1
  22. package/dist/core/__tests__/canvas-inbox-watcher.test.js +4 -1
  23. package/dist/core/__tests__/fixtures/fake-engine.d.ts +3 -1
  24. package/dist/core/__tests__/fixtures/fake-engine.js +16 -3
  25. package/dist/core/__tests__/integration/deferred-no-wake.test.js +4 -1
  26. package/dist/core/__tests__/migration.test.js +57 -11
  27. package/dist/core/__tests__/seam/broker-provider-retry.test.js +17 -2
  28. package/dist/core/__tests__/seam/dormancy-release.test.js +1 -0
  29. package/dist/core/__tests__/seam/held-deferred-human-prompt.test.d.ts +1 -0
  30. package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +63 -0
  31. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +9 -2
  32. package/dist/core/canvas/migrations.js +63 -24
  33. package/dist/core/runtime/broker/engine-drive.js +20 -3
  34. package/dist/core/runtime/broker/fault-retry.js +31 -13
  35. package/dist/core/runtime/broker/held-deferred-inbox.d.ts +13 -0
  36. package/dist/core/runtime/broker/held-deferred-inbox.js +13 -0
  37. package/dist/core/runtime/broker.js +19 -0
  38. package/dist/core/runtime/close.js +2 -2
  39. package/dist/core/runtime/fault.js +8 -5
  40. package/dist/core/runtime/recycle.js +63 -41
  41. package/dist/core/runtime/reset.d.ts +1 -1
  42. package/dist/core/runtime/reset.js +25 -2
  43. package/dist/daemon/__tests__/helpers/source-daemon.js +1 -0
  44. package/dist/daemon/api/handlers/messages.js +1 -1
  45. package/dist/daemon/api/handlers/reports.js +1 -1
  46. package/dist/daemon/cron/sinks.js +1 -1
  47. package/dist/daemon/messaging/node-message.js +1 -1
  48. package/dist/pi-extensions/canvas-inbox-watcher.js +109 -5
  49. package/package.json +1 -1
  50. 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: you revive fresh as the same node with the same mandate, carrying a note to your future self.
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 an urgent push when work is waiting on this review, so an unresolved posture does not stall what is already proved.
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,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
- editor.setAutocompleteProvider(new RefAwareAutocompleteProvider(refs, delegate));
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
- constructor(refs: ReadonlyArray<RefMeta>, delegate: AutocompleteProvider);
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
- constructor(refs, delegate) {
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 || context.kind === 'leading') {
86
+ if (!context) {
84
87
  return this.delegate.getSuggestions(lines, cursorLine, cursorCol, options);
85
88
  }
86
- const prefix = context.prefix;
87
- const matches = this.refs.filter((ref) => formatRefToken(ref.name).startsWith(prefix));
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
- // Filtering above used the full slash-form `prefix` (context.prefix always
96
- // starts with '/' for a matched token), but the RETURNED prefix strips that
97
- // leading '/'. pi-tui's `handleInput` (tui.select.confirm
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.