mem-os-python 1.6.27__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. mem_os_python-1.6.27.dist-info/METADATA +1032 -0
  2. mem_os_python-1.6.27.dist-info/RECORD +253 -0
  3. mem_os_python-1.6.27.dist-info/WHEEL +4 -0
  4. mem_os_python-1.6.27.dist-info/entry_points.txt +2 -0
  5. mem_os_python-1.6.27.dist-info/licenses/LICENSE +21 -0
  6. memos/__init__.py +16 -0
  7. memos/_js/adapters/anthropic.d.ts +279 -0
  8. memos/_js/adapters/anthropic.d.ts.map +1 -0
  9. memos/_js/adapters/anthropic.js +348 -0
  10. memos/_js/adapters/anthropic.js.map +1 -0
  11. memos/_js/adapters/base.d.ts +10 -0
  12. memos/_js/adapters/base.d.ts.map +1 -0
  13. memos/_js/adapters/base.js +2 -0
  14. memos/_js/adapters/base.js.map +1 -0
  15. memos/_js/adapters/openai.d.ts +264 -0
  16. memos/_js/adapters/openai.d.ts.map +1 -0
  17. memos/_js/adapters/openai.js +342 -0
  18. memos/_js/adapters/openai.js.map +1 -0
  19. memos/_js/apps/explorer.d.ts +244 -0
  20. memos/_js/apps/explorer.d.ts.map +1 -0
  21. memos/_js/apps/explorer.js +738 -0
  22. memos/_js/apps/explorer.js.map +1 -0
  23. memos/_js/citations.d.ts +65 -0
  24. memos/_js/citations.d.ts.map +1 -0
  25. memos/_js/citations.js +143 -0
  26. memos/_js/citations.js.map +1 -0
  27. memos/_js/cli-browse.d.ts +3 -0
  28. memos/_js/cli-browse.d.ts.map +1 -0
  29. memos/_js/cli-browse.js +129 -0
  30. memos/_js/cli-browse.js.map +1 -0
  31. memos/_js/cli-completion.d.ts +31 -0
  32. memos/_js/cli-completion.d.ts.map +1 -0
  33. memos/_js/cli-completion.js +340 -0
  34. memos/_js/cli-completion.js.map +1 -0
  35. memos/_js/cli-help.d.ts +18 -0
  36. memos/_js/cli-help.d.ts.map +1 -0
  37. memos/_js/cli-help.js +357 -0
  38. memos/_js/cli-help.js.map +1 -0
  39. memos/_js/cli.d.ts +28 -0
  40. memos/_js/cli.d.ts.map +1 -0
  41. memos/_js/cli.js +2880 -0
  42. memos/_js/cli.js.map +1 -0
  43. memos/_js/compression.d.ts +25 -0
  44. memos/_js/compression.d.ts.map +1 -0
  45. memos/_js/compression.js +319 -0
  46. memos/_js/compression.js.map +1 -0
  47. memos/_js/confidence-machine.d.ts +99 -0
  48. memos/_js/confidence-machine.d.ts.map +1 -0
  49. memos/_js/confidence-machine.js +222 -0
  50. memos/_js/confidence-machine.js.map +1 -0
  51. memos/_js/context-pack.d.ts +506 -0
  52. memos/_js/context-pack.d.ts.map +1 -0
  53. memos/_js/context-pack.js +1435 -0
  54. memos/_js/context-pack.js.map +1 -0
  55. memos/_js/contradictions.d.ts +160 -0
  56. memos/_js/contradictions.d.ts.map +1 -0
  57. memos/_js/contradictions.js +255 -0
  58. memos/_js/contradictions.js.map +1 -0
  59. memos/_js/dashboard.d.ts +30 -0
  60. memos/_js/dashboard.d.ts.map +1 -0
  61. memos/_js/dashboard.js +302 -0
  62. memos/_js/dashboard.js.map +1 -0
  63. memos/_js/ebbinghaus.d.ts +29 -0
  64. memos/_js/ebbinghaus.d.ts.map +1 -0
  65. memos/_js/ebbinghaus.js +32 -0
  66. memos/_js/ebbinghaus.js.map +1 -0
  67. memos/_js/embedding-queue.d.ts +190 -0
  68. memos/_js/embedding-queue.d.ts.map +1 -0
  69. memos/_js/embedding-queue.js +316 -0
  70. memos/_js/embedding-queue.js.map +1 -0
  71. memos/_js/embeddings.d.ts +187 -0
  72. memos/_js/embeddings.d.ts.map +1 -0
  73. memos/_js/embeddings.js +633 -0
  74. memos/_js/embeddings.js.map +1 -0
  75. memos/_js/entity-extraction.d.ts +54 -0
  76. memos/_js/entity-extraction.d.ts.map +1 -0
  77. memos/_js/entity-extraction.js +325 -0
  78. memos/_js/entity-extraction.js.map +1 -0
  79. memos/_js/entity-index.d.ts +93 -0
  80. memos/_js/entity-index.d.ts.map +1 -0
  81. memos/_js/entity-index.js +165 -0
  82. memos/_js/entity-index.js.map +1 -0
  83. memos/_js/event-memory.d.ts +147 -0
  84. memos/_js/event-memory.d.ts.map +1 -0
  85. memos/_js/event-memory.js +290 -0
  86. memos/_js/event-memory.js.map +1 -0
  87. memos/_js/external-import.d.ts +98 -0
  88. memos/_js/external-import.d.ts.map +1 -0
  89. memos/_js/external-import.js +669 -0
  90. memos/_js/external-import.js.map +1 -0
  91. memos/_js/fidelity.d.ts +367 -0
  92. memos/_js/fidelity.d.ts.map +1 -0
  93. memos/_js/fidelity.js +911 -0
  94. memos/_js/fidelity.js.map +1 -0
  95. memos/_js/graph-expansion.d.ts +119 -0
  96. memos/_js/graph-expansion.d.ts.map +1 -0
  97. memos/_js/graph-expansion.js +213 -0
  98. memos/_js/graph-expansion.js.map +1 -0
  99. memos/_js/graph-mermaid.d.ts +13 -0
  100. memos/_js/graph-mermaid.d.ts.map +1 -0
  101. memos/_js/graph-mermaid.js +32 -0
  102. memos/_js/graph-mermaid.js.map +1 -0
  103. memos/_js/graph.d.ts +170 -0
  104. memos/_js/graph.d.ts.map +1 -0
  105. memos/_js/graph.js +482 -0
  106. memos/_js/graph.js.map +1 -0
  107. memos/_js/harness.d.ts +63 -0
  108. memos/_js/harness.d.ts.map +1 -0
  109. memos/_js/harness.js +93 -0
  110. memos/_js/harness.js.map +1 -0
  111. memos/_js/importance.d.ts +118 -0
  112. memos/_js/importance.d.ts.map +1 -0
  113. memos/_js/importance.js +144 -0
  114. memos/_js/importance.js.map +1 -0
  115. memos/_js/index.d.ts +51 -0
  116. memos/_js/index.d.ts.map +1 -0
  117. memos/_js/index.js +53 -0
  118. memos/_js/index.js.map +1 -0
  119. memos/_js/mcp.d.ts +58 -0
  120. memos/_js/mcp.d.ts.map +1 -0
  121. memos/_js/mcp.js +1853 -0
  122. memos/_js/mcp.js.map +1 -0
  123. memos/_js/memory.d.ts +1762 -0
  124. memos/_js/memory.d.ts.map +1 -0
  125. memos/_js/memory.js +6283 -0
  126. memos/_js/memory.js.map +1 -0
  127. memos/_js/procedural.d.ts +74 -0
  128. memos/_js/procedural.d.ts.map +1 -0
  129. memos/_js/procedural.js +125 -0
  130. memos/_js/procedural.js.map +1 -0
  131. memos/_js/project-config.d.ts +68 -0
  132. memos/_js/project-config.d.ts.map +1 -0
  133. memos/_js/project-config.js +59 -0
  134. memos/_js/project-config.js.map +1 -0
  135. memos/_js/proof-of-forgetting.d.ts +57 -0
  136. memos/_js/proof-of-forgetting.d.ts.map +1 -0
  137. memos/_js/proof-of-forgetting.js +80 -0
  138. memos/_js/proof-of-forgetting.js.map +1 -0
  139. memos/_js/provenance.d.ts +122 -0
  140. memos/_js/provenance.d.ts.map +1 -0
  141. memos/_js/provenance.js +182 -0
  142. memos/_js/provenance.js.map +1 -0
  143. memos/_js/quarantine-commands.d.ts +23 -0
  144. memos/_js/quarantine-commands.d.ts.map +1 -0
  145. memos/_js/quarantine-commands.js +42 -0
  146. memos/_js/quarantine-commands.js.map +1 -0
  147. memos/_js/quarantine.d.ts +120 -0
  148. memos/_js/quarantine.d.ts.map +1 -0
  149. memos/_js/quarantine.js +873 -0
  150. memos/_js/quarantine.js.map +1 -0
  151. memos/_js/redact.d.ts +12 -0
  152. memos/_js/redact.d.ts.map +1 -0
  153. memos/_js/redact.js +26 -0
  154. memos/_js/redact.js.map +1 -0
  155. memos/_js/repl.d.ts +57 -0
  156. memos/_js/repl.d.ts.map +1 -0
  157. memos/_js/repl.js +295 -0
  158. memos/_js/repl.js.map +1 -0
  159. memos/_js/rerank-local.d.ts +61 -0
  160. memos/_js/rerank-local.d.ts.map +1 -0
  161. memos/_js/rerank-local.js +210 -0
  162. memos/_js/rerank-local.js.map +1 -0
  163. memos/_js/retain-filter.d.ts +98 -0
  164. memos/_js/retain-filter.d.ts.map +1 -0
  165. memos/_js/retain-filter.js +232 -0
  166. memos/_js/retain-filter.js.map +1 -0
  167. memos/_js/retrieval.d.ts +265 -0
  168. memos/_js/retrieval.d.ts.map +1 -0
  169. memos/_js/retrieval.js +1018 -0
  170. memos/_js/retrieval.js.map +1 -0
  171. memos/_js/revert.d.ts +77 -0
  172. memos/_js/revert.d.ts.map +1 -0
  173. memos/_js/revert.js +252 -0
  174. memos/_js/revert.js.map +1 -0
  175. memos/_js/scope.d.ts +30 -0
  176. memos/_js/scope.d.ts.map +1 -0
  177. memos/_js/scope.js +71 -0
  178. memos/_js/scope.js.map +1 -0
  179. memos/_js/semantic-screen.d.ts +152 -0
  180. memos/_js/semantic-screen.d.ts.map +1 -0
  181. memos/_js/semantic-screen.js +290 -0
  182. memos/_js/semantic-screen.js.map +1 -0
  183. memos/_js/session-hooks.d.ts +127 -0
  184. memos/_js/session-hooks.d.ts.map +1 -0
  185. memos/_js/session-hooks.js +482 -0
  186. memos/_js/session-hooks.js.map +1 -0
  187. memos/_js/storage/sqlite.d.ts +427 -0
  188. memos/_js/storage/sqlite.d.ts.map +1 -0
  189. memos/_js/storage/sqlite.js +2428 -0
  190. memos/_js/storage/sqlite.js.map +1 -0
  191. memos/_js/sync.d.ts +213 -0
  192. memos/_js/sync.d.ts.map +1 -0
  193. memos/_js/sync.js +389 -0
  194. memos/_js/sync.js.map +1 -0
  195. memos/_js/tamper-log.d.ts +98 -0
  196. memos/_js/tamper-log.d.ts.map +1 -0
  197. memos/_js/tamper-log.js +327 -0
  198. memos/_js/tamper-log.js.map +1 -0
  199. memos/_js/temporal.d.ts +92 -0
  200. memos/_js/temporal.d.ts.map +1 -0
  201. memos/_js/temporal.js +536 -0
  202. memos/_js/temporal.js.map +1 -0
  203. memos/_js/time-travel.d.ts +38 -0
  204. memos/_js/time-travel.d.ts.map +1 -0
  205. memos/_js/time-travel.js +46 -0
  206. memos/_js/time-travel.js.map +1 -0
  207. memos/_js/timestamp-format.d.ts +18 -0
  208. memos/_js/timestamp-format.d.ts.map +1 -0
  209. memos/_js/timestamp-format.js +52 -0
  210. memos/_js/timestamp-format.js.map +1 -0
  211. memos/_js/triple-extraction.d.ts +31 -0
  212. memos/_js/triple-extraction.d.ts.map +1 -0
  213. memos/_js/triple-extraction.js +116 -0
  214. memos/_js/triple-extraction.js.map +1 -0
  215. memos/_js/types.d.ts +2474 -0
  216. memos/_js/types.d.ts.map +1 -0
  217. memos/_js/types.js +20 -0
  218. memos/_js/types.js.map +1 -0
  219. memos/_js/updater.d.ts +95 -0
  220. memos/_js/updater.d.ts.map +1 -0
  221. memos/_js/updater.js +268 -0
  222. memos/_js/updater.js.map +1 -0
  223. memos/_js/version.d.ts +17 -0
  224. memos/_js/version.d.ts.map +1 -0
  225. memos/_js/version.js +34 -0
  226. memos/_js/version.js.map +1 -0
  227. memos/_js/vision/clip-query-cache.d.ts +58 -0
  228. memos/_js/vision/clip-query-cache.d.ts.map +1 -0
  229. memos/_js/vision/clip-query-cache.js +79 -0
  230. memos/_js/vision/clip-query-cache.js.map +1 -0
  231. memos/_js/vision/clip.d.ts +113 -0
  232. memos/_js/vision/clip.d.ts.map +1 -0
  233. memos/_js/vision/clip.js +224 -0
  234. memos/_js/vision/clip.js.map +1 -0
  235. memos/_js/vision/fake-clip.d.ts +55 -0
  236. memos/_js/vision/fake-clip.d.ts.map +1 -0
  237. memos/_js/vision/fake-clip.js +122 -0
  238. memos/_js/vision/fake-clip.js.map +1 -0
  239. memos/_js/vision/index.d.ts +15 -0
  240. memos/_js/vision/index.d.ts.map +1 -0
  241. memos/_js/vision/index.js +13 -0
  242. memos/_js/vision/index.js.map +1 -0
  243. memos/_js/vision/store.d.ts +137 -0
  244. memos/_js/vision/store.d.ts.map +1 -0
  245. memos/_js/vision/store.js +383 -0
  246. memos/_js/vision/store.js.map +1 -0
  247. memos/adapters/__init__.py +0 -0
  248. memos/adapters/crewai.py +385 -0
  249. memos/adapters/langchain.py +301 -0
  250. memos/adapters/ollama.py +396 -0
  251. memos/server/__init__.py +0 -0
  252. memos/server/main.py +547 -0
  253. memos/server/routes.py +360 -0
