@crewhaus/compaction-curator 0.1.3 → 0.1.5

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.
@@ -0,0 +1,123 @@
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
+ export declare class CompactionCuratorError extends CrewhausError {
34
+ readonly name = "CompactionCuratorError";
35
+ constructor(message: string, cause?: unknown);
36
+ }
37
+ /**
38
+ * The generic shape this package operates on. Sufficient for two backends:
39
+ * - context items: `text` = message body, `embedding` = optional pre-
40
+ * computed vector
41
+ * - RAG-retrieved chunks: `text` = chunk body, `embedding` = the vector
42
+ * already used for retrieval
43
+ */
44
+ export type Item = {
45
+ readonly text: string;
46
+ /** Optional pre-computed embedding. When absent, callers must supply
47
+ * `EmbedderFn` to `curate()`. */
48
+ readonly embedding?: ReadonlyArray<number>;
49
+ /** Optional client-side identifier carried through unchanged. */
50
+ readonly id?: string;
51
+ };
52
+ /**
53
+ * Async embedder callback. Returns a vector per input string in the same
54
+ * order. Implementations typically batch internally for cost efficiency.
55
+ *
56
+ * Vectors must be normalized (unit length) so cosine reduces to a dot
57
+ * product. Most production embedders return normalized vectors by default;
58
+ * if yours doesn't, normalize before returning here.
59
+ */
60
+ export type EmbedderFn = (texts: ReadonlyArray<string>) => Promise<ReadonlyArray<ReadonlyArray<number>>>;
61
+ export type CurateOptions = {
62
+ /**
63
+ * Query / goal string used to score relevance. When absent, the curator
64
+ * skips the relevance-reorder pass and only does dedupe.
65
+ */
66
+ readonly query?: string;
67
+ /**
68
+ * Cosine-similarity threshold above which two items are considered
69
+ * duplicates. Default 0.92 — high enough to avoid false-positive
70
+ * collapses on near-but-distinct items (a function definition and its
71
+ * caller share substantial token overlap but should NOT dedupe).
72
+ */
73
+ readonly dedupeThreshold?: number;
74
+ /**
75
+ * Max items to keep after relevance scoring. Items below this rank are
76
+ * dropped entirely. When absent, no items are dropped from the
77
+ * relevance pass — items are only reordered. Set when you want a hard
78
+ * top-K filter.
79
+ */
80
+ readonly relevanceTopK?: number;
81
+ /**
82
+ * Embedder supplied by the caller. Required if any item lacks a
83
+ * pre-computed `embedding` AND either dedupe or relevance reorder is
84
+ * requested. The function-level types let it stay optional for the
85
+ * pre-embedded case (most production callers).
86
+ */
87
+ readonly embedder?: EmbedderFn;
88
+ };
89
+ export declare const DEFAULT_DEDUPE_THRESHOLD = 0.92;
90
+ /**
91
+ * Pass 1 — semantic dedupe. For each item, if a *prior* item's embedding
92
+ * has cosine ≥ threshold, drop the current. Order-preserving: the first
93
+ * occurrence wins (keeps the head of the conversation, which carries
94
+ * goal-setting context).
95
+ */
96
+ export declare function dedupeBySimilarity(items: ReadonlyArray<Item>, embeddings: ReadonlyArray<ReadonlyArray<number>>, threshold: number): {
97
+ kept: ReadonlyArray<number>;
98
+ dropped: ReadonlyArray<number>;
99
+ };
100
+ /**
101
+ * Pass 2 — relevance reorder. Compute cosine(query, item) for every
102
+ * surviving item; return indices in descending similarity order.
103
+ * Stable sort: items with identical similarity stay in original order.
104
+ *
105
+ * When `topK` is supplied, items beyond that rank are dropped.
106
+ */
107
+ export declare function rankByRelevance(items: ReadonlyArray<Item>, itemEmbeddings: ReadonlyArray<ReadonlyArray<number>>, query: string, embedder: EmbedderFn | undefined, topK?: number): Promise<ReadonlyArray<number>>;
108
+ export type CurationResult = {
109
+ /** Items in the curated order (deduped + relevance-ranked + topK-trimmed). */
110
+ readonly items: ReadonlyArray<Item>;
111
+ /** Indices into the *original* input array, in output order. */
112
+ readonly originalIndices: ReadonlyArray<number>;
113
+ /** Indices that were dropped (in original order). */
114
+ readonly droppedIndices: ReadonlyArray<number>;
115
+ /** Bytes saved as compared to the original total (text only). */
116
+ readonly bytesSaved: number;
117
+ };
118
+ /**
119
+ * High-level entry: dedupe then relevance-reorder (when a query is
120
+ * supplied) then top-K filter. The most common call from
121
+ * `compaction-autocompact` is `curate(items, { query, embedder })`.
122
+ */
123
+ export declare function curate(items: ReadonlyArray<Item>, opts?: CurateOptions): Promise<CurationResult>;
package/dist/index.js ADDED
@@ -0,0 +1,199 @@
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
+ export class CompactionCuratorError extends CrewhausError {
34
+ name = "CompactionCuratorError";
35
+ constructor(message, cause) {
36
+ super("config", message, cause);
37
+ }
38
+ }
39
+ export const DEFAULT_DEDUPE_THRESHOLD = 0.92;
40
+ function cosine(a, b) {
41
+ if (a.length !== b.length) {
42
+ throw new CompactionCuratorError(`embedding dimension mismatch (${a.length} vs ${b.length})`);
43
+ }
44
+ let dot = 0;
45
+ let na = 0;
46
+ let nb = 0;
47
+ for (let i = 0; i < a.length; i++) {
48
+ const av = a[i] ?? 0;
49
+ const bv = b[i] ?? 0;
50
+ dot += av * bv;
51
+ na += av * av;
52
+ nb += bv * bv;
53
+ }
54
+ if (na === 0 || nb === 0)
55
+ return 0;
56
+ return dot / Math.sqrt(na * nb);
57
+ }
58
+ /**
59
+ * Ensure every input item has an embedding. Items already carrying one
60
+ * pass through; items missing one are batched into a single embedder
61
+ * call. Preserves order so the caller's downstream indexing isn't
62
+ * disturbed.
63
+ */
64
+ async function ensureEmbeddings(items, embedder) {
65
+ if (items.length === 0)
66
+ return [];
67
+ const missingIdx = [];
68
+ const missingTexts = [];
69
+ for (let i = 0; i < items.length; i++) {
70
+ const item = items[i];
71
+ if (item !== undefined && item.embedding === undefined) {
72
+ missingIdx.push(i);
73
+ missingTexts.push(item.text);
74
+ }
75
+ }
76
+ if (missingIdx.length > 0 && embedder === undefined) {
77
+ throw new CompactionCuratorError(`${missingIdx.length} item(s) lack pre-computed embeddings and no embedder was supplied — pass options.embedder or pre-embed your items`);
78
+ }
79
+ const fresh = embedder !== undefined && missingIdx.length > 0 ? await embedder(missingTexts) : [];
80
+ return items.map((item, i) => {
81
+ if (item.embedding !== undefined)
82
+ return item.embedding;
83
+ const slot = missingIdx.indexOf(i);
84
+ if (slot === -1) {
85
+ throw new CompactionCuratorError(`internal: missing embedding for item ${i}`);
86
+ }
87
+ const v = fresh[slot];
88
+ if (v === undefined) {
89
+ throw new CompactionCuratorError(`embedder returned no vector for item ${i}`);
90
+ }
91
+ return v;
92
+ });
93
+ }
94
+ /**
95
+ * Pass 1 — semantic dedupe. For each item, if a *prior* item's embedding
96
+ * has cosine ≥ threshold, drop the current. Order-preserving: the first
97
+ * occurrence wins (keeps the head of the conversation, which carries
98
+ * goal-setting context).
99
+ */
100
+ export function dedupeBySimilarity(items, embeddings, threshold) {
101
+ const kept = [];
102
+ const dropped = [];
103
+ for (let i = 0; i < items.length; i++) {
104
+ let isDuplicate = false;
105
+ for (const k of kept) {
106
+ const e1 = embeddings[i];
107
+ const e2 = embeddings[k];
108
+ if (e1 === undefined || e2 === undefined)
109
+ continue;
110
+ if (cosine(e1, e2) >= threshold) {
111
+ isDuplicate = true;
112
+ break;
113
+ }
114
+ }
115
+ if (isDuplicate)
116
+ dropped.push(i);
117
+ else
118
+ kept.push(i);
119
+ }
120
+ return { kept, dropped };
121
+ }
122
+ /**
123
+ * Pass 2 — relevance reorder. Compute cosine(query, item) for every
124
+ * surviving item; return indices in descending similarity order.
125
+ * Stable sort: items with identical similarity stay in original order.
126
+ *
127
+ * When `topK` is supplied, items beyond that rank are dropped.
128
+ */
129
+ export async function rankByRelevance(items, itemEmbeddings, query, embedder, topK) {
130
+ if (items.length === 0)
131
+ return [];
132
+ if (embedder === undefined) {
133
+ throw new CompactionCuratorError("rankByRelevance requires an embedder to compute the query vector");
134
+ }
135
+ const queryVecs = await embedder([query]);
136
+ const queryVec = queryVecs[0];
137
+ if (queryVec === undefined) {
138
+ throw new CompactionCuratorError("embedder returned no vector for the query");
139
+ }
140
+ const scored = [];
141
+ for (let i = 0; i < items.length; i++) {
142
+ const e = itemEmbeddings[i];
143
+ if (e === undefined)
144
+ continue;
145
+ scored.push({ idx: i, sim: cosine(queryVec, e), original: i });
146
+ }
147
+ scored.sort((a, b) => {
148
+ if (a.sim !== b.sim)
149
+ return b.sim - a.sim;
150
+ return a.original - b.original; // stable
151
+ });
152
+ const ordered = scored.map((s) => s.idx);
153
+ if (topK !== undefined && ordered.length > topK)
154
+ return ordered.slice(0, topK);
155
+ return ordered;
156
+ }
157
+ /**
158
+ * High-level entry: dedupe then relevance-reorder (when a query is
159
+ * supplied) then top-K filter. The most common call from
160
+ * `compaction-autocompact` is `curate(items, { query, embedder })`.
161
+ */
162
+ export async function curate(items, opts = {}) {
163
+ if (items.length === 0) {
164
+ return { items: [], originalIndices: [], droppedIndices: [], bytesSaved: 0 };
165
+ }
166
+ const threshold = opts.dedupeThreshold ?? DEFAULT_DEDUPE_THRESHOLD;
167
+ const embeddings = await ensureEmbeddings(items, opts.embedder);
168
+ const { kept, dropped } = dedupeBySimilarity(items, embeddings, threshold);
169
+ let surviving = kept;
170
+ const droppedAll = [...dropped];
171
+ if (opts.query !== undefined && opts.query.length > 0) {
172
+ const subItems = surviving.map((i) => items[i]).filter((it) => it !== undefined);
173
+ const subEmbeddings = surviving
174
+ .map((i) => embeddings[i])
175
+ .filter((e) => e !== undefined);
176
+ const localOrder = await rankByRelevance(subItems, subEmbeddings, opts.query, opts.embedder, opts.relevanceTopK);
177
+ const localToOriginal = surviving;
178
+ surviving = localOrder
179
+ .map((localIdx) => localToOriginal[localIdx])
180
+ .filter((i) => i !== undefined);
181
+ if (opts.relevanceTopK !== undefined) {
182
+ const survivingSet = new Set(surviving);
183
+ for (const i of kept)
184
+ if (!survivingSet.has(i))
185
+ droppedAll.push(i);
186
+ }
187
+ }
188
+ const outItems = surviving.map((i) => items[i]).filter((it) => it !== undefined);
189
+ const droppedTotal = droppedAll
190
+ .map((i) => items[i])
191
+ .filter((it) => it !== undefined)
192
+ .reduce((acc, it) => acc + Buffer.byteLength(it.text, "utf8"), 0);
193
+ return {
194
+ items: outItems,
195
+ originalIndices: surviving,
196
+ droppedIndices: [...droppedAll].sort((a, b) => a - b),
197
+ bytesSaved: droppedTotal,
198
+ };
199
+ }
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@crewhaus/compaction-curator",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
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",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.3"
18
+ "@crewhaus/errors": "0.1.5"
16
19
  },
