@crewhaus/compaction-curator 0.1.0

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/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@crewhaus/compaction-curator",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Pillar-2 active context curation — pre-compaction relevance + dedupe pass that lets compaction-autocompact skip the model call when curation alone brings tokens under the trigger threshold",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0"
16
+ },
17
+ "license": "Apache-2.0",
18
+ "author": {
19
+ "name": "Max Meier",
20
+ "email": "max@studiomax.io",
21
+ "url": "https://studiomax.io"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/crewhaus/factory.git",
26
+ "directory": "packages/compaction-curator"
27
+ },
28
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/compaction-curator#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/crewhaus/factory/issues"
31
+ },
32
+ "publishConfig": {
33
+ "access": "restricted"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "README.md",
38
+ "LICENSE",
39
+ "NOTICE"
40
+ ]
41
+ }
@@ -0,0 +1,168 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type EmbedderFn, type Item, curate, dedupeBySimilarity, rankByRelevance } from "./index";
3
+
4
+ /**
5
+ * Tiny canned embedder for deterministic tests: maps each unique input
6
+ * string to a fixed unit vector. Same input → same vector → cosine of 1.
7
+ * Different input → orthogonal vector → cosine of 0.
8
+ */
9
+ function makeCannedEmbedder(): { fn: EmbedderFn; counter: () => number } {
10
+ const cache = new Map<string, ReadonlyArray<number>>();
11
+ let calls = 0;
12
+ const dim = 16;
13
+ const fn: EmbedderFn = async (texts) => {
14
+ calls += 1;
15
+ return texts.map((t) => {
16
+ const cached = cache.get(t);
17
+ if (cached !== undefined) return cached;
18
+ const idx = cache.size % dim;
19
+ const v = new Array(dim).fill(0);
20
+ v[idx] = 1;
21
+ const arr = Object.freeze(v) as ReadonlyArray<number>;
22
+ cache.set(t, arr);
23
+ return arr;
24
+ });
25
+ };
26
+ return { fn, counter: () => calls };
27
+ }
28
+
29
+ describe("curate — happy path", () => {
30
+ test("returns input unchanged when no dedupe + no query", async () => {
31
+ const { fn } = makeCannedEmbedder();
32
+ const items: ReadonlyArray<Item> = [
33
+ { text: "alpha gamma delta epsilon zeta" },
34
+ { text: "beta theta iota kappa lambda" },
35
+ { text: "rho sigma tau upsilon phi" },
36
+ ];
37
+ const result = await curate(items, { embedder: fn });
38
+ expect(result.items.length).toBe(3);
39
+ expect(result.droppedIndices.length).toBe(0);
40
+ expect(result.bytesSaved).toBe(0);
41
+ });
42
+
43
+ test("dedupes when two items embed identically", async () => {
44
+ const embedder: EmbedderFn = async (texts) =>
45
+ texts.map(() => [1, 0, 0, 0] as ReadonlyArray<number>);
46
+ const items: ReadonlyArray<Item> = [
47
+ { text: "alpha gamma delta epsilon zeta one" },
48
+ { text: "alpha gamma delta epsilon zeta two" },
49
+ { text: "alpha gamma delta epsilon zeta three" },
50
+ ];
51
+ const result = await curate(items, { embedder });
52
+ expect(result.items.length).toBe(1);
53
+ expect(result.droppedIndices).toEqual([1, 2]);
54
+ });
55
+
56
+ test("keeps when below dedupe threshold", async () => {
57
+ const { fn } = makeCannedEmbedder();
58
+ const items: ReadonlyArray<Item> = [
59
+ { text: "alpha gamma delta epsilon zeta" },
60
+ { text: "beta theta iota kappa lambda" },
61
+ ];
62
+ const result = await curate(items, { embedder: fn, dedupeThreshold: 0.99 });
63
+ expect(result.items.length).toBe(2);
64
+ });
65
+
66
+ test("relevance-orders when query supplied", async () => {
67
+ const items: ReadonlyArray<Item> = [
68
+ { text: "completely unrelated chitchat", id: "off-topic" },
69
+ { text: "deep technical content about query", id: "on-topic" },
70
+ ];
71
+ // Make on-topic share the query vector
72
+ const embedder: EmbedderFn = async (texts) => {
73
+ return texts.map((t) => {
74
+ if (t === "deep technical content about query" || t === "search query") {
75
+ return [1, 0, 0] as ReadonlyArray<number>;
76
+ }
77
+ return [0, 1, 0] as ReadonlyArray<number>;
78
+ });
79
+ };
80
+ const result = await curate(items, { embedder, query: "search query" });
81
+ expect(result.items[0]?.id).toBe("on-topic");
82
+ expect(result.items[1]?.id).toBe("off-topic");
83
+ });
84
+
85
+ test("topK trims after relevance reorder", async () => {
86
+ const items: ReadonlyArray<Item> = [
87
+ { text: "irrelevant content one", id: "1" },
88
+ { text: "highly relevant query match", id: "2" },
89
+ { text: "irrelevant content two", id: "3" },
90
+ ];
91
+ const embedder: EmbedderFn = async (texts) =>
92
+ texts.map((t) => {
93
+ if (t === "highly relevant query match" || t === "the search query") {
94
+ return [1, 0] as ReadonlyArray<number>;
95
+ }
96
+ return [0, 1] as ReadonlyArray<number>;
97
+ });
98
+ const result = await curate(items, {
99
+ embedder,
100
+ query: "the search query",
101
+ relevanceTopK: 1,
102
+ });
103
+ expect(result.items.length).toBe(1);
104
+ expect(result.items[0]?.id).toBe("2");
105
+ expect(result.droppedIndices.length).toBe(2);
106
+ });
107
+ });
108
+
109
+ describe("curate — pre-embedded path", () => {
110
+ test("no embedder needed when all items carry embeddings", async () => {
111
+ const items: ReadonlyArray<Item> = [
112
+ { text: "first item with embedding precomputed", embedding: [1, 0] },
113
+ { text: "second item with embedding precomputed", embedding: [0, 1] },
114
+ ];
115
+ const result = await curate(items, {}); // no embedder, no query
116
+ expect(result.items.length).toBe(2);
117
+ });
118
+
119
+ test("throws when items lack embeddings and no embedder is supplied", async () => {
120
+ const items: ReadonlyArray<Item> = [{ text: "needs embedding plenty of words" }];
121
+ await expect(curate(items, {})).rejects.toThrow(/lack pre-computed embeddings/);
122
+ });
123
+ });
124
+
125
+ describe("curate — edge cases", () => {
126
+ test("empty input returns empty result", async () => {
127
+ const result = await curate([], {});
128
+ expect(result.items.length).toBe(0);
129
+ expect(result.bytesSaved).toBe(0);
130
+ });
131
+
132
+ test("bytesSaved reflects dropped item lengths", async () => {
133
+ const embedder: EmbedderFn = async (texts) => texts.map(() => [1, 0] as ReadonlyArray<number>);
134
+ const items: ReadonlyArray<Item> = [
135
+ { text: "kept first kept first kept first" }, // 32 bytes
136
+ { text: "dropped dropped dropped" }, // 23 bytes
137
+ ];
138
+ const result = await curate(items, { embedder });
139
+ expect(result.bytesSaved).toBe(Buffer.byteLength("dropped dropped dropped", "utf8"));
140
+ });
141
+ });
142
+
143
+ describe("dedupeBySimilarity (pure)", () => {
144
+ test("first occurrence wins for duplicates", () => {
145
+ const items: ReadonlyArray<Item> = [{ text: "a" }, { text: "b" }, { text: "c" }];
146
+ const embeddings: ReadonlyArray<ReadonlyArray<number>> = [
147
+ [1, 0],
148
+ [1, 0], // duplicate of #0
149
+ [0, 1],
150
+ ];
151
+ const r = dedupeBySimilarity(items, embeddings, 0.92);
152
+ expect(r.kept).toEqual([0, 2]);
153
+ expect(r.dropped).toEqual([1]);
154
+ });
155
+ });
156
+
157
+ describe("rankByRelevance (pure)", () => {
158
+ test("ranks by descending cosine similarity", async () => {
159
+ const items: ReadonlyArray<Item> = [{ text: "x" }, { text: "y" }];
160
+ const embeddings: ReadonlyArray<ReadonlyArray<number>> = [
161
+ [0, 1],
162
+ [1, 0],
163
+ ];
164
+ const embedder: EmbedderFn = async () => [[1, 0]];
165
+ const order = await rankByRelevance(items, embeddings, "q", embedder);
166
+ expect(order).toEqual([1, 0]);
167
+ });
168
+ });
package/src/index.ts ADDED
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Pillar 2 active context curation — pre-compaction relevance + dedupe.
3
+ *
4
+ * `compaction-autocompact` (model summarize-then-replace) and
5
+ * `compaction-snip` (drop oldest) are *reactive*: they only fire when
6
+ * context is already near its limit, and they cost a model call (autocompact)
7
+ * or accept information loss (snip). Routray's "Stop Wasting Money on AI
8
+ * Context You Don't Need" (Mar 2026) documents 60–80% token reduction
9
+ * from a pre-compaction pass that:
10
+ *
11
+ * 1. Removes semantically duplicate items (embedding cosine ≥ threshold).
12
+ * 2. Re-orders to front-load high-relevance items (since transformer
13
+ * attention favors prompt-start and prompt-end positions).
14
+ *
15
+ * This package implements both as pure functions over a generic `Item`
16
+ * shape (string text + optional embedding). Callers (compaction-autocompact
17
+ * primarily) wire it as an opt-in stage controlled by `spec.compaction.curate`.
18
+ * When curation brings the item count below the model-summarize trigger,
19
+ * the summarization model call is skipped entirely — that's where the
20
+ * cost savings compound on top of token savings.
21
+ *
22
+ * The package does NOT carry an embedder dependency. Callers supply
23
+ * `EmbedderFn` — typically wrapping `@crewhaus/embedder`'s configured
24
+ * provider. This keeps `compaction-curator` light enough to be useful
25
+ * from `target-pipeline` (which already has an embedder) without forcing
26
+ * a heavy import onto `target-cli` (which doesn't).
27
+ *
28
+ * Catalog layer: R6 (extension of §17 compaction primitives, symmetric to
29
+ * `compaction-autocompact` and `compaction-snip`). Recipe:
30
+ * demos/walkthroughs/52-context-curation.md.
31
+ */
32
+ import { CrewhausError } from "@crewhaus/errors";
33
+
34
+ export class CompactionCuratorError extends CrewhausError {
35
+ override readonly name = "CompactionCuratorError";
36
+ constructor(message: string, cause?: unknown) {
37
+ super("config", message, cause);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * The generic shape this package operates on. Sufficient for two backends:
43
+ * - context items: `text` = message body, `embedding` = optional pre-
44
+ * computed vector
45
+ * - RAG-retrieved chunks: `text` = chunk body, `embedding` = the vector
46
+ * already used for retrieval
47
+ */
48
+ export type Item = {
49
+ readonly text: string;
50
+ /** Optional pre-computed embedding. When absent, callers must supply
51
+ * `EmbedderFn` to `curate()`. */
52
+ readonly embedding?: ReadonlyArray<number>;
53
+ /** Optional client-side identifier carried through unchanged. */
54
+ readonly id?: string;
55
+ };
56
+
57
+ /**
58
+ * Async embedder callback. Returns a vector per input string in the same
59
+ * order. Implementations typically batch internally for cost efficiency.
60
+ *
61
+ * Vectors must be normalized (unit length) so cosine reduces to a dot
62
+ * product. Most production embedders return normalized vectors by default;
63
+ * if yours doesn't, normalize before returning here.
64
+ */
65
+ export type EmbedderFn = (
66
+ texts: ReadonlyArray<string>,
67
+ ) => Promise<ReadonlyArray<ReadonlyArray<number>>>;
68
+
69
+ export type CurateOptions = {
70
+ /**
71
+ * Query / goal string used to score relevance. When absent, the curator
72
+ * skips the relevance-reorder pass and only does dedupe.
73
+ */
74
+ readonly query?: string;
75
+ /**
76
+ * Cosine-similarity threshold above which two items are considered
77
+ * duplicates. Default 0.92 — high enough to avoid false-positive
78
+ * collapses on near-but-distinct items (a function definition and its
79
+ * caller share substantial token overlap but should NOT dedupe).
80
+ */
81
+ readonly dedupeThreshold?: number;
82
+ /**
83
+ * Max items to keep after relevance scoring. Items below this rank are
84
+ * dropped entirely. When absent, no items are dropped from the
85
+ * relevance pass — items are only reordered. Set when you want a hard
86
+ * top-K filter.
87
+ */
88
+ readonly relevanceTopK?: number;
89
+ /**
90
+ * Embedder supplied by the caller. Required if any item lacks a
91
+ * pre-computed `embedding` AND either dedupe or relevance reorder is
92
+ * requested. The function-level types let it stay optional for the
93
+ * pre-embedded case (most production callers).
94
+ */
95
+ readonly embedder?: EmbedderFn;
96
+ };
97
+
98
+ export const DEFAULT_DEDUPE_THRESHOLD = 0.92;
99
+
100
+ function cosine(a: ReadonlyArray<number>, b: ReadonlyArray<number>): number {
101
+ if (a.length !== b.length) {
102
+ throw new CompactionCuratorError(`embedding dimension mismatch (${a.length} vs ${b.length})`);
103
+ }
104
+ let dot = 0;
105
+ let na = 0;
106
+ let nb = 0;
107
+ for (let i = 0; i < a.length; i++) {
108
+ const av = a[i] ?? 0;
109
+ const bv = b[i] ?? 0;
110
+ dot += av * bv;
111
+ na += av * av;
112
+ nb += bv * bv;
113
+ }
114
+ if (na === 0 || nb === 0) return 0;
115
+ return dot / Math.sqrt(na * nb);
116
+ }
117
+
118
+ /**
119
+ * Ensure every input item has an embedding. Items already carrying one
120
+ * pass through; items missing one are batched into a single embedder
121
+ * call. Preserves order so the caller's downstream indexing isn't
122
+ * disturbed.
123
+ */
124
+ async function ensureEmbeddings(
125
+ items: ReadonlyArray<Item>,
126
+ embedder: EmbedderFn | undefined,
127
+ ): Promise<ReadonlyArray<ReadonlyArray<number>>> {
128
+ if (items.length === 0) return [];
129
+ const missingIdx: number[] = [];
130
+ const missingTexts: string[] = [];
131
+ for (let i = 0; i < items.length; i++) {
132
+ const item = items[i];
133
+ if (item !== undefined && item.embedding === undefined) {
134
+ missingIdx.push(i);
135
+ missingTexts.push(item.text);
136
+ }
137
+ }
138
+ if (missingIdx.length > 0 && embedder === undefined) {
139
+ throw new CompactionCuratorError(
140
+ `${missingIdx.length} item(s) lack pre-computed embeddings and no embedder was supplied — pass options.embedder or pre-embed your items`,
141
+ );
142
+ }
143
+ const fresh = embedder !== undefined && missingIdx.length > 0 ? await embedder(missingTexts) : [];
144
+ return items.map((item, i) => {
145
+ if (item.embedding !== undefined) return item.embedding;
146
+ const slot = missingIdx.indexOf(i);
147
+ if (slot === -1) {
148
+ throw new CompactionCuratorError(`internal: missing embedding for item ${i}`);
149
+ }
150
+ const v = fresh[slot];
151
+ if (v === undefined) {
152
+ throw new CompactionCuratorError(`embedder returned no vector for item ${i}`);
153
+ }
154
+ return v;
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Pass 1 — semantic dedupe. For each item, if a *prior* item's embedding
160
+ * has cosine ≥ threshold, drop the current. Order-preserving: the first
161
+ * occurrence wins (keeps the head of the conversation, which carries
162
+ * goal-setting context).
163
+ */
164
+ export function dedupeBySimilarity(
165
+ items: ReadonlyArray<Item>,
166
+ embeddings: ReadonlyArray<ReadonlyArray<number>>,
167
+ threshold: number,
168
+ ): { kept: ReadonlyArray<number>; dropped: ReadonlyArray<number> } {
169
+ const kept: number[] = [];
170
+ const dropped: number[] = [];
171
+ for (let i = 0; i < items.length; i++) {
172
+ let isDuplicate = false;
173
+ for (const k of kept) {
174
+ const e1 = embeddings[i];
175
+ const e2 = embeddings[k];
176
+ if (e1 === undefined || e2 === undefined) continue;
177
+ if (cosine(e1, e2) >= threshold) {
178
+ isDuplicate = true;
179
+ break;
180
+ }
181
+ }
182
+ if (isDuplicate) dropped.push(i);
183
+ else kept.push(i);
184
+ }
185
+ return { kept, dropped };
186
+ }
187
+
188
+ /**
189
+ * Pass 2 — relevance reorder. Compute cosine(query, item) for every
190
+ * surviving item; return indices in descending similarity order.
191
+ * Stable sort: items with identical similarity stay in original order.
192
+ *
193
+ * When `topK` is supplied, items beyond that rank are dropped.
194
+ */
195
+ export async function rankByRelevance(
196
+ items: ReadonlyArray<Item>,
197
+ itemEmbeddings: ReadonlyArray<ReadonlyArray<number>>,
198
+ query: string,
199
+ embedder: EmbedderFn | undefined,
200
+ topK?: number,
201
+ ): Promise<ReadonlyArray<number>> {
202
+ if (items.length === 0) return [];
203
+ if (embedder === undefined) {
204
+ throw new CompactionCuratorError(
205
+ "rankByRelevance requires an embedder to compute the query vector",
206
+ );
207
+ }
208
+ const queryVecs = await embedder([query]);
209
+ const queryVec = queryVecs[0];
210
+ if (queryVec === undefined) {
211
+ throw new CompactionCuratorError("embedder returned no vector for the query");
212
+ }
213
+ const scored: Array<{ idx: number; sim: number; original: number }> = [];
214
+ for (let i = 0; i < items.length; i++) {
215
+ const e = itemEmbeddings[i];
216
+ if (e === undefined) continue;
217
+ scored.push({ idx: i, sim: cosine(queryVec, e), original: i });
218
+ }
219
+ scored.sort((a, b) => {
220
+ if (a.sim !== b.sim) return b.sim - a.sim;
221
+ return a.original - b.original; // stable
222
+ });
223
+ const ordered = scored.map((s) => s.idx);
224
+ if (topK !== undefined && ordered.length > topK) return ordered.slice(0, topK);
225
+ return ordered;
226
+ }
227
+
228
+ export type CurationResult = {
229
+ /** Items in the curated order (deduped + relevance-ranked + topK-trimmed). */
230
+ readonly items: ReadonlyArray<Item>;
231
+ /** Indices into the *original* input array, in output order. */
232
+ readonly originalIndices: ReadonlyArray<number>;
233
+ /** Indices that were dropped (in original order). */
234
+ readonly droppedIndices: ReadonlyArray<number>;
235
+ /** Bytes saved as compared to the original total (text only). */
236
+ readonly bytesSaved: number;
237
+ };
238
+
239
+ /**
240
+ * High-level entry: dedupe then relevance-reorder (when a query is
241
+ * supplied) then top-K filter. The most common call from
242
+ * `compaction-autocompact` is `curate(items, { query, embedder })`.
243
+ */
244
+ export async function curate(
245
+ items: ReadonlyArray<Item>,
246
+ opts: CurateOptions = {},
247
+ ): Promise<CurationResult> {
248
+ if (items.length === 0) {
249
+ return { items: [], originalIndices: [], droppedIndices: [], bytesSaved: 0 };
250
+ }
251
+ const threshold = opts.dedupeThreshold ?? DEFAULT_DEDUPE_THRESHOLD;
252
+ const embeddings = await ensureEmbeddings(items, opts.embedder);
253
+ const { kept, dropped } = dedupeBySimilarity(items, embeddings, threshold);
254
+ let surviving: ReadonlyArray<number> = kept;
255
+ const droppedAll: number[] = [...dropped];
256
+ if (opts.query !== undefined && opts.query.length > 0) {
257
+ const subItems = surviving.map((i) => items[i]).filter((it): it is Item => it !== undefined);
258
+ const subEmbeddings = surviving
259
+ .map((i) => embeddings[i])
260
+ .filter((e): e is ReadonlyArray<number> => e !== undefined);
261
+ const localOrder = await rankByRelevance(
262
+ subItems,
263
+ subEmbeddings,
264
+ opts.query,
265
+ opts.embedder,
266
+ opts.relevanceTopK,
267
+ );
268
+ const localToOriginal: ReadonlyArray<number> = surviving;
269
+ surviving = localOrder
270
+ .map((localIdx) => localToOriginal[localIdx])
271
+ .filter((i): i is number => i !== undefined);
272
+ if (opts.relevanceTopK !== undefined) {
273
+ const survivingSet = new Set(surviving);
274
+ for (const i of kept) if (!survivingSet.has(i)) droppedAll.push(i);
275
+ }
276
+ }
277
+ const outItems = surviving.map((i) => items[i]).filter((it): it is Item => it !== undefined);
278
+ const droppedTotal = droppedAll
279
+ .map((i) => items[i])
280
+ .filter((it): it is Item => it !== undefined)
281
+ .reduce((acc, it) => acc + Buffer.byteLength(it.text, "utf8"), 0);
282
+ return {
283
+ items: outItems,
284
+ originalIndices: surviving,
285
+ droppedIndices: [...droppedAll].sort((a, b) => a - b),
286
+ bytesSaved: droppedTotal,
287
+ };
288
+ }