@north-light/crouter 0.3.220 → 0.3.221
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/client.d.ts +9 -0
- package/dist/api/client.js +10 -0
- package/dist/api/dto/chat-inventory.d.ts +43 -0
- package/dist/api/dto/chat-inventory.js +11 -0
- package/dist/api/dto/profiles.d.ts +19 -5
- package/dist/api/dto/profiles.js +2 -1
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/build-root.d.ts +2 -6
- package/dist/build-root.js +51 -4
- package/dist/builtin-memory/internal/agent-shaping.md +3 -1
- package/dist/builtin-memory/internal/memory-loading.md +4 -0
- package/dist/builtin-memory/plan/roadmap.md +7 -1
- package/dist/builtin-memory/spec/guide.md +7 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/claude-plugin-commands.ts +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crtr-commands/index.ts +7 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +7 -2
- package/dist/clients/attach/__tests__/ref-autocomplete.test.js +1 -1
- package/dist/clients/attach/__tests__/titled-editor-preview.test.js +1 -1
- package/dist/clients/attach/overlays/file-review.js +2 -2
- package/dist/clients/attach/session/keys.d.ts +1 -1
- package/dist/clients/attach/session/profile-files.js +1 -1
- package/dist/clients/attach/viewer.js +690 -690
- package/dist/clients/inbox/review/launch.d.ts +8 -4
- package/dist/clients/inbox/review/launch.js +55 -5
- package/dist/clients/inbox/review/review-client.d.ts +1 -0
- package/dist/clients/inbox/review/review-client.js +4 -0
- package/dist/clients/inbox/review-adapter.d.ts +1 -8
- package/dist/clients/inbox/review-adapter.js +4 -52
- package/dist/commands/memory/lint.js +2 -1
- package/dist/commands/memory/read.js +1 -0
- package/dist/commands/memory.js +1 -1
- package/dist/commands/pkg/market-manage.js +165 -75
- package/dist/commands/pkg/plugin-inspect.js +19 -2
- package/dist/commands/pkg/plugin-manage.d.ts +8 -3
- package/dist/commands/pkg/plugin-manage.js +72 -24
- package/dist/commands/profile/default.js +6 -10
- package/dist/commands/profile/list.js +5 -3
- package/dist/commands/profile/new.js +21 -8
- package/dist/commands/profile/project.js +25 -19
- package/dist/commands/profile/show.js +3 -3
- package/dist/commands/surface-inbox.js +1 -0
- package/dist/commands/sys/__tests__/migrate.test.js +16 -5
- package/dist/commands/sys/doctor.js +35 -5
- package/dist/commands/sys/migrate.js +38 -19
- package/dist/commands/sys/setup-core.js +1 -1
- package/dist/commands/sys/sync-project-guidance.js +1 -1
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +1 -1
- package/dist/core/__tests__/fixtures/c5-command-boundary-ext.js +24 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +24 -18
- package/dist/core/__tests__/fixtures/fake-engine.js +8 -1
- package/dist/core/__tests__/inline-memory-refs.test.js +36 -2
- package/dist/core/__tests__/profile-project-memory-delivery.test.js +217 -0
- package/dist/core/__tests__/serial/broker-sdk-wiring.test.js +102 -2
- package/dist/core/bootstrap.js +6 -0
- package/dist/core/canvas/browse/app.js +5 -2
- package/dist/core/canvas/browse/model.d.ts +25 -15
- package/dist/core/canvas/browse/model.js +86 -65
- package/dist/core/canvas/render-source.d.ts +6 -0
- package/dist/core/canvas/render-source.js +7 -1
- package/dist/core/canvas/render.js +10 -2
- package/dist/core/command-hooks/artifact.d.ts +10 -0
- package/dist/core/command-hooks/artifact.js +129 -0
- package/dist/core/command-hooks/catalog.d.ts +14 -0
- package/dist/core/command-hooks/catalog.js +38 -0
- package/dist/core/command-hooks/compose.d.ts +15 -0
- package/dist/core/command-hooks/compose.js +99 -0
- package/dist/core/command-hooks/discovery.d.ts +87 -0
- package/dist/core/command-hooks/discovery.js +174 -0
- package/dist/core/command-hooks/help.d.ts +5 -0
- package/dist/core/command-hooks/help.js +18 -0
- package/dist/core/command-hooks/index.d.ts +6 -0
- package/dist/core/command-hooks/index.js +6 -0
- package/dist/core/command-hooks/report.d.ts +23 -0
- package/dist/core/command-hooks/report.js +19 -0
- package/dist/core/command-hooks/schema.d.ts +27 -0
- package/dist/core/command-hooks/schema.js +68 -0
- package/dist/core/command-hooks/transport/exec-invoke.d.ts +22 -0
- package/dist/core/command-hooks/transport/exec-invoke.js +274 -0
- package/dist/core/command-plugins/presence.d.ts +2 -0
- package/dist/core/command-plugins/presence.js +17 -0
- package/dist/core/command-plugins/transport/exec-invoke.d.ts +5 -0
- package/dist/core/command-plugins/transport/exec-invoke.js +58 -5
- package/dist/core/command.d.ts +8 -1
- package/dist/core/command.js +12 -10
- package/dist/core/help.d.ts +7 -1
- package/dist/core/io.d.ts +9 -1
- package/dist/core/io.js +44 -2
- package/dist/core/memory/inline-ref-inventory.d.ts +2 -1
- package/dist/core/memory/inline-ref-inventory.js +15 -8
- package/dist/core/memory-resolver.d.ts +13 -1
- package/dist/core/memory-resolver.js +25 -19
- package/dist/core/profiles/manifest.d.ts +13 -2
- package/dist/core/profiles/manifest.js +84 -18
- package/dist/core/profiles/select.js +9 -9
- package/dist/core/render.js +11 -0
- package/dist/core/runtime/advertised-command-invocation.d.ts +20 -0
- package/dist/core/runtime/advertised-command-invocation.js +233 -0
- package/dist/core/runtime/bearings.js +1 -1
- package/dist/core/runtime/broker/event-projection.js +7 -0
- package/dist/core/runtime/broker/frame-dispatch.d.ts +1 -1
- package/dist/core/runtime/broker/frame-dispatch.js +15 -10
- package/dist/core/runtime/broker/read-ops.d.ts +4 -0
- package/dist/core/runtime/broker/read-ops.js +6 -2
- package/dist/core/runtime/broker-extension-render.js +1 -1
- package/dist/core/runtime/broker-inventory.d.ts +5 -0
- package/dist/core/runtime/broker-inventory.js +191 -0
- package/dist/core/runtime/broker-protocol.d.ts +9 -2
- package/dist/core/runtime/broker.js +10 -1
- package/dist/core/runtime/command-surface.d.ts +33 -0
- package/dist/core/runtime/command-surface.js +81 -0
- package/dist/core/runtime/node-read.js +5 -0
- package/dist/core/scope.d.ts +26 -1
- package/dist/core/scope.js +52 -12
- package/dist/core/substrate/on-read.d.ts +7 -1
- package/dist/core/substrate/on-read.js +13 -4
- package/dist/core/substrate/render.js +14 -5
- package/dist/core/substrate/schema.d.ts +11 -1
- package/dist/core/substrate/schema.js +11 -2
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +4 -4
- package/dist/daemon/api/handlers/chat-inventory.d.ts +2 -0
- package/dist/daemon/api/handlers/chat-inventory.js +25 -0
- package/dist/daemon/api/handlers/profiles.js +7 -1
- package/dist/daemon/api/map.d.ts +2 -1
- package/dist/daemon/api/map.js +3 -2
- package/dist/daemon/api/server.js +2 -0
- package/dist/hook-authoring.d.ts +75 -0
- package/dist/hook-authoring.js +358 -0
- package/dist/hook-process.d.ts +7 -0
- package/dist/hook-process.js +34 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/migrations/002-profile-project-memory.d.ts +2 -0
- package/dist/migrations/002-profile-project-memory.js +71 -0
- package/dist/migrations/profile-manifests.d.ts +30 -0
- package/dist/migrations/profile-manifests.js +70 -0
- package/dist/migrations/registry.js +10 -5
- package/dist/migrations/types.d.ts +28 -1
- package/dist/migrations/types.js +15 -9
- package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +21 -4
- package/dist/pi-extensions/canvas-structured-output.js +85 -2
- package/dist/types.d.ts +15 -6
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/clients/attach/__tests__/file-review-focus.test.js +0 -49
- /package/dist/{clients/attach/__tests__/file-review-focus.test.d.ts → core/__tests__/profile-project-memory-delivery.test.d.ts} +0 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// A selected profile's project relationship caps AUTOMATIC delivery — boot and
|
|
2
|
+
// workspace-open — and nothing else. The reported failure this suite exists for:
|
|
3
|
+
// a profile project that is also the node's cwd rendered its authored rung, as
|
|
4
|
+
// if the relationship applied only to projects reached through the manifest.
|
|
5
|
+
//
|
|
6
|
+
// One real fixture drives the real scope resolver, the real boot renderer, the
|
|
7
|
+
// real workspace-open renderer, and the real `crtr memory read` leaf: a profile
|
|
8
|
+
// whose four projects sit at none/name/preview/content in manifest order, with
|
|
9
|
+
// the `preview` project as cwd and canonical-name collisions across projects and
|
|
10
|
+
// against the wider user scope.
|
|
11
|
+
//
|
|
12
|
+
// Migration convergence is NOT here — it lives in migration.test.ts.
|
|
13
|
+
//
|
|
14
|
+
// Run: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/profile-project-memory-delivery.test.ts
|
|
15
|
+
import { test, before, beforeEach, after } from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { closeDb } from '../canvas/db.js';
|
|
21
|
+
import { resetScopeCache } from '../scope.js';
|
|
22
|
+
import { createProfile } from '../profiles/manifest.js';
|
|
23
|
+
import { spawnNode } from '../runtime/nodes.js';
|
|
24
|
+
import { clearSessionCache } from '../substrate/session-cache.js';
|
|
25
|
+
import { renderKnowledgeBlock } from '../substrate/render-node.js';
|
|
26
|
+
import { renderWorkspaceOpenDocs } from '../substrate/on-read-node.js';
|
|
27
|
+
import { deliveredAtOrAbove, loadInjectedDocs, saveInjectedDocs, } from '../substrate/injected-store.js';
|
|
28
|
+
import { readLeaf } from '../../commands/memory/read.js';
|
|
29
|
+
const B = {
|
|
30
|
+
pnone: 'BODY-T9-PNONE',
|
|
31
|
+
pname: 'BODY-T9-PNAME',
|
|
32
|
+
ppreview: 'BODY-T9-PPREVIEW',
|
|
33
|
+
pcontent: 'BODY-T9-PCONTENT',
|
|
34
|
+
sharedFromPnone: 'BODY-T9-SHARED-FROM-PNONE',
|
|
35
|
+
sharedFromPcontent: 'BODY-T9-SHARED-FROM-PCONTENT',
|
|
36
|
+
nameFromPname: 'BODY-T9-NAMEDUP-FROM-PNAME',
|
|
37
|
+
nameFromPcontent: 'BODY-T9-NAMEDUP-FROM-PCONTENT',
|
|
38
|
+
userDupFromPnone: 'BODY-T9-USERDUP-FROM-PNONE',
|
|
39
|
+
userDupFromUser: 'BODY-T9-USERDUP-FROM-USER',
|
|
40
|
+
};
|
|
41
|
+
let home;
|
|
42
|
+
let canvasHome;
|
|
43
|
+
let profileId;
|
|
44
|
+
let prevHome;
|
|
45
|
+
let prevCanvasHome;
|
|
46
|
+
let prevProfile;
|
|
47
|
+
let prevNode;
|
|
48
|
+
let prevCwd;
|
|
49
|
+
let cwdProject;
|
|
50
|
+
let ppreviewDocPath;
|
|
51
|
+
/** A doc delivered automatically at the top rung by both automatic channels —
|
|
52
|
+
* every cap observed below is the relationship's, never the author's. */
|
|
53
|
+
function autoDoc(when, body) {
|
|
54
|
+
return [
|
|
55
|
+
'---',
|
|
56
|
+
'kind: knowledge',
|
|
57
|
+
`when-and-why-to-read: "${when}"`,
|
|
58
|
+
'surfaces:',
|
|
59
|
+
' - {on: boot, at: content}',
|
|
60
|
+
' - {on: workspace-open, at: content}',
|
|
61
|
+
'---',
|
|
62
|
+
'',
|
|
63
|
+
body,
|
|
64
|
+
'',
|
|
65
|
+
].join('\n');
|
|
66
|
+
}
|
|
67
|
+
/** No surfaces at all: invisible at boot, counted into its dir's `[+N more]`. */
|
|
68
|
+
function bootlessDoc(when, body) {
|
|
69
|
+
return ['---', 'kind: knowledge', `when-and-why-to-read: "${when}"`, '---', '', body, ''].join('\n');
|
|
70
|
+
}
|
|
71
|
+
function writeDoc(storeDir, relName, contents) {
|
|
72
|
+
const path = join(storeDir, `${relName}.md`);
|
|
73
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
74
|
+
writeFileSync(path, contents);
|
|
75
|
+
return path;
|
|
76
|
+
}
|
|
77
|
+
before(() => {
|
|
78
|
+
prevHome = process.env['HOME'];
|
|
79
|
+
prevCanvasHome = process.env['CRTR_HOME'];
|
|
80
|
+
prevProfile = process.env['CRTR_PROFILE_ID'];
|
|
81
|
+
prevNode = process.env['CRTR_NODE_ID'];
|
|
82
|
+
prevCwd = process.cwd();
|
|
83
|
+
// realpath: the manifest owner persists realpath-resolved project paths. A raw
|
|
84
|
+
// /var/folders path on macOS makes the cwd ancestor walk and the profile
|
|
85
|
+
// pointer name two different roots, and the cwd project then reads as uncapped
|
|
86
|
+
// for the wrong reason.
|
|
87
|
+
home = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-ppm-home-')));
|
|
88
|
+
canvasHome = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-ppm-canvas-')));
|
|
89
|
+
process.env['HOME'] = home;
|
|
90
|
+
process.env['CRTR_HOME'] = canvasHome;
|
|
91
|
+
const store = (slug) => {
|
|
92
|
+
const dir = join(home, slug, '.crouter', 'memory');
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
return dir;
|
|
95
|
+
};
|
|
96
|
+
const pnone = store('pnone');
|
|
97
|
+
const pname = store('pname');
|
|
98
|
+
const ppreview = store('ppreview');
|
|
99
|
+
const pcontent = store('pcontent');
|
|
100
|
+
const user = join(home, '.crouter', 'memory');
|
|
101
|
+
mkdirSync(user, { recursive: true });
|
|
102
|
+
writeDoc(pnone, 't9-pnone-doc', autoDoc('When the none project delivers', B.pnone));
|
|
103
|
+
writeDoc(pname, 't9-pname-doc', autoDoc('When the name project delivers', B.pname));
|
|
104
|
+
ppreviewDocPath = writeDoc(ppreview, 't9-ppreview-doc', autoDoc('When the cwd project delivers', B.ppreview));
|
|
105
|
+
writeDoc(pcontent, 't9-pcontent-doc', autoDoc('When the content project delivers', B.pcontent));
|
|
106
|
+
// Collision one: `none` (earlier in manifest order) vs `content`. The cap must
|
|
107
|
+
// apply before name dedupe, so the dropped entry cannot shadow the winner.
|
|
108
|
+
writeDoc(pnone, 't9-shared-doc', autoDoc('When the shared name resolves', B.sharedFromPnone));
|
|
109
|
+
writeDoc(pcontent, 't9-shared-doc', autoDoc('When the shared name resolves', B.sharedFromPcontent));
|
|
110
|
+
// Collision two: `name` vs `content`. A non-none cap keeps its project's
|
|
111
|
+
// precedence and discloses at the capped rung — it does not step aside.
|
|
112
|
+
writeDoc(pname, 't9-namedup-doc', autoDoc('When the name-capped duplicate resolves', B.nameFromPname));
|
|
113
|
+
writeDoc(pcontent, 't9-namedup-doc', autoDoc('When the name-capped duplicate resolves', B.nameFromPcontent));
|
|
114
|
+
// Collision three: `none` vs the wider user scope, which project precedence
|
|
115
|
+
// would otherwise beat.
|
|
116
|
+
writeDoc(pnone, 't9-userdup-doc', autoDoc('When the user duplicate resolves', B.userDupFromPnone));
|
|
117
|
+
writeDoc(user, 't9-userdup-doc', autoDoc('When the user duplicate resolves', B.userDupFromUser));
|
|
118
|
+
// Hidden-count pair: a top-level dir always renders, so a dir holding only a
|
|
119
|
+
// bootless doc shows as `<dir>/` + `[+1 more]` — unless its project is `none`.
|
|
120
|
+
writeDoc(pnone, join('t9-none-hidden', 'probe'), bootlessDoc('When counted', 'HIDDEN-NONE'));
|
|
121
|
+
writeDoc(pcontent, join('t9-hidden-control', 'probe'), bootlessDoc('When counted', 'HIDDEN-CONTROL'));
|
|
122
|
+
profileId = createProfile('t9', [
|
|
123
|
+
{ path: join(home, 'pnone'), memory: 'none' },
|
|
124
|
+
{ path: join(home, 'pname'), memory: 'name' },
|
|
125
|
+
{ path: join(home, 'ppreview'), memory: 'preview' },
|
|
126
|
+
{ path: join(home, 'pcontent'), memory: 'content' },
|
|
127
|
+
]).profileId;
|
|
128
|
+
process.env['CRTR_PROFILE_ID'] = profileId;
|
|
129
|
+
// The node works INSIDE a capped project: the reported failure.
|
|
130
|
+
cwdProject = join(home, 'ppreview');
|
|
131
|
+
process.chdir(cwdProject);
|
|
132
|
+
});
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
resetScopeCache();
|
|
135
|
+
clearSessionCache();
|
|
136
|
+
});
|
|
137
|
+
after(() => {
|
|
138
|
+
closeDb();
|
|
139
|
+
resetScopeCache();
|
|
140
|
+
clearSessionCache();
|
|
141
|
+
process.chdir(prevCwd);
|
|
142
|
+
rmSync(home, { recursive: true, force: true });
|
|
143
|
+
rmSync(canvasHome, { recursive: true, force: true });
|
|
144
|
+
const restore = (key, value) => {
|
|
145
|
+
if (value === undefined)
|
|
146
|
+
delete process.env[key];
|
|
147
|
+
else
|
|
148
|
+
process.env[key] = value;
|
|
149
|
+
};
|
|
150
|
+
restore('HOME', prevHome);
|
|
151
|
+
restore('CRTR_HOME', prevCanvasHome);
|
|
152
|
+
restore('CRTR_PROFILE_ID', prevProfile);
|
|
153
|
+
restore('CRTR_NODE_ID', prevNode);
|
|
154
|
+
});
|
|
155
|
+
function newNode() {
|
|
156
|
+
return spawnNode({ kind: 'general', cwd: cwdProject, parent: null, profile_id: profileId }).node_id;
|
|
157
|
+
}
|
|
158
|
+
test('boot delivery caps every profile project at its relationship, including the one used as cwd', () => {
|
|
159
|
+
const boot = renderKnowledgeBlock(newNode(), new Map());
|
|
160
|
+
// The control: an uncapped relationship still renders its authored content.
|
|
161
|
+
assert.ok(boot.includes(B.pcontent), `the content-capped project renders its body; got:\n${boot}`);
|
|
162
|
+
// The reported failure. cwd reaches this root before the manifest pointer
|
|
163
|
+
// does, and the relationship still decides the ceiling.
|
|
164
|
+
assert.ok(!boot.includes(B.ppreview), `the cwd project is capped at preview — its body must not render; got:\n${boot}`);
|
|
165
|
+
assert.match(boot, /t9-ppreview-doc {2}# read when: When the cwd project delivers\./, `the cwd project's doc discloses at preview; got:\n${boot}`);
|
|
166
|
+
// `name` discloses the bare identifier and nothing else.
|
|
167
|
+
assert.ok(!boot.includes(B.pname), 'a name-capped project must not render a body');
|
|
168
|
+
assert.match(boot, /[├└]─ t9-pname-doc$/m, `the name-capped doc discloses as a bare name; got:\n${boot}`);
|
|
169
|
+
assert.ok(!boot.includes('When the name project delivers'), 'a name-capped doc discloses no preview line');
|
|
170
|
+
});
|
|
171
|
+
test('a `none` project contributes nothing to boot — no body, no name, no hidden count, no shadow', () => {
|
|
172
|
+
const boot = renderKnowledgeBlock(newNode(), new Map());
|
|
173
|
+
assert.ok(!boot.includes(B.pnone), 'a none-capped body must not render');
|
|
174
|
+
assert.ok(!boot.includes('t9-pnone-doc'), 'a none-capped doc must not appear as a name either');
|
|
175
|
+
// Hidden counts are disclosure too: the control dir proves a bootless doc
|
|
176
|
+
// does surface as a `[+N more]` count, so the none project's absence is the cap.
|
|
177
|
+
assert.match(boot, /t9-hidden-control\/\n.*\[\+1 more\]/, `the control dir shows its hidden count; got:\n${boot}`);
|
|
178
|
+
assert.ok(!boot.includes('t9-none-hidden'), 'a none-capped project contributes no `[+N more]` count');
|
|
179
|
+
// Shadowing: `none` wins name dedupe on precedence, so a cap applied after
|
|
180
|
+
// dedupe would silently delete the winner instead of deferring to it.
|
|
181
|
+
assert.ok(boot.includes(B.sharedFromPcontent) && !boot.includes(B.sharedFromPnone), `the content project wins the collision the none project would have taken; got:\n${boot}`);
|
|
182
|
+
assert.ok(boot.includes(B.userDupFromUser) && !boot.includes(B.userDupFromPnone), `a none project does not shadow the wider user-scope winner; got:\n${boot}`);
|
|
183
|
+
});
|
|
184
|
+
test('a non-none cap keeps its project precedence and discloses the duplicate at the capped rung', () => {
|
|
185
|
+
const boot = renderKnowledgeBlock(newNode(), new Map());
|
|
186
|
+
assert.match(boot, /[├└]─ t9-namedup-doc$/m, `the name-capped duplicate wins dedupe; got:\n${boot}`);
|
|
187
|
+
assert.ok(!boot.includes(B.nameFromPname), 'the winner discloses at `name`, never its body');
|
|
188
|
+
assert.ok(!boot.includes(B.nameFromPcontent), 'the content project does not take a collision the name project already won');
|
|
189
|
+
});
|
|
190
|
+
test('workspace-open clamps each project store the same way boot does', () => {
|
|
191
|
+
const opened = renderWorkspaceOpenDocs(newNode(), new Map());
|
|
192
|
+
assert.ok(opened.includes(B.pcontent), `the content-capped project delivers its body; got:\n${opened}`);
|
|
193
|
+
assert.ok(!opened.includes(B.ppreview), `the cwd project must not deliver its body; got:\n${opened}`);
|
|
194
|
+
assert.match(opened, /name="t9-ppreview-doc"[^>]*>\nWhen the cwd project delivers\./, `the cwd project delivers its preview line; got:\n${opened}`);
|
|
195
|
+
assert.match(opened, /name="t9-pname-doc"[^>]*\/>/, `the name-capped doc delivers name-only; got:\n${opened}`);
|
|
196
|
+
assert.ok(!opened.includes('t9-pnone-doc'), 'the none-capped project delivers nothing at workspace-open');
|
|
197
|
+
assert.ok(!opened.includes(B.sharedFromPnone), 'nor its side of a name collision');
|
|
198
|
+
});
|
|
199
|
+
test('an explicit `crtr memory read` returns and records the full body a cap withheld', async () => {
|
|
200
|
+
const node = newNode();
|
|
201
|
+
process.env['CRTR_NODE_ID'] = node;
|
|
202
|
+
const seen = loadInjectedDocs(node);
|
|
203
|
+
const opened = renderWorkspaceOpenDocs(node, seen);
|
|
204
|
+
assert.ok(!opened.includes(B.ppreview), 'workspace-open disclosed the capped doc at preview only');
|
|
205
|
+
saveInjectedDocs(node, seen);
|
|
206
|
+
const result = (await readLeaf.run({ name: 't9-ppreview-doc', frontmatter: false }));
|
|
207
|
+
assert.equal(result.path, ppreviewDocPath, 'the read resolved the cwd project doc');
|
|
208
|
+
assert.ok(result.content.includes(B.ppreview), `an explicit read is uncapped and returns the full body; got:\n${result.content}`);
|
|
209
|
+
const afterRead = loadInjectedDocs(node);
|
|
210
|
+
assert.ok(deliveredAtOrAbove(afterRead, ppreviewDocPath, 'content'), 'the explicit read records a content delivery, upgrading the preview disclosure');
|
|
211
|
+
// A later automatic event runs at the capped rung, which is now below what the
|
|
212
|
+
// transcript holds, so it re-delivers nothing.
|
|
213
|
+
assert.equal(renderWorkspaceOpenDocs(node, afterRead), '', 'a later lower automatic event re-delivers nothing');
|
|
214
|
+
// ...and the emptiness is the dedup set, not a dead render.
|
|
215
|
+
assert.ok(renderWorkspaceOpenDocs(node, new Map()).includes('t9-ppreview-doc'), 'the same call against a fresh transcript still delivers');
|
|
216
|
+
delete process.env['CRTR_NODE_ID'];
|
|
217
|
+
});
|
|
@@ -26,9 +26,10 @@ import { tmpdir } from 'node:os';
|
|
|
26
26
|
import { join, dirname } from 'node:path';
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
28
|
import { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION, } from '../../runtime/broker-sdk.js';
|
|
29
|
-
import { buildBrokerSession, makeBrokerUiContext } from '../../runtime/broker.js';
|
|
30
|
-
import { isLeadingEngineCommand, resolveSteerCall, chooseGuidanceDeliveryMode, promptWithNextTurnGuidance, isHiddenJsonlBranchEntry, stripHiddenEntriesFromJsonlExport, engineLeadingCommandTokens, } from '../../runtime/broker/frame-dispatch.js';
|
|
29
|
+
import { buildBrokerSession, makeBrokerUiContext, snapshotMessages } from '../../runtime/broker.js';
|
|
30
|
+
import { isLeadingEngineCommand, resolveSteerCall, chooseGuidanceDeliveryMode, promptWithNextTurnGuidance, promptWithAdvertisedCommandInvocation, isHiddenJsonlBranchEntry, stripHiddenEntriesFromJsonlExport, engineLeadingCommandTokens, } from '../../runtime/broker/frame-dispatch.js';
|
|
31
31
|
import { CANVAS_EXTENSIONS } from '../../runtime/launch.js';
|
|
32
|
+
import { prepareAdvertisedCommandPersistence } from '../../runtime/advertised-command-invocation.js';
|
|
32
33
|
// The REAL engine, assembled from broker-sdk's static SDK re-exports — bypasses
|
|
33
34
|
// the CRTR_BROKER_ENGINE seam entirely so these tests can never accidentally hit
|
|
34
35
|
// the fake (the whole point: prove the wiring the mock hid).
|
|
@@ -403,6 +404,105 @@ test('C5 — isLeadingEngineCommand against a REAL session (registered extension
|
|
|
403
404
|
rmSync(cwd, { recursive: true, force: true });
|
|
404
405
|
}
|
|
405
406
|
});
|
|
407
|
+
test('an advertised custom command persists its exact raw invocation without replacing expanded agent input', async () => {
|
|
408
|
+
const cwd = mkdtempSync(join(tmpdir(), 'crtr-advertised-command-'));
|
|
409
|
+
try {
|
|
410
|
+
const { session } = await buildBrokerSession(realEngine, cfg(cwd, { extensionPaths: [C5_EXT] }));
|
|
411
|
+
try {
|
|
412
|
+
const raw = '/gatewaycmd exact spacing ';
|
|
413
|
+
await promptWithAdvertisedCommandInvocation(session, raw, () => session.prompt(raw, {}));
|
|
414
|
+
const custom = session.sessionManager
|
|
415
|
+
.buildSessionContext()
|
|
416
|
+
.messages.find((message) => message.role === 'custom' && message.customType === 'fixture-output');
|
|
417
|
+
assert.deepEqual(custom, {
|
|
418
|
+
role: 'custom',
|
|
419
|
+
customType: 'fixture-output',
|
|
420
|
+
content: 'expanded agent input',
|
|
421
|
+
display: true,
|
|
422
|
+
details: { fixture: true },
|
|
423
|
+
crouterAdvertisedCommandInvocation: { raw },
|
|
424
|
+
timestamp: custom?.timestamp,
|
|
425
|
+
});
|
|
426
|
+
const welcome = snapshotMessages(session);
|
|
427
|
+
assert.ok(welcome);
|
|
428
|
+
const welcomeCustom = welcome.filter(({ message }) => message.role === 'custom' && message.customType === 'fixture-output');
|
|
429
|
+
assert.equal(welcomeCustom.length, 1, 'welcome reconstructs one advertised custom message');
|
|
430
|
+
assert.deepEqual(welcomeCustom[0].message
|
|
431
|
+
.crouterAdvertisedCommandInvocation, { raw }, 'welcome preserves the exact advertised invocation spacing');
|
|
432
|
+
const persisted = session.sessionManager
|
|
433
|
+
.getEntries()
|
|
434
|
+
.find((entry) => entry.type === 'custom_message' &&
|
|
435
|
+
entry.customType === 'fixture-output');
|
|
436
|
+
assert.equal(persisted?.type, 'custom_message');
|
|
437
|
+
if (persisted?.type !== 'custom_message')
|
|
438
|
+
assert.fail('custom message was not persisted');
|
|
439
|
+
assert.equal(persisted.content, 'expanded agent input');
|
|
440
|
+
assert.deepEqual(persisted.details, {
|
|
441
|
+
crouterPersistenceEnvelope: true,
|
|
442
|
+
crouterAdvertisedCommandInvocation: { raw },
|
|
443
|
+
extensionDetails: { fixture: true },
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
session.dispose();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
test('advertised invocation metadata survives nextTurn delivery and null content normalization', async () => {
|
|
455
|
+
const cwd = mkdtempSync(join(tmpdir(), 'crtr-advertised-next-turn-'));
|
|
456
|
+
try {
|
|
457
|
+
const { session } = await buildBrokerSession(realEngine, cfg(cwd, { extensionPaths: [C5_EXT] }));
|
|
458
|
+
try {
|
|
459
|
+
const rawA = '/gatewaynext first exact text ';
|
|
460
|
+
const rawB = '/gatewaynext second exact text ';
|
|
461
|
+
for (const raw of [rawA, rawB]) {
|
|
462
|
+
await promptWithAdvertisedCommandInvocation(session, raw, () => session.prompt(raw, {}));
|
|
463
|
+
}
|
|
464
|
+
const pending = session._pendingNextTurnMessages;
|
|
465
|
+
assert.deepEqual(pending.map((queued) => ({
|
|
466
|
+
content: queued.content,
|
|
467
|
+
details: queued.details,
|
|
468
|
+
invocation: queued.crouterAdvertisedCommandInvocation,
|
|
469
|
+
})), [
|
|
470
|
+
{
|
|
471
|
+
content: [],
|
|
472
|
+
details: 'extension-details',
|
|
473
|
+
invocation: { raw: rawA },
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
content: [],
|
|
477
|
+
details: 'extension-details',
|
|
478
|
+
invocation: { raw: rawB },
|
|
479
|
+
},
|
|
480
|
+
]);
|
|
481
|
+
// Simulate a queue policy draining B before A. Payload equality cannot
|
|
482
|
+
// identify them; the app-message metadata must bind each persistence.
|
|
483
|
+
for (const queued of [pending[1], pending[0]]) {
|
|
484
|
+
prepareAdvertisedCommandPersistence(session, queued);
|
|
485
|
+
session.sessionManager.appendCustomMessageEntry(queued.customType, queued.content, queued.display, queued.details);
|
|
486
|
+
}
|
|
487
|
+
const restored = session.sessionManager
|
|
488
|
+
.buildSessionContext()
|
|
489
|
+
.messages.slice(-2);
|
|
490
|
+
assert.deepEqual(restored.map((message) => ({
|
|
491
|
+
details: message.details,
|
|
492
|
+
invocation: message.crouterAdvertisedCommandInvocation,
|
|
493
|
+
})), [
|
|
494
|
+
{ details: 'extension-details', invocation: { raw: rawB } },
|
|
495
|
+
{ details: 'extension-details', invocation: { raw: rawA } },
|
|
496
|
+
]);
|
|
497
|
+
}
|
|
498
|
+
finally {
|
|
499
|
+
session.dispose();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
finally {
|
|
503
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
504
|
+
}
|
|
505
|
+
});
|
|
406
506
|
test('C5 — chooseGuidanceDeliveryMode truth table (pure)', () => {
|
|
407
507
|
// idle prompt (routeCall='prompt', not streaming) -> guidance precedes the
|
|
408
508
|
// human call, in the SAME turn.
|
package/dist/core/bootstrap.js
CHANGED
|
@@ -14,6 +14,12 @@ function shouldSkipForArgv(argv) {
|
|
|
14
14
|
const sub = argv[2];
|
|
15
15
|
if (sub === undefined)
|
|
16
16
|
return true;
|
|
17
|
+
// `sys migrate` converges state that every other read already depends on:
|
|
18
|
+
// resolving a scope here would read the selected profile through the current
|
|
19
|
+
// reader (blind to a pre-migration manifest) and could scaffold a cwd scope
|
|
20
|
+
// the migration then has to treat as real. It owns its own discovery.
|
|
21
|
+
if (sub === 'sys' && argv[3] === 'migrate')
|
|
22
|
+
return true;
|
|
17
23
|
return SKIP_SUBCOMMANDS.has(sub);
|
|
18
24
|
}
|
|
19
25
|
export function ensureProjectScope(argv) {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { execFileSync } from 'node:child_process';
|
|
11
11
|
import { resolve } from 'node:path';
|
|
12
12
|
import { dashboardRowsAllFromSource, enrichRowsFromSource, loadPreviewFromSource, renderForestFromSource } from '../render-source.js';
|
|
13
|
+
import { fullName } from '../labels.js';
|
|
13
14
|
import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress, } from '../../tui/terminal.js';
|
|
14
15
|
import { buildTree, flatten, pruneNode, TABS } from './model.js';
|
|
15
16
|
import { loadPins, savePins } from './pins.js';
|
|
@@ -483,8 +484,10 @@ export async function runBrowse(opts) {
|
|
|
483
484
|
/* best-effort — a rename failure never wedges the picker */
|
|
484
485
|
}
|
|
485
486
|
const n = tree.nodes.get(id);
|
|
486
|
-
if (n !== undefined)
|
|
487
|
-
n.row.
|
|
487
|
+
if (n !== undefined) {
|
|
488
|
+
n.row.handle = name;
|
|
489
|
+
n.row.name = fullName({ name, kind: n.row.kind, description: n.row.description });
|
|
490
|
+
}
|
|
488
491
|
}
|
|
489
492
|
recompute(state.renameTarget ?? undefined);
|
|
490
493
|
scheduleFlush();
|
|
@@ -70,11 +70,8 @@ export interface SnippetLine {
|
|
|
70
70
|
text: string;
|
|
71
71
|
hi: Set<number>;
|
|
72
72
|
}
|
|
73
|
-
/**
|
|
74
|
-
*
|
|
75
|
-
* scattered subsequence indices, else empty (empty query). This is a different,
|
|
76
|
-
* stricter model than the subsequence super-search on purpose — a contiguous span
|
|
77
|
-
* is what reads as a highlight. */
|
|
73
|
+
/** The literal case-insensitive substring span in a conversation preview.
|
|
74
|
+
* Scattered letters are not a message match and never receive a highlight. */
|
|
78
75
|
export declare function highlightIndices(query: string, text: string): Set<number>;
|
|
79
76
|
/**
|
|
80
77
|
* Build the preview snippet for `text` under the live `query`: up to `maxLines`
|
|
@@ -91,17 +88,27 @@ export declare function previewSnippet(query: string, text: string, width: numbe
|
|
|
91
88
|
* windowed in the preview, so search matches a prompt from ANYWHERE in the
|
|
92
89
|
* conversation, not just the first one. */
|
|
93
90
|
export declare function promptText(row: DashboardRow): string;
|
|
94
|
-
/** Does this row match the live query?
|
|
95
|
-
*
|
|
96
|
-
* in the conversation (`promptText`). Empty query matches everything. */
|
|
91
|
+
/** Does this row match the live query? Empty query matches everything; a
|
|
92
|
+
* non-empty query uses the same fields and match tiers as relevance ranking. */
|
|
97
93
|
export declare function queryMatch(query: string, row: DashboardRow): boolean;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
|
|
104
|
-
|
|
94
|
+
export type MatchTier = 0 | 1 | 2 | 3;
|
|
95
|
+
export interface FieldMatch {
|
|
96
|
+
/** 3 = bounded word/phrase, 2 = contiguous partial, 1 = subsequence. */
|
|
97
|
+
tier: MatchTier;
|
|
98
|
+
/** Tie-break quality within one tier. */
|
|
99
|
+
quality: number;
|
|
100
|
+
}
|
|
101
|
+
export interface RelevanceScore {
|
|
102
|
+
tier: MatchTier;
|
|
103
|
+
strength: number;
|
|
104
|
+
}
|
|
105
|
+
/** One field's best match. Match class is deliberately separate from quality:
|
|
106
|
+
* a bounded literal always outranks a contiguous partial, which always outranks
|
|
107
|
+
* a scattered subsequence, regardless of field weights. */
|
|
108
|
+
export declare function fieldMatch(query: string, text: string): FieldMatch;
|
|
109
|
+
/** Rank a row by its strongest match class, then by weighted evidence only from
|
|
110
|
+
* that class. Weaker classes cannot add up to beat one stronger literal match. */
|
|
111
|
+
export declare function scoreRow(query: string, row: DashboardRow): RelevanceScore;
|
|
105
112
|
/** The "most recent message" signal for the attention sort: the pi session-file
|
|
106
113
|
* mtime (ms) the snapshot stamps on each row, falling back to the `created`
|
|
107
114
|
* birth timestamp when no session file exists. Larger = more recent.
|
|
@@ -111,6 +118,9 @@ export declare function scoreRow(query: string, row: DashboardRow): number;
|
|
|
111
118
|
* update this single accessor. `created` is ISO 8601, so its epoch-ms is a
|
|
112
119
|
* monotonic stand-in within the same tier. */
|
|
113
120
|
export declare function attentionMtime(row: DashboardRow): number;
|
|
121
|
+
/** Up to a 10% relevance lift for recent conversation activity, halving after a
|
|
122
|
+
* week. Match tier remains separate, so freshness never promotes fuzzy over literal. */
|
|
123
|
+
export declare function relevanceFreshness(row: DashboardRow, now: number): number;
|
|
114
124
|
/** Attention tier (lower = higher priority, shown first):
|
|
115
125
|
* T0 — attached AND streaming (`viewed && streaming`)
|
|
116
126
|
* T1 — attached, not streaming (`viewed`)
|
|
@@ -160,24 +160,18 @@ export function matchIndices(query, text) {
|
|
|
160
160
|
}
|
|
161
161
|
/** Chars of leading context kept before the match start when windowing. */
|
|
162
162
|
const SNIPPET_LEAD = 12;
|
|
163
|
-
/**
|
|
164
|
-
*
|
|
165
|
-
* scattered subsequence indices, else empty (empty query). This is a different,
|
|
166
|
-
* stricter model than the subsequence super-search on purpose — a contiguous span
|
|
167
|
-
* is what reads as a highlight. */
|
|
163
|
+
/** The literal case-insensitive substring span in a conversation preview.
|
|
164
|
+
* Scattered letters are not a message match and never receive a highlight. */
|
|
168
165
|
export function highlightIndices(query, text) {
|
|
169
166
|
if (query === '')
|
|
170
167
|
return new Set();
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return out;
|
|
179
|
-
}
|
|
180
|
-
return matchIndices(query, text);
|
|
168
|
+
const sub = text.toLowerCase().indexOf(query.toLowerCase());
|
|
169
|
+
if (sub < 0)
|
|
170
|
+
return new Set();
|
|
171
|
+
const out = new Set();
|
|
172
|
+
for (let i = sub; i < sub + query.length; i++)
|
|
173
|
+
out.add(i);
|
|
174
|
+
return out;
|
|
181
175
|
}
|
|
182
176
|
/** Greedy word-wrap of single-spaced `s` (caller normalizes whitespace) to `width`
|
|
183
177
|
* cols × `maxLines` lines, returning each line's text AND its start offset in `s`.
|
|
@@ -233,17 +227,7 @@ export function previewSnippet(query, text, width, maxLines) {
|
|
|
233
227
|
return [];
|
|
234
228
|
// 1. Locate the match in the normalized text + choose a window start that keeps
|
|
235
229
|
// a little leading context, snapped to a word boundary.
|
|
236
|
-
|
|
237
|
-
if (query !== '') {
|
|
238
|
-
const sub = norm.toLowerCase().indexOf(query.toLowerCase());
|
|
239
|
-
if (sub >= 0)
|
|
240
|
-
matchStart = sub;
|
|
241
|
-
else {
|
|
242
|
-
const ix = matchIndices(query, norm);
|
|
243
|
-
if (ix.size > 0)
|
|
244
|
-
matchStart = Math.min(...ix);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
230
|
+
const matchStart = query === '' ? -1 : norm.toLowerCase().indexOf(query.toLowerCase());
|
|
247
231
|
let windowStart = 0;
|
|
248
232
|
if (matchStart > SNIPPET_LEAD) {
|
|
249
233
|
let s = matchStart - SNIPPET_LEAD;
|
|
@@ -274,52 +258,69 @@ function shortId(id) {
|
|
|
274
258
|
export function promptText(row) {
|
|
275
259
|
return row.prompts ?? row.goal ?? '';
|
|
276
260
|
}
|
|
277
|
-
/** Does this row match the live query?
|
|
278
|
-
*
|
|
279
|
-
* in the conversation (`promptText`). Empty query matches everything. */
|
|
261
|
+
/** Does this row match the live query? Empty query matches everything; a
|
|
262
|
+
* non-empty query uses the same fields and match tiers as relevance ranking. */
|
|
280
263
|
export function queryMatch(query, row) {
|
|
281
|
-
|
|
282
|
-
return true;
|
|
283
|
-
const prompt = promptText(row);
|
|
284
|
-
return (fuzzyMatch(query, row.name) ||
|
|
285
|
-
fuzzyMatch(query, row.kind) ||
|
|
286
|
-
fuzzyMatch(query, shortId(row.node_id)) ||
|
|
287
|
-
(prompt !== '' && fuzzyMatch(query, prompt)));
|
|
264
|
+
return query === '' || scoreRow(query, row).tier > 0;
|
|
288
265
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
266
|
+
const NO_MATCH = { tier: 0, quality: 0 };
|
|
267
|
+
const WORD_CHAR = /[\p{L}\p{N}]/u;
|
|
268
|
+
function isWordChar(char) {
|
|
269
|
+
return char !== undefined && WORD_CHAR.test(char);
|
|
270
|
+
}
|
|
271
|
+
/** One field's best match. Match class is deliberately separate from quality:
|
|
272
|
+
* a bounded literal always outranks a contiguous partial, which always outranks
|
|
273
|
+
* a scattered subsequence, regardless of field weights. */
|
|
274
|
+
export function fieldMatch(query, text) {
|
|
296
275
|
if (query === '' || text === '')
|
|
297
|
-
return
|
|
276
|
+
return NO_MATCH;
|
|
298
277
|
const q = query.toLowerCase();
|
|
299
278
|
const t = text.toLowerCase();
|
|
300
|
-
const
|
|
301
|
-
if (
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
279
|
+
const firstIdx = t.indexOf(q);
|
|
280
|
+
if (firstIdx >= 0) {
|
|
281
|
+
for (let idx = firstIdx; idx >= 0; idx = t.indexOf(q, idx + 1)) {
|
|
282
|
+
const end = idx + q.length;
|
|
283
|
+
const leftBounded = !isWordChar(q[0]) || !isWordChar(t[idx - 1]);
|
|
284
|
+
const rightBounded = !isWordChar(q[q.length - 1]) || !isWordChar(t[end]);
|
|
285
|
+
if (leftBounded && rightBounded)
|
|
286
|
+
return { tier: 3, quality: t === q ? 1 : 0.95 };
|
|
287
|
+
}
|
|
288
|
+
const position = Math.min(0.2, firstIdx / 400);
|
|
289
|
+
return { tier: 2, quality: (firstIdx === 0 ? 1 : 0.8) - position };
|
|
307
290
|
}
|
|
308
|
-
|
|
291
|
+
const indices = [...matchIndices(q, t)];
|
|
292
|
+
if (indices.length === 0)
|
|
293
|
+
return NO_MATCH;
|
|
294
|
+
const span = indices[indices.length - 1] - indices[0] + 1;
|
|
295
|
+
return { tier: 1, quality: q.length / span };
|
|
296
|
+
}
|
|
297
|
+
/** Explicit handles and user prompts are primary. The generated description is
|
|
298
|
+
* searchable but lower-weight; assistant replies are preview-only. */
|
|
299
|
+
const FIELD_WEIGHTS = { handle: 4, prompt: 4, kind: 2, description: 1.5, id: 1 };
|
|
300
|
+
function explicitHandle(row) {
|
|
301
|
+
const handle = row.handle ?? row.name;
|
|
302
|
+
return handle === row.kind ? '' : handle;
|
|
309
303
|
}
|
|
310
|
-
/**
|
|
311
|
-
*
|
|
312
|
-
const FIELD_WEIGHTS = { name: 4, kind: 2, id: 1, prompt: 1.5 };
|
|
313
|
-
/** Weighted relevance of a row to the query across all searched fields. 0 means
|
|
314
|
-
* no field matched (excluded from relevance results, same as `queryMatch`). */
|
|
304
|
+
/** Rank a row by its strongest match class, then by weighted evidence only from
|
|
305
|
+
* that class. Weaker classes cannot add up to beat one stronger literal match. */
|
|
315
306
|
export function scoreRow(query, row) {
|
|
316
307
|
if (query === '')
|
|
317
|
-
return 0;
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
308
|
+
return { tier: 0, strength: 0 };
|
|
309
|
+
const fields = [
|
|
310
|
+
{ text: explicitHandle(row), weight: FIELD_WEIGHTS.handle, minimumTier: 1 },
|
|
311
|
+
// Scattered subsequences in long messages are random noise, not matches.
|
|
312
|
+
{ text: promptText(row), weight: FIELD_WEIGHTS.prompt, minimumTier: 2 },
|
|
313
|
+
{ text: row.kind, weight: FIELD_WEIGHTS.kind, minimumTier: 1 },
|
|
314
|
+
{ text: row.description ?? '', weight: FIELD_WEIGHTS.description, minimumTier: 1 },
|
|
315
|
+
{ text: shortId(row.node_id), weight: FIELD_WEIGHTS.id, minimumTier: 1 },
|
|
316
|
+
];
|
|
317
|
+
const matches = fields.map(({ text, weight, minimumTier }) => {
|
|
318
|
+
const match = fieldMatch(query, text);
|
|
319
|
+
return { match: match.tier >= minimumTier ? match : NO_MATCH, weight };
|
|
320
|
+
});
|
|
321
|
+
const tier = Math.max(...matches.map(({ match }) => match.tier));
|
|
322
|
+
const strength = matches.reduce((sum, { match, weight }) => sum + (match.tier === tier ? match.quality * weight : 0), 0);
|
|
323
|
+
return { tier, strength };
|
|
323
324
|
}
|
|
324
325
|
// ---------------------------------------------------------------------------
|
|
325
326
|
// Attention tiering (default sort)
|
|
@@ -339,6 +340,14 @@ export function attentionMtime(row) {
|
|
|
339
340
|
const t = Date.parse(row.created);
|
|
340
341
|
return Number.isFinite(t) ? t : 0;
|
|
341
342
|
}
|
|
343
|
+
const RELEVANCE_RECENCY_MAX_BOOST = 0.1;
|
|
344
|
+
const RELEVANCE_RECENCY_HALF_LIFE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
345
|
+
/** Up to a 10% relevance lift for recent conversation activity, halving after a
|
|
346
|
+
* week. Match tier remains separate, so freshness never promotes fuzzy over literal. */
|
|
347
|
+
export function relevanceFreshness(row, now) {
|
|
348
|
+
const age = Math.max(0, now - attentionMtime(row));
|
|
349
|
+
return 1 + RELEVANCE_RECENCY_MAX_BOOST / (1 + age / RELEVANCE_RECENCY_HALF_LIFE_MS);
|
|
350
|
+
}
|
|
342
351
|
/** Attention tier (lower = higher priority, shown first):
|
|
343
352
|
* T0 — attached AND streaming (`viewed && streaming`)
|
|
344
353
|
* T1 — attached, not streaming (`viewed`)
|
|
@@ -427,9 +436,21 @@ export function flatten(tree, opts) {
|
|
|
427
436
|
}
|
|
428
437
|
else {
|
|
429
438
|
const score = new Map();
|
|
439
|
+
const rowOf = (id) => tree.nodes.get(id).row;
|
|
440
|
+
const now = Date.now();
|
|
430
441
|
for (const id of ids)
|
|
431
|
-
score.set(id, scoreRow(query,
|
|
432
|
-
ids.sort((a, b) =>
|
|
442
|
+
score.set(id, scoreRow(query, rowOf(id)));
|
|
443
|
+
ids.sort((a, b) => {
|
|
444
|
+
const aScore = score.get(a);
|
|
445
|
+
const bScore = score.get(b);
|
|
446
|
+
const aStrength = aScore.strength * relevanceFreshness(rowOf(a), now);
|
|
447
|
+
const bStrength = bScore.strength * relevanceFreshness(rowOf(b), now);
|
|
448
|
+
return (pinRank(a) - pinRank(b))
|
|
449
|
+
|| (bScore.tier - aScore.tier)
|
|
450
|
+
|| (bStrength - aStrength)
|
|
451
|
+
|| (attentionMtime(rowOf(b)) - attentionMtime(rowOf(a)))
|
|
452
|
+
|| byRecency(a, b);
|
|
453
|
+
});
|
|
433
454
|
}
|
|
434
455
|
return ids.map((id) => ({ id, depth: 0, hasChildren: false, collapsed: false, matched: true }));
|
|
435
456
|
}
|