@@ -0,0 +1,1435 @@
1
+ /**
2
+ * AI Trio context pack builder.
3
+ *
4
+ * Implements the `ai-trio.memos.context-pack.v1` contract from
5
+ * `docs/ai-trio-contracts.md`. This is the canonical envelope LLM Guardian
6
+ * and any other AI Trio consumer reads.
7
+ *
8
+ * Token-saving features:
9
+ * - Content debloating: strips redundant whitespace, collapses repeated
10
+ * phrases, trims trailing punctuation noise.
11
+ * - Smart dedup: drops near-duplicate items from the pack so the
12
+ * consumer doesn't fold the same fact twice — but only genuine
13
+ * duplicates. Similarity scores are order-blind and value-blind, so
14
+ * every drop is gated by a discriminating-token guard: an inverted
15
+ * fact (source/destination swapped) or a changed number survives.
16
+ * - Token-accurate budgeting: uses a GPT-style BPE approximation
17
+ * (whitespace + punctuation split) instead of the 4-chars-per-token
18
+ * heuristic. Callers can swap in a real tokenizer via `tokenCounter`.
19
+ * - Prompt-cache stability: scores are rounded to 3 decimals, items
20
+ * sort deterministically (score desc, id asc), and serialization
21
+ * layouts are fixed — so rebuilding a pack from the same memories
22
+ * yields byte-identical output. Providers that discount repeated
23
+ * prompt prefixes (Anthropic prompt caching, OpenAI) then cache the
24
+ * evidence block instead of re-billing it every turn.
25
+ *
26
+ * @module @memos/context-pack
27
+ */
28
+ // Runtime import is safe: embeddings.ts has no runtime imports of its own
29
+ // (type-only), so this cannot create a module cycle.
30
+ import { cosineSimilarity } from "./embeddings.js";
31
+ // Stable score-desc/id-asc ordering shared with retrieval fusion.
32
+ // retrieval.ts does not import this module, so no cycle is introduced.
33
+ import { compareScoredMemories } from "./retrieval.js";
34
+ // Citation tokens are pure string functions; procedural.ts is type-only
35
+ // plus pure math — neither imports this module, so no cycle.
36
+ import { assignCitationTokens, citationToken } from "./citations.js";
37
+ import { lessonCitationToken } from "./procedural.js";
38
+ // Fidelity levels are pure functions over nodes (fidelity.ts imports
39
+ // nothing from this module), so no cycle is introduced.
40
+ import { levelText, routeFidelity, shouldCompressFidelityItem, } from "./fidelity.js";
41
+ import { isUntrustedForAgent } from "./provenance.js";
42
+ import { compressText } from "./compression.js";
43
+ /**
44
+ * The image payload carried by a node, or null when it is not an image
45
+ * memory.
46
+ *
47
+ * `metadata.image` — not `type`, not a new MemoryType member — is the
48
+ * discriminator (the same pattern `metadata.context` uses), so
49
+ * MemoryType exhaustiveness stays intact everywhere: the sync
50
+ * allowlist, fidelity labels, adapters, and the MCP zod schema all keep
51
+ * working without knowing images exist.
52
+ *
53
+ * A malformed payload returns null rather than throwing: a pack is a
54
+ * read path, and a partially-written metadata key must degrade to
55
+ * "not an image" instead of failing the whole pack.
56
+ */
57
+ export function imageAttachmentOf(node) {
58
+ const raw = node.metadata?.image;
59
+ if (!raw || typeof raw !== "object")
60
+ return null;
61
+ const image = raw;
62
+ if (typeof image.ref !== "string" || typeof image.sha256 !== "string") {
63
+ return null;
64
+ }
65
+ return image;
66
+ }
67
+ /** The single source of truth for the context-pack schema id. */
68
+ export const CONTEXT_PACK_SCHEMA = "ai-trio.memos.context-pack.v1";
69
+ // ---------------------------------------------------------------------------
70
+ // Token estimation
71
+ // ---------------------------------------------------------------------------
72
+ /**
73
+ * Estimate token count using a GPT-style BPE approximation.
74
+ *
75
+ * Splits on whitespace and punctuation boundaries, then counts the
76
+ * fragments. This is within ~10% of real tiktoken counts for English
77
+ * text and is zero-dependency. For exact counts, pass a `tokenCounter`
78
+ * that calls tiktoken or an LM Studio token-count endpoint.
79
+ */
80
+ export function estimateTokens(text) {
81
+ if (!text || text.length === 0)
82
+ return 0;
83
+ // GPT tokenizes on whitespace + punctuation. Each punctuation mark
84
+ // is typically its own token; words are 1-2 tokens depending on length.
85
+ // A word of <=4 chars ≈ 1 token; longer words ≈ 1.3 tokens on average.
86
+ const tokens = text
87
+ .replace(/([.,;:!?"'(){}[\]])/g, " $1 ")
88
+ .trim()
89
+ .split(/\s+/)
90
+ .filter(Boolean);
91
+ let count = 0;
92
+ for (const tok of tokens) {
93
+ if (tok.length <= 4)
94
+ count += 1;
95
+ else if (tok.length <= 8)
96
+ count += 1;
97
+ else
98
+ count += Math.ceil(tok.length / 4);
99
+ }
100
+ return count;
101
+ }
102
+ // ---------------------------------------------------------------------------
103
+ // Per-item wire overhead (budget accounting)
104
+ // ---------------------------------------------------------------------------
105
+ /**
106
+ * Legacy flat per-item overhead charged by the budget loop: the content
107
+ * tokens, the summary tokens, plus this constant for "everything else".
108
+ * Kept as the default so packs built without `wireFormat` select exactly
109
+ * the same items they always did.
110
+ */
111
+ const LEGACY_ITEM_OVERHEAD_TOKENS = 4;
112
+ /**
113
+ * Tokens the *serialization scaffolding* of one pack item costs on the
114
+ * wire — everything the item contributes except its own content and
115
+ * summary text: the id, score, trust, source, timestamp and tags, the
116
+ * pipe separators (TOON) or field names and quotes (JSON), the optional
117
+ * citation token, and the optional fidelity field.
118
+ *
119
+ * The count is derived from the real serializers (`JSON.stringify`,
120
+ * `packToToon`'s row layout, `serializeCompactRows`'s field order), so
121
+ * the enforced budget tracks the bytes a consumer actually pays for
122
+ * rather than the memory text alone.
123
+ *
124
+ * Two documented approximations, both in the conservative direction:
125
+ * - Compact TOON rows carry numeric placeholder fields (`0`), so the
126
+ * epoch-delta digits are undercounted by ~1 token.
127
+ * - Compact TOON references tags through the header dictionary when
128
+ * that is a net win; this charges the literal tags.
129
+ *
130
+ * @param item — The pack item (or a partial one) being costed.
131
+ * @param wireFormat — Target format; `undefined` returns the legacy
132
+ * flat estimate.
133
+ * @param tokenCounter — Token counter to measure the scaffolding with.
134
+ * @param withCitation — True when the pack is built with `citations`, so
135
+ * the `[mem:xxxx]` token is charged too (it is assigned after budget
136
+ * selection, so the caller must say whether it will exist).
137
+ */
138
+ export function itemWireOverheadTokens(item, wireFormat, tokenCounter = estimateTokens, withCitation = false) {
139
+ if (wireFormat === undefined)
140
+ return LEGACY_ITEM_OVERHEAD_TOKENS;
141
+ // Citations are assigned after budget selection, so the token is
142
+ // rendered on demand for the cost estimate.
143
+ const citation = withCitation ? citationToken(item.id) : undefined;
144
+ if (wireFormat === "json") {
145
+ // Measure the real serializer directly: the same object with the
146
+ // two variable-length text fields blanked out. Field names, quotes,
147
+ // punctuation and the nested `scores` object all land in the count.
148
+ return tokenCounter(JSON.stringify({
149
+ ...item,
150
+ content: "",
151
+ summary: null,
152
+ ...(citation ? { citation } : {}),
153
+ }));
154
+ }
155
+ // Both TOON layouts render the citation as a prefix inside the
156
+ // content field, followed by one space.
157
+ const citedPrefix = citation ? `${citation} ` : "";
158
+ if (wireFormat === "toon") {
159
+ const safeTags = item.tags.join(";");
160
+ return tokenCounter(`${item.id}|${item.score.toFixed(3)}|${item.trust}|${item.source}|${item.updatedAt}|${safeTags}|${citedPrefix}`);
161
+ }
162
+ // "toon-compact": ~id|s|t|c|e|g|ct, plus an optional 8th field.
163
+ const fidelityField = item.fidelity ? `|${item.fidelity}` : "";
164
+ return tokenCounter(`${encodeCompactId(item.id)}|0|0|ui|0|${item.tags.join(";")}|${citedPrefix}${fidelityField}`);
165
+ }
166
+ // ---------------------------------------------------------------------------
167
+ // Content debloating
168
+ // ---------------------------------------------------------------------------
169
+ /**
170
+ * Strip content bloat without losing meaning:
171
+ * - Collapse 3+ newlines to 2
172
+ * - Collapse 3+ spaces to 1
173
+ * - Strip trailing whitespace per line
174
+ * - Remove duplicate consecutive lines
175
+ * - Trim leading/trailing whitespace
176
+ *
177
+ * Returns the debloated text. If the text is short (<100 chars), returns
178
+ * it unchanged — debloating overhead isn't worth it for one-liners.
179
+ */
180
+ export function debloatContent(text) {
181
+ if (!text || text.length === 0)
182
+ return text;
183
+ let result = text;
184
+ // Collapse excessive newlines.
185
+ while (result.includes("\n\n\n")) {
186
+ result = result.replace("\n\n\n", "\n\n");
187
+ }
188
+ // Strip trailing whitespace per line.
189
+ result = result
190
+ .split("\n")
191
+ .map((line) => line.trimEnd())
192
+ .join("\n");
193
+ // Collapse 3+ spaces to 1.
194
+ result = result.replace(/ {3,}/g, " ");
195
+ // Remove duplicate consecutive lines.
196
+ const lines = result.split("\n");
197
+ const deduped = [];
198
+ let prev = "";
199
+ for (const line of lines) {
200
+ const trimmed = line.trim();
201
+ if (trimmed === prev && trimmed.length > 0)
202
+ continue;
203
+ deduped.push(line);
204
+ prev = trimmed;
205
+ }
206
+ result = deduped.join("\n").trim();
207
+ return result;
208
+ }
209
+ // ---------------------------------------------------------------------------
210
+ // Near-duplicate detection
211
+ // ---------------------------------------------------------------------------
212
+ /**
213
+ * Compute the Jaccard similarity between two texts' token sets.
214
+ * Returns a value in [0, 1]. 1 = identical token sets.
215
+ *
216
+ * Note what this CANNOT see: two sentences with the same vocabulary in
217
+ * a different ORDER ("moved X from the runner to the cluster" vs
218
+ * "moved X from the cluster to the runner") score a perfect 1.0. Token
219
+ * sets are order-blind, so Jaccard alone can never tell an inverted
220
+ * fact from a true duplicate — see {@link carriesDistinctFact}.
221
+ */
222
+ function jaccardSimilarity(a, b) {
223
+ const setA = new Set(a.toLowerCase().split(/\W+/).filter(Boolean));
224
+ const setB = new Set(b.toLowerCase().split(/\W+/).filter(Boolean));
225
+ if (setA.size === 0 || setB.size === 0)
226
+ return 0;
227
+ let intersection = 0;
228
+ for (const tok of setA) {
229
+ if (setB.has(tok))
230
+ intersection += 1;
231
+ }
232
+ const union = setA.size + setB.size - intersection;
233
+ return union > 0 ? intersection / union : 0;
234
+ }
235
+ /**
236
+ * Function words stripped when building a text's content-word sequence.
237
+ *
238
+ * Deliberately EXCLUDES every polarity / quantity / modal token
239
+ * (`not, never, no, only, all, both, must, can, will, …`) — those carry
240
+ * the meaning, so they are content for this comparison and a difference
241
+ * in them marks a distinct fact.
242
+ */
243
+ const DEDUP_CONTENT_STOPWORDS = new Set([
244
+ "the",
245
+ "a",
246
+ "an",
247
+ "of",
248
+ "to",
249
+ "is",
250
+ "are",
251
+ "was",
252
+ "were",
253
+ "be",
254
+ "been",
255
+ "being",
256
+ "that",
257
+ "which",
258
+ "and",
259
+ "or",
260
+ "in",
261
+ "on",
262
+ "at",
263
+ "for",
264
+ "with",
265
+ "as",
266
+ "by",
267
+ "it",
268
+ "its",
269
+ "this",
270
+ "these",
271
+ "those",
272
+ "from",
273
+ "into",
274
+ "about",
275
+ "over",
276
+ "under",
277
+ "very",
278
+ "really",
279
+ "just",
280
+ "actually",
281
+ "basically",
282
+ "literally",
283
+ "quite",
284
+ "rather",
285
+ "somewhat",
286
+ "also",
287
+ "even",
288
+ "still",
289
+ "already",
290
+ "however",
291
+ "therefore",
292
+ "then",
293
+ "so",
294
+ "such",
295
+ "via",
296
+ "per",
297
+ "again",
298
+ "here",
299
+ "when",
300
+ "where",
301
+ "how",
302
+ "who",
303
+ "what",
304
+ "why",
305
+ "own",
306
+ "there",
307
+ "their",
308
+ "they",
309
+ "them",
310
+ "we",
311
+ "our",
312
+ "you",
313
+ "your",
314
+ "i",
315
+ "me",
316
+ "my",
317
+ "he",
318
+ "she",
319
+ "his",
320
+ "her",
321
+ "him",
322
+ "us",
323
+ ]);
324
+ /**
325
+ * Minimum Jaccard overlap at which word ORDER becomes a meaningful
326
+ * discriminator. Below it the two texts share so little vocabulary that
327
+ * "the sequences differ" is true of every paraphrase — the semantic
328
+ * dedup pass exists precisely to catch those, so the guard stands down.
329
+ */
330
+ const DISTINCT_FACT_OVERLAP_FLOOR = 0.6;
331
+ /**
332
+ * Every token containing a digit (numbers, quantities, units glued to a
333
+ * value: `3`, `250ms`, `40`, `v2`). Numbers are the single most
334
+ * decision-relevant token class in technical memory, and set-based
335
+ * similarity is blind to their VALUE.
336
+ */
337
+ function numberTokens(text) {
338
+ const out = new Set();
339
+ for (const tok of text.toLowerCase().split(/\W+/)) {
340
+ if (tok.length > 0 && /\d/.test(tok))
341
+ out.add(tok);
342
+ }
343
+ return out;
344
+ }
345
+ /**
346
+ * The text's content words IN ORDER (function words removed, word order
347
+ * preserved). Order is the point: `from the runner to the cluster` and
348
+ * `from the cluster to the runner` produce different sequences while
349
+ * producing identical token sets.
350
+ */
351
+ function contentWordSequence(text) {
352
+ return text
353
+ .toLowerCase()
354
+ .split(/\W+/)
355
+ .filter((tok) => tok.length > 0 && !DEDUP_CONTENT_STOPWORDS.has(tok));
356
+ }
357
+ /** True when the two number-token sets are not equal. */
358
+ function numbersDiffer(a, b) {
359
+ if (a.size !== b.size)
360
+ return true;
361
+ for (const tok of a) {
362
+ if (!b.has(tok))
363
+ return true;
364
+ }
365
+ return false;
366
+ }
367
+ /**
368
+ * Guard applied BEFORE a near-duplicate is dropped: do these two texts
369
+ * actually carry DISTINCT facts, or is the overlap genuine redundancy?
370
+ *
371
+ * Set-membership similarity cannot answer that question. Live-verified
372
+ * failure this exists for (audit finding 4):
373
+ *
374
+ * ```
375
+ * A: "moved the deploy script from the runner to the cluster and set retries to 3."
376
+ * B: "moved the deploy script from the cluster to the runner and set retries to 3."
377
+ * ```
378
+ *
379
+ * Jaccard(A, B) === 1.0 and cosine(A, B) ≈ 0.99 — both dedup passes used
380
+ * to delete B and silently destroy an inverted fact. This guard
381
+ * compares the parts of the texts that actually discriminate:
382
+ *
383
+ * 1. **Numbers.** Any difference in the digit-bearing token sets is a
384
+ * different fact. `pool size 20` vs `pool size 40` is a change, not
385
+ * a duplicate.
386
+ * 2. **Content-word order.** When the vocabulary overlap is high enough
387
+ * for word order to be signal rather than paraphrase noise
388
+ * ({@link DISTINCT_FACT_OVERLAP_FLOOR}), a differing content-word
389
+ * sequence means the roles are swapped — source and destination
390
+ * inverted — which is the most dangerous near-duplicate shape there
391
+ * is.
392
+ *
393
+ * Returns `false` (collapse as before) for genuine redundancy: identical
394
+ * text, or text differing only in function words. Disable with
395
+ * `dedupKeepIfDistinct: false` to restore pure set-membership dedup.
396
+ */
397
+ function carriesDistinctFact(a, b) {
398
+ if (numbersDiffer(numberTokens(a), numberTokens(b)))
399
+ return true;
400
+ if (jaccardSimilarity(a, b) < DISTINCT_FACT_OVERLAP_FLOOR)
401
+ return false;
402
+ const seqA = contentWordSequence(a);
403
+ const seqB = contentWordSequence(b);
404
+ if (seqA.length !== seqB.length)
405
+ return true;
406
+ for (let i = 0; i < seqA.length; i += 1) {
407
+ if (seqA[i] !== seqB[i])
408
+ return true;
409
+ }
410
+ return false;
411
+ }
412
+ // ---------------------------------------------------------------------------
413
+ // Pack builder
414
+ // ---------------------------------------------------------------------------
415
+ const DEFAULT_DEDUP_THRESHOLD = 0.85;
416
+ /**
417
+ * Derived pack limits.
418
+ *
419
+ * `contextPack()` derives how many items to retrieve from the caller's
420
+ * `tokenBudget`. The old rule clamped the derived value at 50, which made
421
+ * a 200k-token context window buy exactly the same evidence volume as a
422
+ * 4k one — the window scaled, the memory layer didn't. The ceiling now
423
+ * grows with the budget up to {@link PACK_ITEM_LIMIT_MAX}, which is what
424
+ * a large window is actually paying for.
425
+ */
426
+ /** Budget assumed per packed item when deriving the item limit. */
427
+ export const PACK_TOKENS_PER_ITEM = 80;
428
+ /** Floor on the derived item limit (tiny budgets still get a real pack). */
429
+ export const PACK_ITEM_LIMIT_MIN = 8;
430
+ /**
431
+ * Absolute ceiling on the derived item limit, and therefore on the
432
+ * candidate pool (`limit * PACK_CANDIDATE_DEPTH_FACTOR` rows come back
433
+ * from storage). Bounds memory use no matter how large a budget a caller
434
+ * passes; 500 items of evidence is already more than fits in any
435
+ * practical prompt once the token budget itself is enforced.
436
+ */
437
+ export const PACK_ITEM_LIMIT_MAX = 500;
438
+ /**
439
+ * How many candidates the pack path fetches per item slot. A larger
440
+ * budget has to retrieve more candidates for budget trimming to have
441
+ * material to work with.
442
+ */
443
+ export const PACK_CANDIDATE_DEPTH_FACTOR = 4;
444
+ /**
445
+ * Derive the pack item limit from a token budget.
446
+ *
447
+ * Scales linearly (`tokenBudget / PACK_TOKENS_PER_ITEM`) between
448
+ * {@link PACK_ITEM_LIMIT_MIN} and {@link PACK_ITEM_LIMIT_MAX}. Budgets up
449
+ * to 4,000 tokens derive the historical value of 50, so small-budget
450
+ * packs are unchanged; larger windows now buy proportionally more
451
+ * evidence.
452
+ */
453
+ export function derivePackItemLimit(tokenBudget) {
454
+ const perItem = Math.floor(tokenBudget / PACK_TOKENS_PER_ITEM);
455
+ return Math.min(PACK_ITEM_LIMIT_MAX, Math.max(PACK_ITEM_LIMIT_MIN, perItem));
456
+ }
457
+ /**
458
+ * Derive the candidate depth (how many rows retrieval fetches) for a
459
+ * derived pack item limit.
460
+ */
461
+ export function derivePackCandidateDepth(limit) {
462
+ return limit * PACK_CANDIDATE_DEPTH_FACTOR;
463
+ }
464
+ /**
465
+ * Re-render an oversized item through {@link compressText} so it can be
466
+ * admitted under the budget instead of dropped.
467
+ *
468
+ * Returns `null` — meaning "skip the item", exactly as the budget loop
469
+ * did before compression existed — when compression is disabled, when
470
+ * the compressed form is empty, when it did not actually save any tokens
471
+ * (code-like content, or text made entirely of stop words), OR when the
472
+ * compressed form still does not fit the remaining budget. Otherwise
473
+ * returns the shrunken item, its new token count, and how many tokens
474
+ * compression saved.
475
+ */
476
+ function compressForBudget(prepared, opts) {
477
+ if (!opts.enabled)
478
+ return null;
479
+ // Image-memory guard: compression must never strand an image. The
480
+ // `[img:…]` token is pinned by `compressText` itself, so this is a
481
+ // belt-and-braces check — but a future compression variant that
482
+ // trims differently would silently break image recall, and the cost
483
+ // of checking is one comparison. If the token is gone, the item keeps
484
+ // its uncompressed form (the same "no win → skip compression" rule
485
+ // the budget loop already applies).
486
+ const beforeRefs = imageRefsIn(prepared.item.content);
487
+ const compressedContent = compressText(prepared.item.content, opts.level);
488
+ if (beforeRefs.length > 0 && !imageRefsIn(compressedContent).length) {
489
+ return null;
490
+ }
491
+ // An empty (or all-stopword) result carries no information — skip the
492
+ // item rather than inject a blank row.
493
+ if (compressedContent.trim().length === 0)
494
+ return null;
495
+ const compressedSummary = prepared.item.summary !== null
496
+ ? compressText(prepared.item.summary, opts.level) || null
497
+ : null;
498
+ const contentTokens = opts.tokenCounter(compressedContent);
499
+ const summaryTokens = compressedSummary
500
+ ? opts.tokenCounter(compressedSummary)
501
+ : 0;
502
+ const before = prepared.contentTokens + prepared.summaryTokens;
503
+ const after = contentTokens + summaryTokens;
504
+ // No win: keep the original text (and let the caller skip the item)
505
+ // rather than pay for a lossy rewrite that bought nothing.
506
+ if (after >= before)
507
+ return null;
508
+ const tokenCount = after + prepared.overhead;
509
+ // The compressed form must actually fit. Compression buys headroom, it
510
+ // does not grant an exception to the budget.
511
+ if (opts.tokensUsed + tokenCount > opts.tokenBudget)
512
+ return null;
513
+ return {
514
+ item: {
515
+ ...prepared.item,
516
+ content: compressedContent,
517
+ summary: compressedSummary,
518
+ },
519
+ tokenCount,
520
+ compressionSaved: before - after,
521
+ };
522
+ }
523
+ /**
524
+ * Cache-prefix stability contract.
525
+ *
526
+ * LLM providers discount cached input tokens (up to ~90%) only when the
527
+ * prompt prefix is byte-identical across calls. Everything this module
528
+ * emits is built to be byte-stable for identical inputs:
529
+ * - evidence items are U-shape ordered (lost-in-the-middle mitigation):
530
+ * the highest-scoring item comes FIRST, the second-best item is pinned
531
+ * LAST (the two most-attended positions), and the rest follow in
532
+ * descending score order. Deterministic: it reuses the score-desc /
533
+ * node-id-tiebreak sort below, so equal scores can never surface
534
+ * input-order noise;
535
+ * - the `scores` breakdown is re-emitted in a fixed key order
536
+ * (keyword, semantic, entity, hybrid) no matter which retrieval legs
537
+ * produced it — fusion builds that object in leg-dependent order;
538
+ * - serializers use a fixed section/field order and always emit every
539
+ * section (empty packs still emit their headers), so a missing section
540
+ * can never shift the bytes of a later one. TOON serializers append one
541
+ * fixed trailer line (`# q=<query>`) after the last evidence row so a
542
+ * reader scanning bottom-up re-anchors on the query.
543
+ *
544
+ * Rule of thumb for future edits: compress *contents* all you want, but
545
+ * never reorder *layout* — layout churn breaks cache prefixes and raises
546
+ * bills even while "saving" tokens.
547
+ */
548
+ /** Fixed key order for the per-item score breakdown (see contract above). */
549
+ const SCORE_KEY_ORDER = ["keyword", "semantic", "entity", "hybrid"];
550
+ /**
551
+ * Re-emit a score breakdown in the fixed {@link SCORE_KEY_ORDER} order.
552
+ * Unknown/future keys are appended afterwards in sorted order so nothing
553
+ * is dropped and the layout stays deterministic.
554
+ */
555
+ function normalizeScores(scores) {
556
+ const out = {};
557
+ if (scores) {
558
+ for (const key of SCORE_KEY_ORDER) {
559
+ if (scores[key] !== undefined)
560
+ out[key] = roundScore(scores[key]);
561
+ }
562
+ for (const key of Object.keys(scores).sort()) {
563
+ if (!(key in out))
564
+ out[key] = roundScore(scores[key]);
565
+ }
566
+ }
567
+ return out;
568
+ }
569
+ /**
570
+ * Round a score to 3 decimals.
571
+ *
572
+ * Cache-prefix stability: fusion scores are float64 math, so two runs
573
+ * over the same memories can differ in the last bits
574
+ * (0.9499999999999999 vs 0.95). Rounding at build time keeps JSON and
575
+ * TOON output byte-identical across rebuilds, which is what lets
576
+ * provider prompt caches hit on the evidence block.
577
+ */
578
+ export function roundScore(score) {
579
+ return Math.round(score * 1000) / 1000;
580
+ }
581
+ /**
582
+ * Summary elision: drop the summary when it costs at least this fraction
583
+ * of its own content's tokens. Below that, a summary is a genuinely
584
+ * cheaper carrier of the fact; above it, the full content is the better
585
+ * deal on its own.
586
+ */
587
+ const SUMMARY_ELISION_RATIO = 0.5;
588
+ /**
589
+ * Summary elision: drop the summary when its token-set Jaccard similarity
590
+ * with its own content reaches this — i.e. it's a compressed copy, not an
591
+ * independent viewpoint. 0.45 catches the classic extractive case (a
592
+ * summary that is a prefix restatement of its own content sits at
593
+ * ~0.4-0.5 because the content adds extra vocabulary); genuinely
594
+ * condensed summaries land far below.
595
+ */
596
+ const SUMMARY_ELISION_JACCARD = 0.45;
597
+ /**
598
+ * Semantic dedup: cosine similarity at or above this counts as a
599
+ * paraphrased duplicate. Deliberately high (near-identical vectors) so
600
+ * distinct-but-related facts are never dropped.
601
+ */
602
+ const DEFAULT_SEMANTIC_DEDUP_THRESHOLD = 0.9;
603
+ export function buildContextPack(opts) {
604
+ const { query, namespace, tokenBudget, items, trust = "local", source = "session", includeSummary = true, tokenCounter = estimateTokens, debloat = true, dedup = true, dedupThreshold = DEFAULT_DEDUP_THRESHOLD, dedupKeepIfDistinct = true, embeddings, semanticDedupThreshold = DEFAULT_SEMANTIC_DEDUP_THRESHOLD, citations = false, lessons, fidelity, wireFormat, compression, images: imagesOption, } = opts;
605
+ // Sort by descending score, node id as the final tiebreak (cache-prefix
606
+ // stability: equal scores must not surface input-order noise).
607
+ const sorted = [...items].sort(compareScoredMemories);
608
+ // Fidelity level: "adaptive" routes per-query; a fixed level renders
609
+ // every item at that level; undefined keeps the historical default of
610
+ // L3 verbatim text.
611
+ const resolvedLevel = fidelity === "adaptive" ? routeFidelity(query).level : fidelity;
612
+ const fidelityAdaptive = fidelity === "adaptive";
613
+ // Image attachments: default ON when any candidate is an image
614
+ // memory. Explicit `false` opts out; a pack with no image nodes is
615
+ // byte-identical to before either way (the field is simply absent).
616
+ const imagesEnabled = imagesOption ??
617
+ items.some((scored) => imageAttachmentOf(scored.node) !== null);
618
+ // Phase 1: debloat, elide redundant summaries, and compute token counts.
619
+ //
620
+ // Tokenization budget per item (perf): `tokenCounter` runs at most four
621
+ // times — packed content, packed summary (if present), raw content, and
622
+ // raw summary (if included). The previous revision tokenized the packed
623
+ // forms a SECOND time after building the item (6 calls/item worst case);
624
+ // the counts are now computed once and reused everywhere below.
625
+ const prepared = sorted.map((scored) => {
626
+ // Fidelity rendering happens BEFORE debloat: a summary or entity
627
+ // list has no filler worth stripping, and applying it first keeps
628
+ // the token math consistent with the budget trimming below.
629
+ const rawContent = resolvedLevel === undefined
630
+ ? scored.node.content
631
+ : levelText(scored.node, resolvedLevel);
632
+ const content = debloat ? debloatContent(rawContent) : rawContent;
633
+ const rawSummaryText = scored.node.summary || "";
634
+ let summary = includeSummary
635
+ ? (debloat ? debloatContent(rawSummaryText) : rawSummaryText) || null
636
+ : null;
637
+ // Token counts for the PACKED forms — computed once, reused.
638
+ const contentTokens = tokenCounter(content);
639
+ let summaryTokens = summary ? tokenCounter(summary) : 0;
640
+ // Summary elision: a summary that restates its own content is paid
641
+ // for twice on the wire while adding no new information. Drop it when
642
+ // it costs half as many tokens as the full content OR lexically
643
+ // overlaps the content enough to be a compressed copy. The full
644
+ // content always stays, so no information is lost — only the lossy
645
+ // duplicate is. Cost note: the Jaccard leg runs only when the ratio
646
+ // leg misses, and both operands were just tokenized anyway, so this
647
+ // adds one linear scan per item at most — negligible next to
648
+ // `tokenCounter`.
649
+ if (summary &&
650
+ (summaryTokens >= SUMMARY_ELISION_RATIO * contentTokens ||
651
+ jaccardSimilarity(summary, content) >= SUMMARY_ELISION_JACCARD)) {
652
+ summary = null;
653
+ summaryTokens = 0;
654
+ }
655
+ // Token counts for the RAW forms — used only to measure how much
656
+ // debloating saved on this item. The raw summary is only counted when
657
+ // the summary SURVIVED elision: otherwise its tokens would be
658
+ // double-counted (once via `summaryTokens = 0`, once via the raw
659
+ // side), inflating `tokensSaved` with savings that came from
660
+ // elision, not debloating.
661
+ const rawContentTokens = tokenCounter(rawContent);
662
+ const rawSummaryTokens = summary !== null ? tokenCounter(rawSummaryText) : 0;
663
+ const debloatSaved = Math.max(0, rawContentTokens + rawSummaryTokens - (contentTokens + summaryTokens));
664
+ const item = {
665
+ id: scored.node.id,
666
+ content,
667
+ summary,
668
+ // Present only when the pack was built with `fidelity` — keeps the
669
+ // default object shape byte-identical to before.
670
+ ...(resolvedLevel !== undefined && { fidelity: resolvedLevel }),
671
+ score: roundScore(scored.score),
672
+ // Fixed key order — fusion builds this object in leg-dependent
673
+ // order, which would otherwise shift JSON bytes between calls.
674
+ scores: normalizeScores(scored.scores),
675
+ trust,
676
+ source,
677
+ // Per-node provenance for compact TOON encoding
678
+ trustScore: scored.node.trustScore,
679
+ nodeSource: scored.node.source,
680
+ // Provenance-trust layer: tier + read-time trust flag, so every
681
+ // pack item handed to an agent is traceable to its channel and
682
+ // low-trust items are explicitly marked.
683
+ provenance: scored.node.provenance,
684
+ untrustedSource: isUntrustedForAgent(scored.node),
685
+ tags: [...scored.node.tags],
686
+ updatedAt: new Date(scored.node.updatedAt).toISOString(),
687
+ // Image reference(s), when this item is an image memory and the
688
+ // pack carries images. The field is omitted entirely otherwise so
689
+ // a text-only pack stays byte-identical.
690
+ ...(imagesEnabled && imageAttachmentOf(scored.node)
691
+ ? { images: [imageAttachmentOf(scored.node)] }
692
+ : {}),
693
+ };
694
+ // Per-item wire overhead: the flat legacy estimate unless the caller
695
+ // asked for wire-accurate accounting, in which case the id / score /
696
+ // trust / source / timestamp / tags / separators / citation of the
697
+ // target format are charged too.
698
+ const overhead = itemWireOverheadTokens(item, wireFormat, tokenCounter, citations);
699
+ const tokenCount = contentTokens + summaryTokens + overhead;
700
+ return {
701
+ item,
702
+ tokenCount,
703
+ debloatSaved,
704
+ contentTokens,
705
+ summaryTokens,
706
+ overhead,
707
+ };
708
+ });
709
+ // Phase 2: dedup — skip items too similar to one already in the pack.
710
+ //
711
+ // Two complementary passes over the same candidate stream (sorted by
712
+ // descending score, so the strongest item of any duplicate cluster is
713
+ // always the survivor):
714
+ // a) Lexical (existing): token-set Jaccard >= dedupThreshold catches
715
+ // shared-vocabulary copies.
716
+ // b) Semantic (opt-in via `embeddings`): stored-vector cosine >=
717
+ // semanticDedupThreshold catches paraphrases with little lexical
718
+ // overlap. Vectors are passed in by the caller from data it already
719
+ // has in memory (hybrid search), so this adds zero embedding calls.
720
+ //
721
+ // Both passes are gated by `carriesDistinctFact`: a similarity score
722
+ // alone is order-blind and value-blind, so it collapses an inverted
723
+ // fact (source/destination swapped) or a changed number (pool size 20
724
+ // vs 40) into its twin. The guard re-checks the discriminating tokens
725
+ // before any drop, so the pack only ever discards genuine redundancy.
726
+ const output = [];
727
+ // Kept items' vectors plus their rendered text, so the semantic pass
728
+ // can run the same discriminating-token guard the lexical pass uses
729
+ // (a cosine score is as order-blind and value-blind as a Jaccard one).
730
+ const outputVectors = [];
731
+ let tokensUsed = 0;
732
+ let tokensSaved = 0;
733
+ for (const preparedItem of prepared) {
734
+ let { item, tokenCount, debloatSaved } = preparedItem; // eslint-disable-line prefer-const -- reassigned after compression
735
+ // Pass a: lexical near-duplicate check.
736
+ if (dedup) {
737
+ let isDup = false;
738
+ for (const existing of output) {
739
+ if (jaccardSimilarity(item.content, existing.content) >= dedupThreshold &&
740
+ !(dedupKeepIfDistinct &&
741
+ carriesDistinctFact(item.content, existing.content))) {
742
+ isDup = true;
743
+ break;
744
+ }
745
+ }
746
+ if (isDup) {
747
+ // The entire item was dropped — count its tokens as saved.
748
+ tokensSaved += tokenCount;
749
+ continue;
750
+ }
751
+ }
752
+ // Pass b: semantic paraphrase check (only when the caller supplied
753
+ // vectors). Items without a vector can't be compared and stay in.
754
+ if (embeddings) {
755
+ const vector = embeddings.get(item.id);
756
+ if (vector) {
757
+ let isParaphrase = false;
758
+ for (const kept of outputVectors) {
759
+ if (cosineSimilarity(vector, kept.vector) >= semanticDedupThreshold &&
760
+ !(dedupKeepIfDistinct &&
761
+ carriesDistinctFact(item.content, kept.content))) {
762
+ isParaphrase = true;
763
+ break;
764
+ }
765
+ }
766
+ if (isParaphrase) {
767
+ tokensSaved += tokenCount;
768
+ continue;
769
+ }
770
+ }
771
+ }
772
+ // Token budget check.
773
+ //
774
+ // An oversized item is not thrown away on the first miss: when
775
+ // compression is enabled, the SAME item is re-rendered through
776
+ // `compressText` and admitted if the compressed form fits. Only an
777
+ // item that is still too big after compression is skipped — so a
778
+ // tight budget loses a memory's filler, not the memory.
779
+ let compressionSaved = 0;
780
+ if (tokensUsed + tokenCount > tokenBudget && output.length > 0) {
781
+ const shrunk = compressForBudget(preparedItem, {
782
+ // Composition guard (audit finding 7f): an item already
783
+ // rendered at a compressed fidelity level (L1 facts / L2
784
+ // extractive summary) must not be stopword-filtered on top of
785
+ // that loss — the connective tissue is what keeps the summary
786
+ // answerable. L3 verbatim (or no fidelity at all) is the only
787
+ // case where budget compression is a net win.
788
+ enabled: compression?.enabled === true &&
789
+ shouldCompressFidelityItem(item.fidelity),
790
+ level: compression?.level ?? "light",
791
+ tokenCounter,
792
+ tokensUsed,
793
+ tokenBudget,
794
+ });
795
+ if (!shrunk)
796
+ continue;
797
+ item = shrunk.item;
798
+ tokenCount = shrunk.tokenCount;
799
+ compressionSaved = shrunk.compressionSaved;
800
+ }
801
+ // Track savings from debloating (+ compression, when it ran).
802
+ tokensSaved += debloatSaved + compressionSaved;
803
+ output.push(item);
804
+ tokensUsed += tokenCount;
805
+ // Remember this item's vector for later semantic comparisons.
806
+ const vector = embeddings?.get(item.id);
807
+ if (vector)
808
+ outputVectors.push({ id: item.id, vector, content: item.content });
809
+ }
810
+ // U-shape evidence ordering (lost-in-the-middle mitigation).
811
+ //
812
+ // Reader attention over long contexts is U-shaped: strongest at the
813
+ // start and end, weakest in the middle (Liu et al. 2023). The most
814
+ // useful position is first, the second-most is last — never a dumping
815
+ // ground. `output` is a subsequence of `sorted` above (dedup and the
816
+ // budget pass only ever REMOVE items), so it is still in score-desc /
817
+ // node-id-tiebreak order: the first two entries are exactly best and
818
+ // 2nd-best, no re-sort needed. With 0-2 items plain descending order
819
+ // already satisfies best-first/2nd-last, so the list is left untouched.
820
+ // Token counts are unaffected: this only permutes the final list.
821
+ const ordered = output.length >= 3 ? [output[0], ...output.slice(2), output[1]] : output;
822
+ // Memory-grounded citations: every rendered item gets a `[mem:xxxx]`
823
+ // token, unique within the pack (git-style lengthening on collision).
824
+ // Assigned AFTER ordering/trimming so tokens are unique among exactly
825
+ // the items the consumer will see.
826
+ if (citations) {
827
+ const tokens = assignCitationTokens(ordered.map((item) => item.id));
828
+ for (const item of ordered) {
829
+ item.citation = tokens.get(item.id);
830
+ }
831
+ }
832
+ // Item id -> source node, for fidelity verbatim-token accounting.
833
+ const itemById = new Map(sorted.map((scored) => [scored.node.id, scored.node]));
834
+ return {
835
+ schema: CONTEXT_PACK_SCHEMA,
836
+ query,
837
+ namespace,
838
+ tokenBudget,
839
+ items: ordered,
840
+ tokensSaved,
841
+ ...(lessons && lessons.length > 0 ? { lessons } : {}),
842
+ // Fidelity metadata: contentTokens is what the packed contents cost
843
+ // at the resolved level; verbatimTokens is what the same items would
844
+ // have cost at L3. Absent unless the pack was built with `fidelity`.
845
+ ...(resolvedLevel !== undefined
846
+ ? {
847
+ fidelity: {
848
+ level: resolvedLevel,
849
+ adaptive: fidelityAdaptive,
850
+ contentTokens: ordered.reduce((sum, item) => sum + tokenCounter(item.content), 0),
851
+ verbatimTokens: ordered.reduce((sum, item) => {
852
+ const node = itemById.get(item.id);
853
+ return (sum + tokenCounter(node ? levelText(node, "L3") : item.content));
854
+ }, 0),
855
+ },
856
+ }
857
+ : {}),
858
+ };
859
+ }
860
+ /**
861
+ * Serialize a ContextPack to TOON (Token-Optimized Object Notation).
862
+ *
863
+ * TOON is a compact, human-readable alternative to JSON for highly
864
+ * structured data. Each line is a pipe-delimited record with a stable
865
+ * field order: `id|score|trust|source|updatedAt|content|tags`.
866
+ *
867
+ * Format:
868
+ * # ai-trio.memos.context-pack.v1
869
+ * # toon:pipe-delimited|q=<query>|n=<namespace>|b=<tokenBudget>|s=<tokensSaved>
870
+ * # fields: id|score|trust|source|updatedAt|tags|content
871
+ * mem_abc123|0.95|local|user_input|2026-06-18T12:00:00Z|user;preference|User likes dark mode
872
+ * mem_def456|0.87|local|user_input|2026-06-18T11:30:00Z|work|User works at Google
873
+ * # q=<query>
874
+ *
875
+ * The trailer line repeats the query AFTER the evidence block (the header
876
+ * already states it BEFORE) — a reader scanning bottom-up re-anchors on
877
+ * the query. It is a `#` comment line, so line-based consumers skip it.
878
+ *
879
+ * Token savings vs JSON: 60-90% on typical context packs (where JSON
880
+ * overhead like braces, quotes, and field names dominate).
881
+ *
882
+ * Fidelity: when the pack was built with the `fidelity` option, a
883
+ * `# fidelity=<level>` header line is emitted so the rendered level is
884
+ * visible (and cacheable) in the serialized form.
885
+ *
886
+ * @param pack — The ContextPack to serialize.
887
+ * @returns A TOON-formatted string.
888
+ */
889
+ export function packToToon(pack) {
890
+ const lines = [];
891
+ // Header lines (start with # so consumers can skip them)
892
+ lines.push(`# ${pack.schema}`);
893
+ lines.push(`# toon:pipe-delimited|q=${pack.query}|n=${pack.namespace}|b=${pack.tokenBudget}|s=${pack.tokensSaved}`);
894
+ lines.push("# fields: id|score|trust|source|updatedAt|tags|content");
895
+ // Fidelity header: records the level item contents were rendered at
896
+ // (cache-prefix stability — the layout only ever grows this line when
897
+ // the pack was actually built with `fidelity`).
898
+ if (pack.fidelity) {
899
+ lines.push(`# fidelity=${pack.fidelity.level}`);
900
+ }
901
+ for (const item of pack.items) {
902
+ // Escape pipe characters in content by replacing with ¦
903
+ const safeContent = item.content.replace(/\|/g, "¦").replace(/\n/g, " ");
904
+ const safeTags = item.tags.join(";");
905
+ // Citation tokens render ahead of the content (not as a new field —
906
+ // the pipe layout stays fixed so existing line parsers keep working).
907
+ const cited = item.citation
908
+ ? `${item.citation} ${safeContent}`
909
+ : safeContent;
910
+ // Image references render TRAILING the content, for the same reason:
911
+ // adding an 8th column would break every existing parser, and the
912
+ // code documents that the fixed layout is what keeps provider prompt
913
+ // caches warm. A consumer that cares parses the token out; one that
914
+ // doesn't ignores it.
915
+ const withImages = cited + imageRefSuffix(item);
916
+ lines.push(`${item.id}|${item.score.toFixed(3)}|${item.trust}|${item.source}|${item.updatedAt}|${safeTags}|${withImages}`);
917
+ }
918
+ // Query trailer: repeat the query after the evidence block (see the
919
+ // docstring). Always emitted — fixed layout keeps cache prefixes stable.
920
+ lines.push(`# q=${pack.query}`);
921
+ // Operating-instructions section: procedural lessons ride after the
922
+ // trailer as comment lines, so line-based TOON consumers skip them
923
+ // unless they opt into lessons.
924
+ appendLessonsSection(lines, pack.lessons);
925
+ return lines.join("\n");
926
+ }
927
+ /**
928
+ * The `[img:<ref>]` suffix appended to a TOON row for an image item.
929
+ *
930
+ * Deliberately NOT a new column: the pipe layout is documented as fixed
931
+ * so provider prompt caches stay warm, and an 8th column would break
932
+ * every existing parser. A consumer that cares parses the token out;
933
+ * one that doesn't ignores it. Returns "" for text items, so a
934
+ * text-only pack renders byte-identically to before.
935
+ */
936
+ function imageRefSuffix(item) {
937
+ if (!item.images || item.images.length === 0)
938
+ return "";
939
+ return item.images.map((image) => ` [img:${image.ref}]`).join("");
940
+ }
941
+ /** Compact-wire variant of {@link imageRefSuffix} (same token, same position). */
942
+ function compactImageRefSuffix(images) {
943
+ return imageRefSuffix({ images });
944
+ }
945
+ /**
946
+ * Every `[img:<ref>]` / `[image:<ref>]` token in a text block.
947
+ *
948
+ * Used by the budget-compression guard to verify a compressed caption
949
+ * still carries its image reference. Mirrors the pinning regex in
950
+ * `compression.ts` — the two must stay in lockstep, since one pins the
951
+ * tokens and the other proves they survived.
952
+ */
953
+ function imageRefsIn(text) {
954
+ return text.match(/\[(?:img|image):[A-Za-z0-9_-]{4,}\]/g) ?? [];
955
+ }
956
+ /**
957
+ * Append the procedural-lessons "operating instructions" section to a
958
+ * verbose-TOON line buffer. Lessons render as `#`-comment lines so
959
+ * existing parsers ignore them; each carries its own `[lesson:…]`
960
+ * token for stable reference.
961
+ */
962
+ function appendLessonsSection(lines, lessons) {
963
+ if (!lessons || lessons.length === 0)
964
+ return;
965
+ lines.push("# operating-instructions: learned lessons");
966
+ for (const lesson of lessons) {
967
+ const text = lesson.lesson.replace(/\|/g, "¦").replace(/\n/g, " ");
968
+ const context = lesson.context
969
+ ? ` (when: ${lesson.context.replace(/\|/g, "¦").replace(/\n/g, " ")})`
970
+ : "";
971
+ lines.push(`# - ${lessonCitationToken(lesson.id)} ${text}${context} [score ${lesson.score.toFixed(2)}, ${lesson.useCount} uses]`);
972
+ }
973
+ }
974
+ /**
975
+ * Serialize an array of ScoredMemory to TOON (Token-Optimized Object Notation).
976
+ *
977
+ * @param results — Array of scored memories from a search.
978
+ * @returns A TOON-formatted string.
979
+ */
980
+ export function searchResultsToToon(results) {
981
+ const lines = [];
982
+ lines.push("# memos.search.v1");
983
+ lines.push("# toon:pipe-delimited");
984
+ lines.push("# fields: id|score|trust|source|updatedAt|tags|content");
985
+ for (const r of results) {
986
+ const safeContent = r.node.content.replace(/\|/g, "¦").replace(/\n/g, " ");
987
+ const safeTags = r.node.tags.join(";");
988
+ lines.push(`${r.node.id}|${r.score.toFixed(3)}|${r.node.source}|${r.node.source}|${new Date(r.node.updatedAt).toISOString()}|${safeTags}|${safeContent}`);
989
+ }
990
+ return lines.join("\n");
991
+ }
992
+ /**
993
+ * Serialize a ContextPack in the requested format.
994
+ *
995
+ * @param pack — The ContextPack to serialize.
996
+ * @param format — "json" returns the pack object as-is.
997
+ * "toon" returns verbose pipe-delimited.
998
+ * "toon-compact" returns ultra-compact pipe-delimited.
999
+ * @param tokenCounter — Optional custom token counter for budget
1000
+ * enforcement in compact mode.
1001
+ * @param maxTokens — Optional hard ceiling on the rendered token count,
1002
+ * honoured by the compact TOON serializer (`packToToonCompact`). Ignored
1003
+ * for "json" (an object has no length to trim) and "toon".
1004
+ * @returns The pack object (for json) or a string (for toon formats).
1005
+ */
1006
+ export function serializeContextPack(pack, format, tokenCounter, maxTokens) {
1007
+ if (format === "toon-compact") {
1008
+ return packToToonCompact(pack, tokenCounter, maxTokens);
1009
+ }
1010
+ if (format === "toon") {
1011
+ return packToToon(pack);
1012
+ }
1013
+ return pack;
1014
+ }
1015
+ // ---------------------------------------------------------------------------
1016
+ // Compact TOON serialization (v2 wire format)
1017
+ // ---------------------------------------------------------------------------
1018
+ /**
1019
+ * Single-char codes for memory source (provenance).
1020
+ */
1021
+ const SOURCE_CODE = {
1022
+ user_input: "ui",
1023
+ agent_inferred: "ai",
1024
+ external_data: "ex",
1025
+ system: "sy",
1026
+ };
1027
+ /** Reverse map: code -> source name. */
1028
+ const CODE_SOURCE = Object.fromEntries(Object.entries(SOURCE_CODE).map(([name, code]) => [code, name]));
1029
+ /**
1030
+ * Alphabet for compact ID encoding: base64url characters. `~` is reserved
1031
+ * as the marker/escape character, so it never appears in encoded bodies.
1032
+ */
1033
+ const ID_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1034
+ /**
1035
+ * Losslessly shorten a memory ID for the wire.
1036
+ *
1037
+ * Canonical dashed UUIDs are re-packed as base64url under a `~` marker
1038
+ * (36 chars -> 24). Other pure-hex even-length IDs (except 32-hex, which
1039
+ * would be indistinguishable from UUID bodies on decode) are packed the
1040
+ * same way. Any other shape passes through unchanged; a literal leading
1041
+ * `~` is escaped as `~~` so the decoder can always tell encoded from raw.
1042
+ *
1043
+ * Decode rule: a decoded hex body of exactly 32 chars is re-dashed to the
1044
+ * canonical 8-4-4-4-12 UUID form; anything shorter stays plain hex. That
1045
+ * is why bare 32-hex IDs are excluded from encoding above.
1046
+ */
1047
+ export function encodeCompactId(id) {
1048
+ if (id.startsWith("~"))
1049
+ return `~${id}`;
1050
+ const dashedUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
1051
+ const plainHex = !dashedUuid &&
1052
+ /^[0-9a-f]+$/i.test(id) &&
1053
+ id.length % 2 === 0 &&
1054
+ id.length >= 2 &&
1055
+ id.length !== 32;
1056
+ if (!dashedUuid && !plainHex)
1057
+ return id;
1058
+ const hex = id.replace(/-/g, "").toLowerCase();
1059
+ const bytes = new Uint8Array(hex.length / 2);
1060
+ for (let i = 0; i < bytes.length; i += 1) {
1061
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
1062
+ }
1063
+ let out = "";
1064
+ let bits = 0;
1065
+ let acc = 0;
1066
+ for (const b of bytes) {
1067
+ acc = (acc << 8) | b;
1068
+ bits += 8;
1069
+ while (bits >= 6) {
1070
+ bits -= 6;
1071
+ out += ID_ALPHABET[(acc >> bits) & 63];
1072
+ }
1073
+ }
1074
+ if (bits > 0)
1075
+ out += ID_ALPHABET[(acc << (6 - bits)) & 63];
1076
+ return `~${out}`;
1077
+ }
1078
+ /**
1079
+ * Inverse of {@link encodeCompactId}. Unknown shapes are returned as-is so
1080
+ * hand-written or foreign IDs survive untouched.
1081
+ */
1082
+ export function decodeCompactId(wire) {
1083
+ if (wire.startsWith("~~"))
1084
+ return wire.slice(1);
1085
+ if (!wire.startsWith("~"))
1086
+ return wire;
1087
+ const body = wire.slice(1);
1088
+ let bits = 0;
1089
+ let acc = 0;
1090
+ const bytes = [];
1091
+ for (const ch of body) {
1092
+ const val = ID_ALPHABET.indexOf(ch);
1093
+ if (val < 0)
1094
+ return wire; // not our encoding — treat as raw
1095
+ acc = (acc << 6) | val;
1096
+ bits += 6;
1097
+ if (bits >= 8) {
1098
+ bits -= 8;
1099
+ bytes.push((acc >> bits) & 255);
1100
+ }
1101
+ }
1102
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
1103
+ // 32-hex bodies came from canonical dashed UUIDs — restore the dashes.
1104
+ if (hex.length === 32) {
1105
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1106
+ }
1107
+ return hex;
1108
+ }
1109
+ /**
1110
+ * Parse a `k=v` header parameter, returning `fallback` when absent.
1111
+ * Values may not contain `|` (the header field separator).
1112
+ */
1113
+ function headerParam(line, key, fallback) {
1114
+ for (const part of line.split("|")) {
1115
+ if (part.startsWith(`${key}=`))
1116
+ return part.slice(key.length + 1);
1117
+ }
1118
+ return fallback;
1119
+ }
1120
+ /**
1121
+ * Serialize a ContextPack to ultra-compact TOON format (v2).
1122
+ *
1123
+ * Layout:
1124
+ * # memos.search.v2|e=<minEpoch>|t=<tagDict>
1125
+ * # f=~id|s|t|c|e|g|ct
1126
+ * ~Ab3xZ9|950|9|ui|-86400|0;2|User likes dark mode
1127
+ * # q=<query> <- pack path only: query trailer for
1128
+ * bottom-up readers; skipped by the parser
1129
+ *
1130
+ * Optimizations vs the v1 compact format:
1131
+ * 1. IDs: pure-hex IDs (incl. dashed UUIDs) re-packed to base64url with a
1132
+ * `~` prefix (36-char UUID -> 24 chars); other shapes pass through.
1133
+ * 2. Timestamps: delta-encoded from a header epoch anchor `e=` (rows with
1134
+ * the smallest timestamp store 0).
1135
+ * 3. Tags: a per-call dictionary in the header (`t=a,b,c`), rows reference
1136
+ * tags by index; falls back to literal tags when the dictionary is not
1137
+ * a net win.
1138
+ * 4. Carried over from v1: integer scores (x1000), 0-9 trust digit,
1139
+ * 2-char source codes, single-char field legend.
1140
+ *
1141
+ * Round-trip: `parseToonCompact` restores the original IDs (dashed UUIDs
1142
+ * re-dashed), absolute timestamps, and tag names exactly.
1143
+ *
1144
+ * @param pack — The ContextPack to serialize.
1145
+ * @param tokenCounter — Optional custom token counter. Used only when
1146
+ * `maxTokens` is set, to measure the rendered wire cost.
1147
+ * @param maxTokens — Optional hard ceiling on the rendered token count.
1148
+ * When the full pack exceeds it, the items are first re-rendered through
1149
+ * `compressText` and re-checked: if the compressed pack fits, it is
1150
+ * returned whole — COMPRESS BEFORE DROP. Only when compression is not
1151
+ * enough are items dropped lowest-score-first until the output fits, and
1152
+ * those drops are taken from the ALREADY compressed set, so no fact is
1153
+ * ever discarded before it has been given the chance to fit. Trimming is
1154
+ * by SCORE, not by list position, so the U-shape pin (second-best item,
1155
+ * pinned last) is never the first casualty. If even the single best item
1156
+ * is over budget it is still emitted — an over-budget answer carrying
1157
+ * the top fact beats an empty evidence block. Omitted (the default)
1158
+ * serializes the whole pack, exactly as before.
1159
+ * @returns A compact TOON-formatted string.
1160
+ */
1161
+ export function packToToonCompact(pack, tokenCounter, maxTokens) {
1162
+ const render = (items) => {
1163
+ const body = serializeCompactRows(items.map((item) => ({
1164
+ id: item.id,
1165
+ citation: item.citation,
1166
+ score: item.score,
1167
+ trustScore: item.trustScore ?? 0.5,
1168
+ source: item.nodeSource ?? item.source,
1169
+ updatedAtMs: new Date(item.updatedAt).getTime(),
1170
+ tags: item.tags,
1171
+ content: item.content,
1172
+ fidelity: item.fidelity,
1173
+ images: item.images,
1174
+ })));
1175
+ // Prepend the pack envelope (schema/query/namespace/budget) as a header
1176
+ // line so the pack path keeps its provenance. The search-result path
1177
+ // omits it — nothing to say there.
1178
+ // The fidelity level rides in the envelope as `f=<level>` when the pack
1179
+ // was built with the `fidelity` option — so the wire form carries the
1180
+ // level without a schema bump.
1181
+ const fidelityParam = pack.fidelity ? `|f=${pack.fidelity.level}` : "";
1182
+ const envelope = `# ${CONTEXT_PACK_SCHEMA}|q=${pack.query}|n=${pack.namespace}` +
1183
+ `|b=${pack.tokenBudget}|s=${pack.tokensSaved}${fidelityParam}`;
1184
+ // Query trailer: repeat the query AFTER the evidence block (the envelope
1185
+ // already states it BEFORE) — a reader scanning bottom-up re-anchors on
1186
+ // the query. A `#` comment line, so `parseToonCompact` skips it.
1187
+ const trailer = `# q=${pack.query}`;
1188
+ // Lessons ride as `#` comment lines after the trailer — the compact
1189
+ // parser skips every `#` line, so the wire format stays parse-stable.
1190
+ const lessonLines = [];
1191
+ for (const lesson of pack.lessons ?? []) {
1192
+ const text = lesson.lesson.replace(/\|/g, "¦").replace(/\n/g, " ");
1193
+ lessonLines.push(`# L|${lessonCitationToken(lesson.id)}|${lesson.score.toFixed(2)}|${text}`);
1194
+ }
1195
+ return [`${envelope}\n${body}\n${trailer}`, ...lessonLines].join("\n");
1196
+ };
1197
+ const full = render(pack.items);
1198
+ // No budget asked for (or nothing to trim): byte-identical to the
1199
+ // pre-budget serializer.
1200
+ if (maxTokens === undefined || !(maxTokens > 0))
1201
+ return full;
1202
+ const counter = tokenCounter ?? estimateTokens;
1203
+ if (counter(full) <= maxTokens)
1204
+ return full;
1205
+ // COMPRESS BEFORE DROP (audit finding 7g).
1206
+ //
1207
+ // Trimming used to be score-only: the moment the full pack was over
1208
+ // budget, the lowest-scored items were discarded whole. Under budget
1209
+ // pressure the tail is exactly where the specific facts live, so the
1210
+ // pack lost whole memories when their filler alone was enough to
1211
+ // reclaim the space.
1212
+ //
1213
+ // First try shrinking every item through `compressText` and see whether
1214
+ // that alone brings the pack under budget. Only when it does not does
1215
+ // the score-based trim below run — and it then runs over the ALREADY
1216
+ // compressed items, so dropping an item means dropping a fact that
1217
+ // could not be made to fit, not a fact that was never compressed.
1218
+ //
1219
+ // `compressText` is a no-op on code-like and already-terse text, so
1220
+ // this is free (one render) whenever compression cannot help.
1221
+ const compressedItems = pack.items.map((item) => ({
1222
+ ...item,
1223
+ content: compressText(item.content, "light"),
1224
+ }));
1225
+ // `render` charges a row for its content, so a candidate is only
1226
+ // compared once it carries the bytes it will actually be measured at.
1227
+ const trimmedOperand = counter(render(compressedItems)) < counter(full)
1228
+ ? compressedItems
1229
+ : pack.items;
1230
+ const compressed = render(trimmedOperand);
1231
+ if (counter(compressed) <= maxTokens)
1232
+ return compressed;
1233
+ // Trim by score: keep the top-k items and drop the rest, then re-render
1234
+ // in the pack's own (U-shape) order. Trimming by score rather than by
1235
+ // position matters because `buildContextPack` pins the second-best item
1236
+ // LAST (lost-in-the-middle mitigation) — cutting from the tail would
1237
+ // throw away the runner-up before the mid-list filler.
1238
+ //
1239
+ // The operand is the compressed set: a compressed item is preferred
1240
+ // over a dropped one at every k. It is `pack.items` unchanged when
1241
+ // compression cannot shrink this pack's text at all, so a terse pack
1242
+ // trims exactly as it did before.
1243
+ const trimmable = trimmedOperand;
1244
+ const byScore = trimmable
1245
+ .map((item, index) => ({ item, index }))
1246
+ .sort((a, b) => b.item.score - a.item.score || a.index - b.index);
1247
+ const keepCount = (k) => new Set(byScore.slice(0, k).map((entry) => entry.index));
1248
+ let best = null;
1249
+ for (let k = trimmable.length - 1; k >= 1; k -= 1) {
1250
+ const keep = keepCount(k);
1251
+ const candidate = render(trimmable.filter((_, i) => keep.has(i)));
1252
+ if (counter(candidate) <= maxTokens)
1253
+ return candidate;
1254
+ best = candidate;
1255
+ }
1256
+ // Even the top item is over budget. Emitting the best fact anyway beats
1257
+ // emitting an empty evidence block; the caller can tighten the pack
1258
+ // itself if it prefers silence.
1259
+ return best ?? compressed;
1260
+ }
1261
+ /**
1262
+ * Serialize search results to compact TOON format (v2). See
1263
+ * {@link packToToonCompact} for the wire layout.
1264
+ */
1265
+ export function searchResultsToToonCompact(results) {
1266
+ return serializeCompactRows(results.map((r) => ({
1267
+ id: r.node.id,
1268
+ score: r.score,
1269
+ trustScore: r.node.trustScore ?? 0.5,
1270
+ source: r.node.source,
1271
+ updatedAtMs: r.node.updatedAt,
1272
+ tags: r.node.tags,
1273
+ content: r.node.content,
1274
+ })));
1275
+ }
1276
+ /**
1277
+ * Core v2 compact serializer shared by pack and search-result entry points.
1278
+ *
1279
+ * Two-pass: pass 1 collects the epoch anchor and tag dictionary, pass 2
1280
+ * emits rows. The tag dictionary is only used when the header cost is
1281
+ * smaller than the per-row savings (otherwise rows carry literal tags).
1282
+ */
1283
+ function serializeCompactRows(rows) {
1284
+ const lines = [];
1285
+ if (rows.length === 0) {
1286
+ lines.push("# memos.search.v2");
1287
+ lines.push("# f=~id|s|t|c|e|g|ct");
1288
+ return lines.join("\n");
1289
+ }
1290
+ // --- Pass 1: anchors -----------------------------------------------------
1291
+ const minEpoch = Math.min(...rows.map((r) => Math.floor(r.updatedAtMs / 1000)));
1292
+ const tagCounts = new Map();
1293
+ let tagTotalChars = 0;
1294
+ for (const r of rows) {
1295
+ for (const tag of r.tags) {
1296
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
1297
+ tagTotalChars += tag.length;
1298
+ }
1299
+ }
1300
+ const distinctTags = [...tagCounts.keys()];
1301
+ const dictChars = distinctTags.reduce((n, t) => n + t.length + 1, 0);
1302
+ // Honest cost comparison: the ";" separators between tags exist on BOTH
1303
+ // paths (rows join tags with ";" regardless), so they cancel out. What
1304
+ // differs is the payload per occurrence — a decimal index under the
1305
+ // dictionary vs the literal tag text — plus the one-time header cost of
1306
+ // the dictionary itself.
1307
+ // Never use the dictionary when a tag is all-digits: numeric row fields
1308
+ // would be ambiguous with dictionary indices on decode.
1309
+ const hasNumericTag = distinctTags.some((t) => /^[0-9]+$/.test(t));
1310
+ let withDictCost = dictChars;
1311
+ let withoutDictCost = 0;
1312
+ for (const [tag, count] of tagCounts) {
1313
+ withDictCost += count * String(distinctTags.indexOf(tag)).length;
1314
+ withoutDictCost += count * tag.length;
1315
+ }
1316
+ const dictWins = distinctTags.length > 0 && !hasNumericTag && withDictCost < withoutDictCost;
1317
+ // --- Header --------------------------------------------------------------
1318
+ const header = ["# memos.search.v2"];
1319
+ header.push(`e=${minEpoch}`);
1320
+ if (dictWins)
1321
+ header.push(`t=${distinctTags.join(",")}`);
1322
+ lines.push(header.join("|"));
1323
+ lines.push("# f=~id|s|t|c|e|g|ct");
1324
+ // --- Pass 2: rows --------------------------------------------------------
1325
+ const tagIndex = dictWins
1326
+ ? new Map(distinctTags.map((t, i) => [t, i]))
1327
+ : null;
1328
+ for (const r of rows) {
1329
+ const safeContent = r.content.replace(/\|/g, "¦").replace(/\n/g, " ");
1330
+ // Citation token renders inside the content field — the pipe layout
1331
+ // (and therefore the parser) is untouched.
1332
+ const cited = r.citation ? `${r.citation} ${safeContent}` : safeContent;
1333
+ // Image refs render TRAILING the content for the same reason: no new
1334
+ // column, so the fixed layout (and provider prompt caches) hold.
1335
+ const withImages = cited + compactImageRefSuffix(r.images);
1336
+ const scoreInt = Math.round(r.score * 1000);
1337
+ const srcCode = SOURCE_CODE[r.source] ?? r.source;
1338
+ const trustCode = Math.round(r.trustScore * 9);
1339
+ const epochDelta = Math.floor(r.updatedAtMs / 1000) - minEpoch;
1340
+ // IDs go on the wire verbatim (packed when hex/UUID-shaped). No prefix
1341
+ // is stripped or assumed — real storage IDs are bare dashed UUIDs.
1342
+ const shortId = encodeCompactId(r.id);
1343
+ const safeTags = tagIndex
1344
+ ? r.tags.map((t) => String(tagIndex.get(t) ?? t)).join(";")
1345
+ : r.tags.join(";");
1346
+ // Optional 8th field: the fidelity level the row's content was
1347
+ // rendered at. Only pack rows carry it (search-result rows never do),
1348
+ // and only when the pack was built with `fidelity`.
1349
+ const fidelityField = r.fidelity ? [r.fidelity] : [];
1350
+ lines.push([
1351
+ shortId,
1352
+ scoreInt,
1353
+ trustCode,
1354
+ srcCode,
1355
+ epochDelta,
1356
+ safeTags,
1357
+ withImages,
1358
+ ...fidelityField,
1359
+ ].join("|"));
1360
+ }
1361
+ return lines.join("\n");
1362
+ }
1363
+ /**
1364
+ * Decode a v2 (or v1) compact TOON string back into ContextPackItem objects.
1365
+ *
1366
+ * v2 headers carry an epoch anchor (`e=`) and optional tag dictionary
1367
+ * (`t=`); rows reference tags by index when the dictionary is present.
1368
+ * v1 strings (no `e=` in the header) keep their absolute-epoch semantics.
1369
+ *
1370
+ * All `#` comment lines are skipped — including the query trailer
1371
+ * (`# q=<query>`) that pack serializers append after the last evidence
1372
+ * row, so packs round-trip with the trailer intact-but-ignored.
1373
+ *
1374
+ * @param toon — The compact TOON string (header lines are skipped).
1375
+ * @returns Array of decoded ContextPackItem objects.
1376
+ */
1377
+ export function parseToonCompact(toon) {
1378
+ const items = [];
1379
+ const lines = toon.split("\n");
1380
+ const header = lines.find((l) => l.startsWith("# memos.search."));
1381
+ const isV2 = header?.includes(".v2") ?? false;
1382
+ const epochAnchor = isV2 && header ? parseInt(headerParam(header, "e", "0"), 10) : null;
1383
+ const tagDict = isV2 && header && header.includes("t=")
1384
+ ? headerParam(header, "t", "").split(",").filter(Boolean)
1385
+ : null;
1386
+ const dataLines = lines.filter((l) => l && !l.startsWith("#"));
1387
+ for (const line of dataLines) {
1388
+ const parts = line.split("|");
1389
+ if (parts.length < 7)
1390
+ continue;
1391
+ // Optional 8th field: fidelity level (pack rows built with the
1392
+ // `fidelity` option). Legacy 7-field rows parse unchanged. The last
1393
+ // field is only treated as fidelity when it exactly matches a known
1394
+ // level, so content containing raw pipes is never misread.
1395
+ const [id, scoreInt, trustCode, srcCode, ts, tags, ...rest] = parts;
1396
+ const fidelityLevels = new Set(["L0", "L1", "L2", "L3"]);
1397
+ const lastField = rest[rest.length - 1];
1398
+ const fidelity = rest.length > 1 && lastField && fidelityLevels.has(lastField)
1399
+ ? lastField
1400
+ : undefined;
1401
+ const content = fidelity !== undefined ? rest.slice(0, -1).join("|") : rest.join("|");
1402
+ // Reverse source code mapping.
1403
+ const source = CODE_SOURCE[srcCode] ?? srcCode;
1404
+ // Timestamps: v2 deltas are relative to the header anchor.
1405
+ const epoch = epochAnchor !== null ? epochAnchor + parseInt(ts, 10) : parseInt(ts, 10);
1406
+ // Tags: numeric fields resolve through the header dictionary.
1407
+ const resolvedTags = (tags ? tags.split(";") : []).map((t) => {
1408
+ if (tagDict && /^[0-9]+$/.test(t)) {
1409
+ return tagDict[parseInt(t, 10)] ?? t;
1410
+ }
1411
+ return t;
1412
+ });
1413
+ // v2 IDs are verbatim: decode and use as-is. The v1 format stripped a
1414
+ // "mem_" prefix on serialize, so the legacy decode path restores it.
1415
+ const rawId = decodeCompactId(id);
1416
+ const item = {
1417
+ id: isV2 ? rawId : rawId.startsWith("mem_") ? rawId : `mem_${rawId}`,
1418
+ content,
1419
+ summary: null,
1420
+ score: parseInt(scoreInt, 10) / 1000,
1421
+ scores: {},
1422
+ trust: String(Math.round((parseInt(trustCode, 10) / 9) * 100) / 100),
1423
+ source,
1424
+ // Populate per-node fields for compact encoding round-trip
1425
+ trustScore: parseInt(trustCode, 10) / 9,
1426
+ nodeSource: source,
1427
+ tags: resolvedTags,
1428
+ updatedAt: new Date(epoch * 1000).toISOString(),
1429
+ ...(fidelity !== undefined && { fidelity }),
1430
+ };
1431
+ items.push(item);
1432
+ }
1433
+ return items;
1434
+ }
1435
+ //# sourceMappingURL=context-pack.js.map