@nebutra/agent-runtime 0.2.0 → 0.2.2
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/LICENSE +21 -676
- package/README.md +2 -0
- package/dist/adapters/dispatcher-sse.js +1 -0
- package/dist/adapters/index.d.ts +24 -5
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/mcp-catalog.js +1 -0
- package/dist/adapters/prisma-rollout.js +1 -0
- package/dist/chunk-424PT5DM.js +23 -0
- package/dist/chunk-424PT5DM.js.map +1 -0
- package/dist/{chunk-NN7DATXA.js → chunk-4Y25ZTKI.js} +3 -3
- package/dist/chunk-4Y25ZTKI.js.map +1 -0
- package/dist/{chunk-BJBBR3QA.js → chunk-D4YAPLOW.js} +4 -4
- package/dist/chunk-D4YAPLOW.js.map +1 -0
- package/dist/{chunk-ZMYX5VBU.js → chunk-GQZKYWFT.js} +23 -7
- package/dist/chunk-GQZKYWFT.js.map +1 -0
- package/dist/chunk-KCNN4QUQ.js +255 -0
- package/dist/chunk-KCNN4QUQ.js.map +1 -0
- package/dist/{chunk-PGGWSUTM.js → chunk-NI4EDT4T.js} +2 -2
- package/dist/chunk-NI4EDT4T.js.map +1 -0
- package/dist/chunk-Q62VKHIT.js +178 -0
- package/dist/chunk-Q62VKHIT.js.map +1 -0
- package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
- package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
- package/dist/{chunk-5N4644PB.js → chunk-SD2ZJ7XG.js} +4 -4
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +52 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands.js +1 -0
- package/dist/definitions.js +1 -0
- package/dist/dispatcher.js +1 -0
- package/dist/durable-turn.js +3 -2
- package/dist/hook-pipeline.js +1 -0
- package/dist/index.d.ts +8 -85
- package/dist/index.js +249 -134
- package/dist/index.js.map +1 -1
- package/dist/loop.d.ts +2 -2
- package/dist/loop.js +3 -2
- package/dist/mcp-bridge.d.ts +3 -3
- package/dist/mcp-bridge.js +3 -2
- package/dist/model.js +1 -0
- package/dist/orchestration.d.ts +84 -0
- package/dist/orchestration.js +16 -0
- package/dist/orchestration.js.map +1 -0
- package/dist/policy.js +1 -0
- package/dist/protocol.js +1 -0
- package/dist/pulsar.d.ts +78 -0
- package/dist/pulsar.js +17 -0
- package/dist/pulsar.js.map +1 -0
- package/dist/rollout-store-persistent.js +1 -0
- package/dist/rollout.js +1 -0
- package/dist/sandbox.js +2 -1
- package/dist/skills.js +2 -1
- package/dist/subagents.js +1 -0
- package/dist/tools.d.ts +4 -3
- package/dist/tools.js +5 -3
- package/package.json +84 -27
- package/.turbo/turbo-build.log +0 -115
- package/.turbo/turbo-test.log +0 -44
- package/.turbo/turbo-typecheck.log +0 -4
- package/CHANGELOG.md +0 -253
- package/dist/chunk-BJBBR3QA.js.map +0 -1
- package/dist/chunk-NN7DATXA.js.map +0 -1
- package/dist/chunk-PGGWSUTM.js.map +0 -1
- package/dist/chunk-ZMYX5VBU.js.map +0 -1
- package/src/adapters/dispatcher-sse.test.ts +0 -218
- package/src/adapters/dispatcher-sse.ts +0 -222
- package/src/adapters/index.ts +0 -18
- package/src/adapters/mcp-catalog.test.ts +0 -213
- package/src/adapters/mcp-catalog.ts +0 -188
- package/src/adapters/prisma-rollout.test.ts +0 -153
- package/src/adapters/prisma-rollout.ts +0 -104
- package/src/agent-runtime.test.ts +0 -176
- package/src/artifact-stream.test.ts +0 -330
- package/src/artifact-stream.ts +0 -453
- package/src/channel-gateway.test.ts +0 -432
- package/src/channel-gateway.ts +0 -357
- package/src/code-review.test.ts +0 -501
- package/src/code-review.ts +0 -495
- package/src/command-suggestions.test.ts +0 -251
- package/src/command-suggestions.ts +0 -338
- package/src/commands.test.ts +0 -184
- package/src/commands.ts +0 -140
- package/src/commit-message.test.ts +0 -249
- package/src/commit-message.ts +0 -180
- package/src/context-compaction.test.ts +0 -522
- package/src/context-compaction.ts +0 -434
- package/src/definitions.test.ts +0 -78
- package/src/definitions.ts +0 -190
- package/src/deployment-status.test.ts +0 -215
- package/src/deployment-status.ts +0 -227
- package/src/design-context.test.ts +0 -195
- package/src/design-context.ts +0 -198
- package/src/dispatcher.test.ts +0 -234
- package/src/dispatcher.ts +0 -189
- package/src/durable-turn.test.ts +0 -209
- package/src/durable-turn.ts +0 -135
- package/src/edit-planner.test.ts +0 -204
- package/src/edit-planner.ts +0 -325
- package/src/fuzzy-match.test.ts +0 -311
- package/src/fuzzy-match.ts +0 -444
- package/src/hook-pipeline.test.ts +0 -279
- package/src/hook-pipeline.ts +0 -373
- package/src/inbound-admission.test.ts +0 -394
- package/src/inbound-admission.ts +0 -246
- package/src/index.ts +0 -47
- package/src/loop.test.ts +0 -161
- package/src/loop.ts +0 -211
- package/src/mcp-bridge.test.ts +0 -165
- package/src/mcp-bridge.ts +0 -76
- package/src/memory-provider.test.ts +0 -232
- package/src/memory-provider.ts +0 -257
- package/src/model.ts +0 -168
- package/src/permission-ruleset.test.ts +0 -301
- package/src/permission-ruleset.ts +0 -200
- package/src/policy.ts +0 -151
- package/src/project-repo.test.ts +0 -232
- package/src/project-repo.ts +0 -311
- package/src/protocol.ts +0 -159
- package/src/rollout-store-persistent.test.ts +0 -217
- package/src/rollout-store-persistent.ts +0 -166
- package/src/rollout.ts +0 -150
- package/src/sandbox.ts +0 -113
- package/src/session-share.test.ts +0 -360
- package/src/session-share.ts +0 -310
- package/src/skill-distillation.test.ts +0 -177
- package/src/skill-distillation.ts +0 -369
- package/src/skills.test.ts +0 -277
- package/src/skills.ts +0 -255
- package/src/subagents.test.ts +0 -290
- package/src/subagents.ts +0 -332
- package/src/tools.ts +0 -126
- package/src/workbench.test.ts +0 -0
- package/src/workbench.ts +0 -0
- package/tsconfig.json +0 -12
- package/tsup.config.ts +0 -33
- /package/dist/{chunk-5N4644PB.js.map → chunk-SD2ZJ7XG.js.map} +0 -0
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
classifyMatch,
|
|
4
|
-
dedupeByText,
|
|
5
|
-
type FuzzyMatchFn,
|
|
6
|
-
historyCandidates,
|
|
7
|
-
InMemorySuggestionHistoryStore,
|
|
8
|
-
rankSuggestions,
|
|
9
|
-
recordHistory,
|
|
10
|
-
type SuggestionItem,
|
|
11
|
-
} from "./command-suggestions.js";
|
|
12
|
-
|
|
13
|
-
const T = "tenant_a";
|
|
14
|
-
|
|
15
|
-
/** Deterministic fake matcher: subsequence with fixed score, recorded indices. */
|
|
16
|
-
const fakeFuzzy: FuzzyMatchFn = (text, query) => {
|
|
17
|
-
if (query === "") return null;
|
|
18
|
-
const lowerText = text.toLowerCase();
|
|
19
|
-
const lowerQuery = query.toLowerCase();
|
|
20
|
-
const indices: number[] = [];
|
|
21
|
-
let cursor = 0;
|
|
22
|
-
for (const ch of lowerQuery) {
|
|
23
|
-
const found = lowerText.indexOf(ch, cursor);
|
|
24
|
-
if (found === -1) return null;
|
|
25
|
-
indices.push(found);
|
|
26
|
-
cursor = found + 1;
|
|
27
|
-
}
|
|
28
|
-
return { score: 10, indices };
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
/** Matcher that returns a huge score, used to prove exact/prefix banding wins. */
|
|
32
|
-
const hugeScoreFuzzy: FuzzyMatchFn = (text, query) => {
|
|
33
|
-
if (query === "") return null;
|
|
34
|
-
if (text.toLowerCase().includes(query.toLowerCase())) {
|
|
35
|
-
return { score: 1_000_000, indices: [0] };
|
|
36
|
-
}
|
|
37
|
-
return null;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const neverFuzzy: FuzzyMatchFn = () => null;
|
|
41
|
-
|
|
42
|
-
function item(id: string, text: string, overrides: Partial<SuggestionItem> = {}): SuggestionItem {
|
|
43
|
-
return {
|
|
44
|
-
id,
|
|
45
|
-
text,
|
|
46
|
-
type: overrides.type ?? "completion",
|
|
47
|
-
isHistory: overrides.isHistory ?? false,
|
|
48
|
-
detail: overrides.detail,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
describe("classifyMatch", () => {
|
|
53
|
-
it("returns exact for case-insensitive full equality with the highest band", () => {
|
|
54
|
-
const r = classifyMatch("Deploy", "deploy", fakeFuzzy);
|
|
55
|
-
expect(r.matchType).toBe("exact");
|
|
56
|
-
expect(r.score).toBeGreaterThan(0);
|
|
57
|
-
expect(r.indices).toEqual([0, 1, 2, 3, 4, 5]);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("returns prefix when text starts with query (ci) but is not equal", () => {
|
|
61
|
-
const r = classifyMatch("deploy-prod", "deploy", fakeFuzzy);
|
|
62
|
-
expect(r.matchType).toBe("prefix");
|
|
63
|
-
expect(r.indices).toEqual([0, 1, 2, 3, 4, 5]);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("delegates to injected fuzzy when neither exact nor prefix", () => {
|
|
67
|
-
const r = classifyMatch("git deploy", "dpl", fakeFuzzy);
|
|
68
|
-
expect(r.matchType).toBe("fuzzy");
|
|
69
|
-
expect(r.score).toBe(10);
|
|
70
|
-
expect(r.indices.length).toBe(3);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("returns none when fuzzy returns null", () => {
|
|
74
|
-
const r = classifyMatch("git status", "zzz", neverFuzzy);
|
|
75
|
-
expect(r.matchType).toBe("none");
|
|
76
|
-
expect(r.score).toBe(0);
|
|
77
|
-
expect(r.indices).toEqual([]);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("bands exact > prefix > fuzzy regardless of fuzzy magnitude", () => {
|
|
81
|
-
const exact = classifyMatch("deploy", "deploy", hugeScoreFuzzy);
|
|
82
|
-
const prefix = classifyMatch("deploy-prod", "deploy", hugeScoreFuzzy);
|
|
83
|
-
const fuzzy = classifyMatch("xx-deploy", "deploy", hugeScoreFuzzy);
|
|
84
|
-
expect(fuzzy.matchType).toBe("fuzzy");
|
|
85
|
-
expect(fuzzy.score).toBe(1_000_000);
|
|
86
|
-
expect(prefix.score).toBeGreaterThan(fuzzy.score);
|
|
87
|
-
expect(exact.score).toBeGreaterThan(prefix.score);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("empty query yields none/0/[]", () => {
|
|
91
|
-
const r = classifyMatch("anything", "", fakeFuzzy);
|
|
92
|
-
expect(r.matchType).toBe("none");
|
|
93
|
-
expect(r.score).toBe(0);
|
|
94
|
-
expect(r.indices).toEqual([]);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe("rankSuggestions", () => {
|
|
99
|
-
it("throws on empty tenantId (fail-closed)", () => {
|
|
100
|
-
expect(() => rankSuggestions("", "q", [], fakeFuzzy)).toThrow();
|
|
101
|
-
expect(() => rankSuggestions(" ", "q", [], fakeFuzzy)).toThrow();
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("drops 'none' matches when query is non-empty", () => {
|
|
105
|
-
const candidates = [item("1", "deploy"), item("2", "totally-unrelated")];
|
|
106
|
-
const out = rankSuggestions(T, "deploy", candidates, neverFuzzy);
|
|
107
|
-
expect(out.results.map((r) => r.item.id)).toEqual(["1"]);
|
|
108
|
-
expect(out.query).toBe("deploy");
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("keeps all candidates when query is empty", () => {
|
|
112
|
-
const candidates = [item("1", "alpha"), item("2", "beta")];
|
|
113
|
-
const out = rankSuggestions(T, "", candidates, neverFuzzy);
|
|
114
|
-
expect(out.results.length).toBe(2);
|
|
115
|
-
expect(out.results.every((r) => r.matchType === "none")).toBe(true);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("applies history boost when scores tie", () => {
|
|
119
|
-
const candidates = [
|
|
120
|
-
item("plain", "deploy app", { isHistory: false }),
|
|
121
|
-
item("hist", "deploy app", { isHistory: true }),
|
|
122
|
-
];
|
|
123
|
-
const out = rankSuggestions(T, "deploy", candidates, fakeFuzzy);
|
|
124
|
-
expect(out.results[0]?.item.id).toBe("hist");
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("shorter text wins when score and history tie", () => {
|
|
128
|
-
const candidates = [
|
|
129
|
-
item("long", "deployment longer", { isHistory: true }),
|
|
130
|
-
item("short", "deploy app", { isHistory: true }),
|
|
131
|
-
];
|
|
132
|
-
const out = rankSuggestions(T, "deploy", candidates, fakeFuzzy);
|
|
133
|
-
expect(out.results[0]?.item.id).toBe("short");
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("is stable on full ties (input order preserved)", () => {
|
|
137
|
-
const candidates = [
|
|
138
|
-
item("a", "same text", { isHistory: false }),
|
|
139
|
-
item("b", "same text", { isHistory: false }),
|
|
140
|
-
item("c", "same text", { isHistory: false }),
|
|
141
|
-
];
|
|
142
|
-
const out = rankSuggestions(T, "same text", candidates, fakeFuzzy);
|
|
143
|
-
expect(out.results.map((r) => r.item.id)).toEqual(["a", "b", "c"]);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("caps to opts.limit and defaults to 50", () => {
|
|
147
|
-
const many = Array.from({ length: 80 }, (_, i) => item(String(i), `deploy ${i}`));
|
|
148
|
-
const capped = rankSuggestions(T, "deploy", many, fakeFuzzy, { limit: 5 });
|
|
149
|
-
expect(capped.results.length).toBe(5);
|
|
150
|
-
const def = rankSuggestions(T, "deploy", many, fakeFuzzy);
|
|
151
|
-
expect(def.results.length).toBe(50);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("orders exact above prefix above fuzzy", () => {
|
|
155
|
-
const candidates = [
|
|
156
|
-
item("fuzzy", "xx-deploy"),
|
|
157
|
-
item("exact", "deploy"),
|
|
158
|
-
item("prefix", "deploy-prod"),
|
|
159
|
-
];
|
|
160
|
-
const out = rankSuggestions(T, "deploy", candidates, fakeFuzzy);
|
|
161
|
-
expect(out.results.map((r) => r.item.id)).toEqual(["exact", "prefix", "fuzzy"]);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("does not mutate the input candidates array", () => {
|
|
165
|
-
const candidates = [item("2", "beta"), item("1", "alpha")];
|
|
166
|
-
const snapshot = candidates.map((c) => ({ ...c }));
|
|
167
|
-
rankSuggestions(T, "a", candidates, fakeFuzzy);
|
|
168
|
-
expect(candidates).toEqual(snapshot);
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
describe("dedupeByText", () => {
|
|
173
|
-
it("keeps first occurrence per normalized text", () => {
|
|
174
|
-
const out = dedupeByText([
|
|
175
|
-
item("1", "Deploy"),
|
|
176
|
-
item("2", " deploy "),
|
|
177
|
-
item("3", "DEPLOY"),
|
|
178
|
-
item("4", "other"),
|
|
179
|
-
]);
|
|
180
|
-
expect(out.map((c) => c.id)).toEqual(["1", "4"]);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it("prefers the history item even if it comes later", () => {
|
|
184
|
-
const out = dedupeByText([
|
|
185
|
-
item("plain", "deploy", { isHistory: false }),
|
|
186
|
-
item("hist", "DEPLOY", { isHistory: true }),
|
|
187
|
-
]);
|
|
188
|
-
expect(out.map((c) => c.id)).toEqual(["hist"]);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it("is otherwise stable", () => {
|
|
192
|
-
const out = dedupeByText([item("a", "one"), item("b", "two"), item("c", "three")]);
|
|
193
|
-
expect(out.map((c) => c.id)).toEqual(["a", "b", "c"]);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it("does not mutate input", () => {
|
|
197
|
-
const input = [item("1", "x"), item("2", "x")];
|
|
198
|
-
const snapshot = input.map((c) => ({ ...c }));
|
|
199
|
-
dedupeByText(input);
|
|
200
|
-
expect(input).toEqual(snapshot);
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
describe("InMemorySuggestionHistoryStore", () => {
|
|
205
|
-
it("records and reads back most-recent-first", () => {
|
|
206
|
-
const store = new InMemorySuggestionHistoryStore();
|
|
207
|
-
recordHistory(store, T, "first cmd");
|
|
208
|
-
recordHistory(store, T, "second cmd");
|
|
209
|
-
const out = historyCandidates(store, T);
|
|
210
|
-
expect(out.map((c) => c.text)).toEqual(["second cmd", "first cmd"]);
|
|
211
|
-
expect(out.every((c) => c.isHistory && c.type === "history")).toBe(true);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it("dedupes on re-record and moves the entry to the front", () => {
|
|
215
|
-
const store = new InMemorySuggestionHistoryStore();
|
|
216
|
-
recordHistory(store, T, "a");
|
|
217
|
-
recordHistory(store, T, "b");
|
|
218
|
-
recordHistory(store, T, "a");
|
|
219
|
-
expect(historyCandidates(store, T).map((c) => c.text)).toEqual(["a", "b"]);
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it("evicts oldest beyond the ring cap", () => {
|
|
223
|
-
const store = new InMemorySuggestionHistoryStore(3);
|
|
224
|
-
recordHistory(store, T, "1");
|
|
225
|
-
recordHistory(store, T, "2");
|
|
226
|
-
recordHistory(store, T, "3");
|
|
227
|
-
recordHistory(store, T, "4");
|
|
228
|
-
expect(historyCandidates(store, T).map((c) => c.text)).toEqual(["4", "3", "2"]);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("isolates tenants (cross-tenant reads impossible)", () => {
|
|
232
|
-
const store = new InMemorySuggestionHistoryStore();
|
|
233
|
-
recordHistory(store, "tenant_x", "x-secret");
|
|
234
|
-
recordHistory(store, "tenant_y", "y-secret");
|
|
235
|
-
expect(historyCandidates(store, "tenant_x").map((c) => c.text)).toEqual(["x-secret"]);
|
|
236
|
-
expect(historyCandidates(store, "tenant_y").map((c) => c.text)).toEqual(["y-secret"]);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it("throws on empty tenant for record and read (fail-closed)", () => {
|
|
240
|
-
const store = new InMemorySuggestionHistoryStore();
|
|
241
|
-
expect(() => recordHistory(store, "", "x")).toThrow();
|
|
242
|
-
expect(() => recordHistory(store, " ", "x")).toThrow();
|
|
243
|
-
expect(() => historyCandidates(store, "")).toThrow();
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it("ignores blank text on record", () => {
|
|
247
|
-
const store = new InMemorySuggestionHistoryStore();
|
|
248
|
-
recordHistory(store, T, " ");
|
|
249
|
-
expect(historyCandidates(store, T)).toEqual([]);
|
|
250
|
-
});
|
|
251
|
-
});
|
|
@@ -1,338 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* command-suggestions — pure, deterministic ranked input-suggestion model.
|
|
3
|
-
*
|
|
4
|
-
* A dependency-free re-expression of the ranked command/input suggestion
|
|
5
|
-
* engine found in modern terminals and command palettes. The fuzzy matcher
|
|
6
|
-
* itself lives elsewhere; this module never imports it — callers wire a real
|
|
7
|
-
* matcher through the injected {@link FuzzyMatchFn} port (tests pass a
|
|
8
|
-
* deterministic fake).
|
|
9
|
-
*
|
|
10
|
-
* Invariants:
|
|
11
|
-
* - Pure functions: no I/O, no shared state, no input mutation, no console.
|
|
12
|
-
* - Match banding is contractual: exact > prefix > fuzzy, regardless of the
|
|
13
|
-
* fuzzy matcher's score magnitude.
|
|
14
|
-
* - Tenancy is structural and fails closed: every history operation requires
|
|
15
|
-
* a non-empty tenantId (Zod-validated) and is keyed per tenant, so
|
|
16
|
-
* cross-tenant reads are impossible by construction.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { z } from "zod";
|
|
20
|
-
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
// Types (faithful, neutral)
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
|
|
25
|
-
export type SuggestionType = "history" | "workflow" | "completion" | "ai";
|
|
26
|
-
|
|
27
|
-
export type MatchType = "exact" | "prefix" | "fuzzy" | "none";
|
|
28
|
-
|
|
29
|
-
export interface SuggestionItem {
|
|
30
|
-
readonly id: string;
|
|
31
|
-
readonly text: string;
|
|
32
|
-
readonly type: SuggestionType;
|
|
33
|
-
readonly isHistory: boolean;
|
|
34
|
-
readonly detail?: string | undefined;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface RankedSuggestion {
|
|
38
|
-
readonly item: SuggestionItem;
|
|
39
|
-
readonly score: number;
|
|
40
|
-
readonly matchType: MatchType;
|
|
41
|
-
readonly matchIndices: number[];
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface SuggestionResults {
|
|
45
|
-
readonly query: string;
|
|
46
|
-
readonly results: RankedSuggestion[];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface RankOptions {
|
|
50
|
-
readonly limit?: number | undefined;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Injected fuzzy-match port. The real matcher is wired by the caller; this
|
|
55
|
-
* module declares only the shape it consumes. A `null` return means "no match".
|
|
56
|
-
*/
|
|
57
|
-
export type FuzzyMatchFn = (
|
|
58
|
-
text: string,
|
|
59
|
-
query: string,
|
|
60
|
-
) => { score: number; indices: number[] } | null;
|
|
61
|
-
|
|
62
|
-
export interface MatchClassification {
|
|
63
|
-
readonly matchType: MatchType;
|
|
64
|
-
readonly score: number;
|
|
65
|
-
readonly indices: number[];
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
// Score bands — exact > prefix > fuzzy, independent of fuzzy magnitude
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Fuzzy scores share the lowest band. Prefix and exact are pinned ABOVE any
|
|
74
|
-
* representable fuzzy score so banding is a total order regardless of what the
|
|
75
|
-
* injected matcher returns.
|
|
76
|
-
*/
|
|
77
|
-
const FUZZY_CEILING = Number.MAX_SAFE_INTEGER / 4;
|
|
78
|
-
const PREFIX_SCORE = FUZZY_CEILING * 2;
|
|
79
|
-
const EXACT_SCORE = FUZZY_CEILING * 3;
|
|
80
|
-
|
|
81
|
-
const DEFAULT_LIMIT = 50;
|
|
82
|
-
const DEFAULT_HISTORY_CAP = 500;
|
|
83
|
-
|
|
84
|
-
// ---------------------------------------------------------------------------
|
|
85
|
-
// Boundary validation (fail-closed tenancy)
|
|
86
|
-
// ---------------------------------------------------------------------------
|
|
87
|
-
|
|
88
|
-
const TenantSchema = z
|
|
89
|
-
.string()
|
|
90
|
-
.refine((v) => v.trim() !== "", "tenantId is required (fail-closed)");
|
|
91
|
-
|
|
92
|
-
function assertTenant(tenantId: unknown): string {
|
|
93
|
-
const parsed = TenantSchema.safeParse(tenantId);
|
|
94
|
-
if (!parsed.success) {
|
|
95
|
-
throw new Error("tenantId is required (fail-closed)");
|
|
96
|
-
}
|
|
97
|
-
return parsed.data;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ---------------------------------------------------------------------------
|
|
101
|
-
// classifyMatch
|
|
102
|
-
// ---------------------------------------------------------------------------
|
|
103
|
-
|
|
104
|
-
function fullSpan(length: number): number[] {
|
|
105
|
-
return Array.from({ length }, (_, i) => i);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Classify how `query` matches `text`. Exact (case-insensitive full equality)
|
|
110
|
-
* outranks prefix (case-insensitive starts-with) outranks the injected fuzzy
|
|
111
|
-
* matcher. An empty query is inert: `none`, score 0, no indices.
|
|
112
|
-
*/
|
|
113
|
-
export function classifyMatch(
|
|
114
|
-
text: string,
|
|
115
|
-
query: string,
|
|
116
|
-
fuzzy: FuzzyMatchFn,
|
|
117
|
-
): MatchClassification {
|
|
118
|
-
if (query === "") {
|
|
119
|
-
return { matchType: "none", score: 0, indices: [] };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const lowerText = text.toLowerCase();
|
|
123
|
-
const lowerQuery = query.toLowerCase();
|
|
124
|
-
|
|
125
|
-
if (lowerText === lowerQuery) {
|
|
126
|
-
return {
|
|
127
|
-
matchType: "exact",
|
|
128
|
-
score: EXACT_SCORE,
|
|
129
|
-
indices: fullSpan(text.length),
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (lowerText.startsWith(lowerQuery)) {
|
|
134
|
-
return {
|
|
135
|
-
matchType: "prefix",
|
|
136
|
-
score: PREFIX_SCORE,
|
|
137
|
-
indices: fullSpan(query.length),
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const fuzzyResult = fuzzy(text, query);
|
|
142
|
-
if (fuzzyResult === null) {
|
|
143
|
-
return { matchType: "none", score: 0, indices: [] };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
return {
|
|
147
|
-
matchType: "fuzzy",
|
|
148
|
-
// Clamp into the fuzzy band so an overzealous matcher can never reach the
|
|
149
|
-
// prefix/exact bands.
|
|
150
|
-
score: Math.min(fuzzyResult.score, FUZZY_CEILING),
|
|
151
|
-
indices: [...fuzzyResult.indices],
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
// rankSuggestions
|
|
157
|
-
// ---------------------------------------------------------------------------
|
|
158
|
-
|
|
159
|
-
interface ScoredEntry {
|
|
160
|
-
readonly ranked: RankedSuggestion;
|
|
161
|
-
readonly order: number;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Compare two scored entries. Returns negative when `a` should rank first.
|
|
166
|
-
* Order: score desc → history-first on tie → shorter text → stable input order.
|
|
167
|
-
*/
|
|
168
|
-
function compareEntries(a: ScoredEntry, b: ScoredEntry): number {
|
|
169
|
-
if (a.ranked.score !== b.ranked.score) {
|
|
170
|
-
return b.ranked.score - a.ranked.score;
|
|
171
|
-
}
|
|
172
|
-
const aHist = a.ranked.item.isHistory ? 1 : 0;
|
|
173
|
-
const bHist = b.ranked.item.isHistory ? 1 : 0;
|
|
174
|
-
if (aHist !== bHist) {
|
|
175
|
-
return bHist - aHist;
|
|
176
|
-
}
|
|
177
|
-
const aLen = a.ranked.item.text.length;
|
|
178
|
-
const bLen = b.ranked.item.text.length;
|
|
179
|
-
if (aLen !== bLen) {
|
|
180
|
-
return aLen - bLen;
|
|
181
|
-
}
|
|
182
|
-
return a.order - b.order;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Rank `candidates` against `query` for one tenant. Fails closed on an empty
|
|
187
|
-
* tenant. With a non-empty query, `none` matches are dropped; with an empty
|
|
188
|
-
* query every candidate is kept (inert match) and ordered purely by the
|
|
189
|
-
* tie-breakers. Never mutates `candidates`.
|
|
190
|
-
*/
|
|
191
|
-
export function rankSuggestions(
|
|
192
|
-
tenantId: string,
|
|
193
|
-
query: string,
|
|
194
|
-
candidates: readonly SuggestionItem[],
|
|
195
|
-
fuzzy: FuzzyMatchFn,
|
|
196
|
-
opts?: RankOptions,
|
|
197
|
-
): SuggestionResults {
|
|
198
|
-
assertTenant(tenantId);
|
|
199
|
-
|
|
200
|
-
const limit = opts?.limit !== undefined && opts.limit >= 0 ? opts.limit : DEFAULT_LIMIT;
|
|
201
|
-
const keepNone = query === "";
|
|
202
|
-
|
|
203
|
-
const scored: ScoredEntry[] = [];
|
|
204
|
-
candidates.forEach((item, order) => {
|
|
205
|
-
const c = classifyMatch(item.text, query, fuzzy);
|
|
206
|
-
if (c.matchType === "none" && !keepNone) {
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
scored.push({
|
|
210
|
-
ranked: {
|
|
211
|
-
item,
|
|
212
|
-
score: c.score,
|
|
213
|
-
matchType: c.matchType,
|
|
214
|
-
matchIndices: [...c.indices],
|
|
215
|
-
},
|
|
216
|
-
order,
|
|
217
|
-
});
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
const sorted = [...scored].sort(compareEntries);
|
|
221
|
-
const results = sorted.slice(0, limit).map((e) => e.ranked);
|
|
222
|
-
|
|
223
|
-
return { query, results };
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// ---------------------------------------------------------------------------
|
|
227
|
-
// dedupeByText
|
|
228
|
-
// ---------------------------------------------------------------------------
|
|
229
|
-
|
|
230
|
-
function normalizeText(text: string): string {
|
|
231
|
-
return text.trim().toLowerCase();
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Keep one item per normalized (trimmed, case-insensitive) text. First
|
|
236
|
-
* occurrence wins, EXCEPT a history item supersedes an earlier non-history
|
|
237
|
-
* item that shares the same normalized text. Stable otherwise. Pure.
|
|
238
|
-
*/
|
|
239
|
-
export function dedupeByText(candidates: readonly SuggestionItem[]): SuggestionItem[] {
|
|
240
|
-
const order: string[] = [];
|
|
241
|
-
const chosen = new Map<string, SuggestionItem>();
|
|
242
|
-
|
|
243
|
-
for (const item of candidates) {
|
|
244
|
-
const key = normalizeText(item.text);
|
|
245
|
-
const existing = chosen.get(key);
|
|
246
|
-
if (existing === undefined) {
|
|
247
|
-
chosen.set(key, item);
|
|
248
|
-
order.push(key);
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
if (!existing.isHistory && item.isHistory) {
|
|
252
|
-
// History wins even when it appears later; keep the original slot.
|
|
253
|
-
chosen.set(key, item);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return order.map((key) => {
|
|
258
|
-
const value = chosen.get(key);
|
|
259
|
-
if (value === undefined) {
|
|
260
|
-
throw new Error("dedupeByText: invariant violated (missing key)");
|
|
261
|
-
}
|
|
262
|
-
return value;
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// ---------------------------------------------------------------------------
|
|
267
|
-
// Tenant-scoped history store
|
|
268
|
-
// ---------------------------------------------------------------------------
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Tenant-scoped append-with-dedupe history seam. Implementations MUST key by
|
|
272
|
-
* tenant so cross-tenant reads are impossible, MUST keep entries
|
|
273
|
-
* most-recent-first, and MUST bound storage to a ring cap.
|
|
274
|
-
*/
|
|
275
|
-
export interface SuggestionHistoryStore {
|
|
276
|
-
/** Append `text` for `tenantId`, deduped, most-recent-first, ring-bounded. */
|
|
277
|
-
append(tenantId: string, text: string): void;
|
|
278
|
-
/** Most-recent-first texts for `tenantId`. */
|
|
279
|
-
list(tenantId: string): string[];
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* In-memory reference implementation mirroring this package's other stores.
|
|
284
|
-
* Tenancy is structural: `tenantId` is part of the key AND validated on every
|
|
285
|
-
* read/write, so a tenant can never observe another tenant's history.
|
|
286
|
-
*/
|
|
287
|
-
export class InMemorySuggestionHistoryStore implements SuggestionHistoryStore {
|
|
288
|
-
readonly #cap: number;
|
|
289
|
-
readonly #byTenant = new Map<string, string[]>();
|
|
290
|
-
|
|
291
|
-
constructor(cap: number = DEFAULT_HISTORY_CAP) {
|
|
292
|
-
this.#cap = cap > 0 ? cap : DEFAULT_HISTORY_CAP;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
append(tenantId: string, text: string): void {
|
|
296
|
-
const tenant = assertTenant(tenantId);
|
|
297
|
-
const trimmed = text.trim();
|
|
298
|
-
if (trimmed === "") {
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
const prior = this.#byTenant.get(tenant) ?? [];
|
|
302
|
-
const deduped = prior.filter((t) => t !== trimmed);
|
|
303
|
-
const next = [trimmed, ...deduped].slice(0, this.#cap);
|
|
304
|
-
this.#byTenant.set(tenant, next);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
list(tenantId: string): string[] {
|
|
308
|
-
const tenant = assertTenant(tenantId);
|
|
309
|
-
return [...(this.#byTenant.get(tenant) ?? [])];
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Record `text` into the tenant's suggestion history. Fails closed on an empty
|
|
315
|
-
* tenant; blank text is ignored.
|
|
316
|
-
*/
|
|
317
|
-
export function recordHistory(store: SuggestionHistoryStore, tenantId: string, text: string): void {
|
|
318
|
-
assertTenant(tenantId);
|
|
319
|
-
store.append(tenantId, text);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Read the tenant's history as ranked-ready {@link SuggestionItem}s
|
|
324
|
-
* (most-recent-first, flagged `isHistory`). Fails closed on an empty tenant.
|
|
325
|
-
*/
|
|
326
|
-
export function historyCandidates(
|
|
327
|
-
store: SuggestionHistoryStore,
|
|
328
|
-
tenantId: string,
|
|
329
|
-
): SuggestionItem[] {
|
|
330
|
-
assertTenant(tenantId);
|
|
331
|
-
return store.list(tenantId).map((text, index) => ({
|
|
332
|
-
id: `history:${index}:${text}`,
|
|
333
|
-
text,
|
|
334
|
-
type: "history" as const,
|
|
335
|
-
isHistory: true,
|
|
336
|
-
detail: undefined,
|
|
337
|
-
}));
|
|
338
|
-
}
|