17
20
  "license": "Apache-2.0",
18
21
  "author": {
@@ -32,5 +35,5 @@
32
35
  "publishConfig": {
33
36
  "access": "public"
34
37
  },
35
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
38
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
36
39
  }
package/src/index.test.ts DELETED
@@ -1,421 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import {
3
- CompactionCuratorError,
4
- type EmbedderFn,
5
- type Item,
6
- curate,
7
- dedupeBySimilarity,
8
- rankByRelevance,
9
- } from "./index";
10
-
11
- /**
12
- * Tiny canned embedder for deterministic tests: maps each unique input
13
- * string to a fixed unit vector. Same input → same vector → cosine of 1.
14
- * Different input → orthogonal vector → cosine of 0.
15
- */
16
- function makeCannedEmbedder(): { fn: EmbedderFn; counter: () => number } {
17
- const cache = new Map<string, ReadonlyArray<number>>();
18
- let calls = 0;
19
- const dim = 16;
20
- const fn: EmbedderFn = async (texts) => {
21
- calls += 1;
22
- return texts.map((t) => {
23
- const cached = cache.get(t);
24
- if (cached !== undefined) return cached;
25
- const idx = cache.size % dim;
26
- const v = new Array(dim).fill(0);
27
- v[idx] = 1;
28
- const arr = Object.freeze(v) as ReadonlyArray<number>;
29
- cache.set(t, arr);
30
- return arr;
31
- });
32
- };
33
- return { fn, counter: () => calls };
34
- }
35
-
36
- describe("curate — happy path", () => {
37
- test("returns input unchanged when no dedupe + no query", async () => {
38
- const { fn } = makeCannedEmbedder();
39
- const items: ReadonlyArray<Item> = [
40
- { text: "alpha gamma delta epsilon zeta" },
41
- { text: "beta theta iota kappa lambda" },
42
- { text: "rho sigma tau upsilon phi" },
43
- ];
44
- const result = await curate(items, { embedder: fn });
45
- expect(result.items.length).toBe(3);
46
- expect(result.droppedIndices.length).toBe(0);
47
- expect(result.bytesSaved).toBe(0);
48
- });
49
-
50
- test("dedupes when two items embed identically", async () => {
51
- const embedder: EmbedderFn = async (texts) =>
52
- texts.map(() => [1, 0, 0, 0] as ReadonlyArray<number>);
53
- const items: ReadonlyArray<Item> = [
54
- { text: "alpha gamma delta epsilon zeta one" },
55
- { text: "alpha gamma delta epsilon zeta two" },
56
- { text: "alpha gamma delta epsilon zeta three" },
57
- ];
58
- const result = await curate(items, { embedder });
59
- expect(result.items.length).toBe(1);
60
- expect(result.droppedIndices).toEqual([1, 2]);
61
- });
62
-
63
- test("keeps when below dedupe threshold", async () => {
64
- const { fn } = makeCannedEmbedder();
65
- const items: ReadonlyArray<Item> = [
66
- { text: "alpha gamma delta epsilon zeta" },
67
- { text: "beta theta iota kappa lambda" },
68
- ];
69
- const result = await curate(items, { embedder: fn, dedupeThreshold: 0.99 });
70
- expect(result.items.length).toBe(2);
71
- });
72
-
73
- test("relevance-orders when query supplied", async () => {
74
- const items: ReadonlyArray<Item> = [
75
- { text: "completely unrelated chitchat", id: "off-topic" },
76
- { text: "deep technical content about query", id: "on-topic" },
77
- ];
78
- // Make on-topic share the query vector
79
- const embedder: EmbedderFn = async (texts) => {
80
- return texts.map((t) => {
81
- if (t === "deep technical content about query" || t === "search query") {
82
- return [1, 0, 0] as ReadonlyArray<number>;
83
- }
84
- return [0, 1, 0] as ReadonlyArray<number>;
85
- });
86
- };
87
- const result = await curate(items, { embedder, query: "search query" });
88
- expect(result.items[0]?.id).toBe("on-topic");
89
- expect(result.items[1]?.id).toBe("off-topic");
90
- });
91
-
92
- test("topK trims after relevance reorder", async () => {
93
- const items: ReadonlyArray<Item> = [
94
- { text: "irrelevant content one", id: "1" },
95
- { text: "highly relevant query match", id: "2" },
96
- { text: "irrelevant content two", id: "3" },
97
- ];
98
- const embedder: EmbedderFn = async (texts) =>
99
- texts.map((t) => {
100
- if (t === "highly relevant query match" || t === "the search query") {
101
- return [1, 0] as ReadonlyArray<number>;
102
- }
103
- return [0, 1] as ReadonlyArray<number>;
104
- });
105
- const result = await curate(items, {
106
- embedder,
107
- query: "the search query",
108
- relevanceTopK: 1,
109
- });
110
- expect(result.items.length).toBe(1);
111
- expect(result.items[0]?.id).toBe("2");
112
- expect(result.droppedIndices.length).toBe(2);
113
- });
114
- });
115
-
116
- describe("curate — pre-embedded path", () => {
117
- test("no embedder needed when all items carry embeddings", async () => {
118
- const items: ReadonlyArray<Item> = [
119
- { text: "first item with embedding precomputed", embedding: [1, 0] },
120
- { text: "second item with embedding precomputed", embedding: [0, 1] },
121
- ];
122
- const result = await curate(items, {}); // no embedder, no query
123
- expect(result.items.length).toBe(2);
124
- });
125
-
126
- test("throws when items lack embeddings and no embedder is supplied", async () => {
127
- const items: ReadonlyArray<Item> = [{ text: "needs embedding plenty of words" }];
128
- await expect(curate(items, {})).rejects.toThrow(/lack pre-computed embeddings/);
129
- });
130
- });
131
-
132
- describe("curate — edge cases", () => {
133
- test("empty input returns empty result", async () => {
134
- const result = await curate([], {});
135
- expect(result.items.length).toBe(0);
136
- expect(result.bytesSaved).toBe(0);
137
- });
138
-
139
- test("bytesSaved reflects dropped item lengths", async () => {
140
- const embedder: EmbedderFn = async (texts) => texts.map(() => [1, 0] as ReadonlyArray<number>);
141
- const items: ReadonlyArray<Item> = [
142
- { text: "kept first kept first kept first" }, // 32 bytes
143
- { text: "dropped dropped dropped" }, // 23 bytes
144
- ];
145
- const result = await curate(items, { embedder });
146
- expect(result.bytesSaved).toBe(Buffer.byteLength("dropped dropped dropped", "utf8"));
147
- });
148
- });
149
-
150
- describe("dedupeBySimilarity (pure)", () => {
151
- test("first occurrence wins for duplicates", () => {
152
- const items: ReadonlyArray<Item> = [{ text: "a" }, { text: "b" }, { text: "c" }];
153
- const embeddings: ReadonlyArray<ReadonlyArray<number>> = [
154
- [1, 0],
155
- [1, 0], // duplicate of #0
156
- [0, 1],
157
- ];
158
- const r = dedupeBySimilarity(items, embeddings, 0.92);
159
- expect(r.kept).toEqual([0, 2]);
160
- expect(r.dropped).toEqual([1]);
161
- });
162
- });
163
-
164
- describe("rankByRelevance (pure)", () => {
165
- test("ranks by descending cosine similarity", async () => {
166
- const items: ReadonlyArray<Item> = [{ text: "x" }, { text: "y" }];
167
- const embeddings: ReadonlyArray<ReadonlyArray<number>> = [
168
- [0, 1],
169
- [1, 0],
170
- ];
171
- const embedder: EmbedderFn = async () => [[1, 0]];
172
- const order = await rankByRelevance(items, embeddings, "q", embedder);
173
- expect(order).toEqual([1, 0]);
174
- });
175
-
176
- test("returns empty for empty items without calling the embedder", async () => {
177
- let called = false;
178
- const embedder: EmbedderFn = async (texts) => {
179
- called = true;
180
- return texts.map(() => [1, 0]);
181
- };
182
- const order = await rankByRelevance([], [], "q", embedder);
183
- expect(order).toEqual([]);
184
- expect(called).toBe(false);
185
- });
186
-
187
- test("throws when no embedder is supplied", async () => {
188
- const items: ReadonlyArray<Item> = [{ text: "x" }];
189
- await expect(rankByRelevance(items, [[1, 0]], "q", undefined)).rejects.toThrow(
190
- /requires an embedder to compute the query vector/,
191
- );
192
- });
193
-
194
- test("throws when the embedder returns no vector for the query", async () => {
195
- const items: ReadonlyArray<Item> = [{ text: "x" }];
196
- const embedder: EmbedderFn = async () => []; // empty -> queryVec is undefined
197
- await expect(rankByRelevance(items, [[1, 0]], "q", embedder)).rejects.toThrow(
198
- /returned no vector for the query/,
199
- );
200
- });
201
-
202
- test("skips items whose embedding is missing from the embeddings array", async () => {
203
- // Two items but only one embedding supplied: index 1 has no embedding and
204
- // is skipped, so only index 0 is ranked/returned.
205
- const items: ReadonlyArray<Item> = [{ text: "x" }, { text: "y" }];
206
- const embeddings: ReadonlyArray<ReadonlyArray<number>> = [[1, 0]];
207
- const embedder: EmbedderFn = async () => [[1, 0]];
208
- const order = await rankByRelevance(items, embeddings, "q", embedder);
209
- expect(order).toEqual([0]);
210
- });
211
-
212
- test("stable order is preserved for equal similarities", async () => {
213
- // Both items orthogonal to the query -> identical similarity (0). Stable
214
- // sort must keep original order [0, 1].
215
- const items: ReadonlyArray<Item> = [{ text: "x" }, { text: "y" }];
216
- const embeddings: ReadonlyArray<ReadonlyArray<number>> = [
217
- [0, 1],
218
- [0, 1],
219
- ];
220
- const embedder: EmbedderFn = async () => [[1, 0]];
221
- const order = await rankByRelevance(items, embeddings, "q", embedder);
222
- expect(order).toEqual([0, 1]);
223
- });
224
-
225
- test("does not trim when topK is greater than or equal to the item count", async () => {
226
- const items: ReadonlyArray<Item> = [{ text: "x" }, { text: "y" }];
227
- const embeddings: ReadonlyArray<ReadonlyArray<number>> = [
228
- [1, 0],
229
- [0, 1],
230
- ];
231
- const embedder: EmbedderFn = async () => [[1, 0]];
232
- const order = await rankByRelevance(items, embeddings, "q", embedder, 5);
233
- expect(order).toEqual([0, 1]);
234
- });
235
- });
236
-
237
- describe("cosine (via dedupeBySimilarity / curate)", () => {
238
- test("throws on embedding dimension mismatch", () => {
239
- // First kept vector has dim 2, second item has dim 3 -> cosine throws when
240
- // comparing item 1 against kept item 0.
241
- const items: ReadonlyArray<Item> = [{ text: "a" }, { text: "b" }];
242
- const embeddings: ReadonlyArray<ReadonlyArray<number>> = [
243
- [1, 0],
244
- [1, 0, 0],
245
- ];
246
- expect(() => dedupeBySimilarity(items, embeddings, 0.92)).toThrow(CompactionCuratorError);
247
- // dedupe compares the current item (index 1, dim 3) against the kept item
248
- // (index 0, dim 2), so the message reports the dims in that order.
249
- expect(() => dedupeBySimilarity(items, embeddings, 0.92)).toThrow(
250
- /embedding dimension mismatch \(3 vs 2\)/,
251
- );
252
- });
253
-
254
- test("zero-norm vector yields cosine 0 (not a duplicate, no divide-by-zero)", () => {
255
- // Item 1 is the zero vector: cosine(any, 0) === 0 < threshold, so it is
256
- // kept rather than collapsed, and no NaN/Infinity leaks through.
257
- const items: ReadonlyArray<Item> = [{ text: "a" }, { text: "b" }];
258
- const embeddings: ReadonlyArray<ReadonlyArray<number>> = [
259
- [1, 0],
260
- [0, 0],
261
- ];
262
- const r = dedupeBySimilarity(items, embeddings, 0.92);
263
- expect(r.kept).toEqual([0, 1]);
264
- expect(r.dropped).toEqual([]);
265
- });
266
-
267
- test("treats missing trailing components as zero when dims are equal length", () => {
268
- // Arrays of equal length whose trailing slot is `undefined` -> the `?? 0`
269
- // fallbacks keep the dot/norm math finite. [1, undefined] vs [1, undefined]
270
- // is identical -> dedupe.
271
- const sparse = [1, undefined] as unknown as ReadonlyArray<number>;
272
- const items: ReadonlyArray<Item> = [{ text: "a" }, { text: "b" }];
273
- const embeddings: ReadonlyArray<ReadonlyArray<number>> = [sparse, sparse];
274
- const r = dedupeBySimilarity(items, embeddings, 0.92);
275
- expect(r.kept).toEqual([0]);
276
- expect(r.dropped).toEqual([1]);
277
- });
278
- });
279
-
280
- describe("dedupeBySimilarity — missing embeddings are skipped", () => {
281
- test("a kept item with no embedding is never matched against", () => {
282
- // Index 0 has no embedding (undefined): the inner loop's `continue` keeps it,
283
- // and a later identical item is still compared against other kept items.
284
- const items: ReadonlyArray<Item> = [{ text: "a" }, { text: "b" }, { text: "c" }];
285
- const embeddings = [undefined, [1, 0], [1, 0]] as unknown as ReadonlyArray<
286
- ReadonlyArray<number>
287
- >;
288
- const r = dedupeBySimilarity(items, embeddings, 0.92);
289
- // 0 kept (no embedding), 1 kept (first real), 2 dropped (dup of 1)
290
- expect(r.kept).toEqual([0, 1]);
291
- expect(r.dropped).toEqual([2]);
292
- });
293
-
294
- test("current item with no embedding is kept (inner comparison skipped)", () => {
295
- const items: ReadonlyArray<Item> = [{ text: "a" }, { text: "b" }];
296
- const embeddings = [[1, 0], undefined] as unknown as ReadonlyArray<ReadonlyArray<number>>;
297
- const r = dedupeBySimilarity(items, embeddings, 0.92);
298
- expect(r.kept).toEqual([0, 1]);
299
- expect(r.dropped).toEqual([]);
300
- });
301
- });
302
-
303
- describe("curate — embedder failure modes", () => {
304
- test("throws when the embedder returns fewer vectors than requested", async () => {
305
- // Two items need embeddings but the embedder returns only one vector ->
306
- // the second slot is undefined and curate reports the offending index.
307
- const embedder: EmbedderFn = async () => [[1, 0]];
308
- const items: ReadonlyArray<Item> = [
309
- { text: "first needs embedding" },
310
- { text: "second needs embedding" },
311
- ];
312
- await expect(curate(items, { embedder })).rejects.toThrow(
313
- /embedder returned no vector for item 1/,
314
- );
315
- });
316
-
317
- test("mixes pre-embedded and freshly embedded items in original order", async () => {
318
- // Item 0 is pre-embedded, item 1 needs embedding. The fresh vector must land
319
- // in the right slot so the two are NOT collapsed (different vectors).
320
- let seen: ReadonlyArray<string> | undefined;
321
- const embedder: EmbedderFn = async (texts) => {
322
- seen = texts;
323
- return texts.map(() => [0, 1]);
324
- };
325
- const items: ReadonlyArray<Item> = [
326
- { text: "pre embedded item", embedding: [1, 0] },
327
- { text: "needs embedding item" },
328
- ];
329
- const result = await curate(items, { embedder });
330
- // Only the missing one is sent to the embedder.
331
- expect(seen).toEqual(["needs embedding item"]);
332
- expect(result.items.length).toBe(2);
333
- expect(result.droppedIndices).toEqual([]);
334
- });
335
- });
336
-
337
- describe("curate — query/topK branch coverage", () => {
338
- test("ignores an empty-string query (dedupe only, no reorder)", async () => {
339
- // query "" has length 0, so the relevance branch is skipped and order is
340
- // preserved as-is.
341
- const items: ReadonlyArray<Item> = [
342
- { text: "alpha gamma delta epsilon zeta", embedding: [1, 0] },
343
- { text: "beta theta iota kappa lambda", embedding: [0, 1] },
344
- ];
345
- const result = await curate(items, { query: "" });
346
- expect(result.items.length).toBe(2);
347
- expect(result.originalIndices).toEqual([0, 1]);
348
- expect(result.droppedIndices).toEqual([]);
349
- });
350
-
351
- test("query reorder without topK keeps every surviving item (no drops)", async () => {
352
- // With a query but no relevanceTopK, items are only reordered; the topK
353
- // drop-accounting branch must NOT run.
354
- const items: ReadonlyArray<Item> = [
355
- { text: "off topic", id: "a", embedding: [0, 1] },
356
- { text: "on topic", id: "b", embedding: [1, 0] },
357
- ];
358
- const embedder: EmbedderFn = async () => [[1, 0]]; // query vector
359
- const result = await curate(items, { query: "q", embedder });
360
- expect(result.items.map((i) => i.id)).toEqual(["b", "a"]);
361
- expect(result.droppedIndices).toEqual([]);
362
- });
363
-
364
- test("dedupe drops and topK drops are merged and sorted in droppedIndices", async () => {
365
- // index 0 & 1 are duplicates (index 1 dedupe-dropped); of the survivors,
366
- // topK=1 trims one more. Final droppedIndices must be sorted ascending.
367
- // Distinct, orthogonal vectors per text so only the intended pair collides.
368
- const vectors: Record<string, ReadonlyArray<number>> = {
369
- query: [1, 0, 0],
370
- dup: [0, 1, 0], // items 0 and 1 share this -> dedupe collapse
371
- match: [1, 0, 0], // identical to the query -> highest relevance
372
- filler: [0, 0, 1], // distinct from everything -> survives dedupe, topK-trimmed
373
- };
374
- const embedder: EmbedderFn = async (texts) => texts.map((t) => vectors[t] ?? [0, 0, 0]);
375
- const items: ReadonlyArray<Item> = [
376
- { text: "dup", id: "0" }, // dedupe-kept, then topK-dropped (sim 0)
377
- { text: "dup", id: "1" }, // dedupe-dropped (same vector as 0)
378
- { text: "match", id: "2" }, // most relevant -> kept by topK
379
- { text: "filler", id: "3" }, // dedupe-kept, then topK-dropped (sim 0)
380
- ];
381
- const result = await curate(items, {
382
- embedder,
383
- query: "query",
384
- relevanceTopK: 1,
385
- });
386
- expect(result.items.map((i) => i.id)).toEqual(["2"]);
387
- // 1 from dedupe; 0 and 3 from topK trim -> sorted ascending, no duplicates.
388
- expect(result.droppedIndices).toEqual([0, 1, 3]);
389
- // bytesSaved counts the dedupe drop plus both topK drops.
390
- const expectedBytes =
391
- Buffer.byteLength("dup", "utf8") + // index 1 (dedupe)
392
- Buffer.byteLength("dup", "utf8") + // index 0 (topK)
393
- Buffer.byteLength("filler", "utf8"); // index 3 (topK)
394
- expect(result.bytesSaved).toBe(expectedBytes);
395
- });
396
-
397
- test("relevanceTopK without a query is ignored (no reorder, no drops)", async () => {
398
- // The topK filter only applies inside the query branch; without a query it
399
- // must have no effect.
400
- const items: ReadonlyArray<Item> = [
401
- { text: "one", embedding: [1, 0] },
402
- { text: "two", embedding: [0, 1] },
403
- { text: "three", embedding: [1, 1] },
404
- ];
405
- const result = await curate(items, { relevanceTopK: 1 });
406
- expect(result.items.length).toBe(3);
407
- expect(result.droppedIndices).toEqual([]);
408
- });
409
- });
410
-
411
- describe("CompactionCuratorError", () => {
412
- test("carries the config code and a wrapped cause", () => {
413
- const cause = new Error("boom");
414
- const err = new CompactionCuratorError("wrapped", cause);
415
- expect(err).toBeInstanceOf(Error);
416
- expect(err.name).toBe("CompactionCuratorError");
417
- expect(err.code).toBe("config");
418
- expect(err.cause).toBe(cause);
419
- expect(err.message).toBe("wrapped");
420
- });
421
- });
package/src/index.ts DELETED
@@ -1,288 +0,0 @@
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
- }