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.
- mem_os_python-1.6.27.dist-info/METADATA +1032 -0
- mem_os_python-1.6.27.dist-info/RECORD +253 -0
- mem_os_python-1.6.27.dist-info/WHEEL +4 -0
- mem_os_python-1.6.27.dist-info/entry_points.txt +2 -0
- mem_os_python-1.6.27.dist-info/licenses/LICENSE +21 -0
- memos/__init__.py +16 -0
- memos/_js/adapters/anthropic.d.ts +279 -0
- memos/_js/adapters/anthropic.d.ts.map +1 -0
- memos/_js/adapters/anthropic.js +348 -0
- memos/_js/adapters/anthropic.js.map +1 -0
- memos/_js/adapters/base.d.ts +10 -0
- memos/_js/adapters/base.d.ts.map +1 -0
- memos/_js/adapters/base.js +2 -0
- memos/_js/adapters/base.js.map +1 -0
- memos/_js/adapters/openai.d.ts +264 -0
- memos/_js/adapters/openai.d.ts.map +1 -0
- memos/_js/adapters/openai.js +342 -0
- memos/_js/adapters/openai.js.map +1 -0
- memos/_js/apps/explorer.d.ts +244 -0
- memos/_js/apps/explorer.d.ts.map +1 -0
- memos/_js/apps/explorer.js +738 -0
- memos/_js/apps/explorer.js.map +1 -0
- memos/_js/citations.d.ts +65 -0
- memos/_js/citations.d.ts.map +1 -0
- memos/_js/citations.js +143 -0
- memos/_js/citations.js.map +1 -0
- memos/_js/cli-browse.d.ts +3 -0
- memos/_js/cli-browse.d.ts.map +1 -0
- memos/_js/cli-browse.js +129 -0
- memos/_js/cli-browse.js.map +1 -0
- memos/_js/cli-completion.d.ts +31 -0
- memos/_js/cli-completion.d.ts.map +1 -0
- memos/_js/cli-completion.js +340 -0
- memos/_js/cli-completion.js.map +1 -0
- memos/_js/cli-help.d.ts +18 -0
- memos/_js/cli-help.d.ts.map +1 -0
- memos/_js/cli-help.js +357 -0
- memos/_js/cli-help.js.map +1 -0
- memos/_js/cli.d.ts +28 -0
- memos/_js/cli.d.ts.map +1 -0
- memos/_js/cli.js +2880 -0
- memos/_js/cli.js.map +1 -0
- memos/_js/compression.d.ts +25 -0
- memos/_js/compression.d.ts.map +1 -0
- memos/_js/compression.js +319 -0
- memos/_js/compression.js.map +1 -0
- memos/_js/confidence-machine.d.ts +99 -0
- memos/_js/confidence-machine.d.ts.map +1 -0
- memos/_js/confidence-machine.js +222 -0
- memos/_js/confidence-machine.js.map +1 -0
- memos/_js/context-pack.d.ts +506 -0
- memos/_js/context-pack.d.ts.map +1 -0
- memos/_js/context-pack.js +1435 -0
- memos/_js/context-pack.js.map +1 -0
- memos/_js/contradictions.d.ts +160 -0
- memos/_js/contradictions.d.ts.map +1 -0
- memos/_js/contradictions.js +255 -0
- memos/_js/contradictions.js.map +1 -0
- memos/_js/dashboard.d.ts +30 -0
- memos/_js/dashboard.d.ts.map +1 -0
- memos/_js/dashboard.js +302 -0
- memos/_js/dashboard.js.map +1 -0
- memos/_js/ebbinghaus.d.ts +29 -0
- memos/_js/ebbinghaus.d.ts.map +1 -0
- memos/_js/ebbinghaus.js +32 -0
- memos/_js/ebbinghaus.js.map +1 -0
- memos/_js/embedding-queue.d.ts +190 -0
- memos/_js/embedding-queue.d.ts.map +1 -0
- memos/_js/embedding-queue.js +316 -0
- memos/_js/embedding-queue.js.map +1 -0
- memos/_js/embeddings.d.ts +187 -0
- memos/_js/embeddings.d.ts.map +1 -0
- memos/_js/embeddings.js +633 -0
- memos/_js/embeddings.js.map +1 -0
- memos/_js/entity-extraction.d.ts +54 -0
- memos/_js/entity-extraction.d.ts.map +1 -0
- memos/_js/entity-extraction.js +325 -0
- memos/_js/entity-extraction.js.map +1 -0
- memos/_js/entity-index.d.ts +93 -0
- memos/_js/entity-index.d.ts.map +1 -0
- memos/_js/entity-index.js +165 -0
- memos/_js/entity-index.js.map +1 -0
- memos/_js/event-memory.d.ts +147 -0
- memos/_js/event-memory.d.ts.map +1 -0
- memos/_js/event-memory.js +290 -0
- memos/_js/event-memory.js.map +1 -0
- memos/_js/external-import.d.ts +98 -0
- memos/_js/external-import.d.ts.map +1 -0
- memos/_js/external-import.js +669 -0
- memos/_js/external-import.js.map +1 -0
- memos/_js/fidelity.d.ts +367 -0
- memos/_js/fidelity.d.ts.map +1 -0
- memos/_js/fidelity.js +911 -0
- memos/_js/fidelity.js.map +1 -0
- memos/_js/graph-expansion.d.ts +119 -0
- memos/_js/graph-expansion.d.ts.map +1 -0
- memos/_js/graph-expansion.js +213 -0
- memos/_js/graph-expansion.js.map +1 -0
- memos/_js/graph-mermaid.d.ts +13 -0
- memos/_js/graph-mermaid.d.ts.map +1 -0
- memos/_js/graph-mermaid.js +32 -0
- memos/_js/graph-mermaid.js.map +1 -0
- memos/_js/graph.d.ts +170 -0
- memos/_js/graph.d.ts.map +1 -0
- memos/_js/graph.js +482 -0
- memos/_js/graph.js.map +1 -0
- memos/_js/harness.d.ts +63 -0
- memos/_js/harness.d.ts.map +1 -0
- memos/_js/harness.js +93 -0
- memos/_js/harness.js.map +1 -0
- memos/_js/importance.d.ts +118 -0
- memos/_js/importance.d.ts.map +1 -0
- memos/_js/importance.js +144 -0
- memos/_js/importance.js.map +1 -0
- memos/_js/index.d.ts +51 -0
- memos/_js/index.d.ts.map +1 -0
- memos/_js/index.js +53 -0
- memos/_js/index.js.map +1 -0
- memos/_js/mcp.d.ts +58 -0
- memos/_js/mcp.d.ts.map +1 -0
- memos/_js/mcp.js +1853 -0
- memos/_js/mcp.js.map +1 -0
- memos/_js/memory.d.ts +1762 -0
- memos/_js/memory.d.ts.map +1 -0
- memos/_js/memory.js +6283 -0
- memos/_js/memory.js.map +1 -0
- memos/_js/procedural.d.ts +74 -0
- memos/_js/procedural.d.ts.map +1 -0
- memos/_js/procedural.js +125 -0
- memos/_js/procedural.js.map +1 -0
- memos/_js/project-config.d.ts +68 -0
- memos/_js/project-config.d.ts.map +1 -0
- memos/_js/project-config.js +59 -0
- memos/_js/project-config.js.map +1 -0
- memos/_js/proof-of-forgetting.d.ts +57 -0
- memos/_js/proof-of-forgetting.d.ts.map +1 -0
- memos/_js/proof-of-forgetting.js +80 -0
- memos/_js/proof-of-forgetting.js.map +1 -0
- memos/_js/provenance.d.ts +122 -0
- memos/_js/provenance.d.ts.map +1 -0
- memos/_js/provenance.js +182 -0
- memos/_js/provenance.js.map +1 -0
- memos/_js/quarantine-commands.d.ts +23 -0
- memos/_js/quarantine-commands.d.ts.map +1 -0
- memos/_js/quarantine-commands.js +42 -0
- memos/_js/quarantine-commands.js.map +1 -0
- memos/_js/quarantine.d.ts +120 -0
- memos/_js/quarantine.d.ts.map +1 -0
- memos/_js/quarantine.js +873 -0
- memos/_js/quarantine.js.map +1 -0
- memos/_js/redact.d.ts +12 -0
- memos/_js/redact.d.ts.map +1 -0
- memos/_js/redact.js +26 -0
- memos/_js/redact.js.map +1 -0
- memos/_js/repl.d.ts +57 -0
- memos/_js/repl.d.ts.map +1 -0
- memos/_js/repl.js +295 -0
- memos/_js/repl.js.map +1 -0
- memos/_js/rerank-local.d.ts +61 -0
- memos/_js/rerank-local.d.ts.map +1 -0
- memos/_js/rerank-local.js +210 -0
- memos/_js/rerank-local.js.map +1 -0
- memos/_js/retain-filter.d.ts +98 -0
- memos/_js/retain-filter.d.ts.map +1 -0
- memos/_js/retain-filter.js +232 -0
- memos/_js/retain-filter.js.map +1 -0
- memos/_js/retrieval.d.ts +265 -0
- memos/_js/retrieval.d.ts.map +1 -0
- memos/_js/retrieval.js +1018 -0
- memos/_js/retrieval.js.map +1 -0
- memos/_js/revert.d.ts +77 -0
- memos/_js/revert.d.ts.map +1 -0
- memos/_js/revert.js +252 -0
- memos/_js/revert.js.map +1 -0
- memos/_js/scope.d.ts +30 -0
- memos/_js/scope.d.ts.map +1 -0
- memos/_js/scope.js +71 -0
- memos/_js/scope.js.map +1 -0
- memos/_js/semantic-screen.d.ts +152 -0
- memos/_js/semantic-screen.d.ts.map +1 -0
- memos/_js/semantic-screen.js +290 -0
- memos/_js/semantic-screen.js.map +1 -0
- memos/_js/session-hooks.d.ts +127 -0
- memos/_js/session-hooks.d.ts.map +1 -0
- memos/_js/session-hooks.js +482 -0
- memos/_js/session-hooks.js.map +1 -0
- memos/_js/storage/sqlite.d.ts +427 -0
- memos/_js/storage/sqlite.d.ts.map +1 -0
- memos/_js/storage/sqlite.js +2428 -0
- memos/_js/storage/sqlite.js.map +1 -0
- memos/_js/sync.d.ts +213 -0
- memos/_js/sync.d.ts.map +1 -0
- memos/_js/sync.js +389 -0
- memos/_js/sync.js.map +1 -0
- memos/_js/tamper-log.d.ts +98 -0
- memos/_js/tamper-log.d.ts.map +1 -0
- memos/_js/tamper-log.js +327 -0
- memos/_js/tamper-log.js.map +1 -0
- memos/_js/temporal.d.ts +92 -0
- memos/_js/temporal.d.ts.map +1 -0
- memos/_js/temporal.js +536 -0
- memos/_js/temporal.js.map +1 -0
- memos/_js/time-travel.d.ts +38 -0
- memos/_js/time-travel.d.ts.map +1 -0
- memos/_js/time-travel.js +46 -0
- memos/_js/time-travel.js.map +1 -0
- memos/_js/timestamp-format.d.ts +18 -0
- memos/_js/timestamp-format.d.ts.map +1 -0
- memos/_js/timestamp-format.js +52 -0
- memos/_js/timestamp-format.js.map +1 -0
- memos/_js/triple-extraction.d.ts +31 -0
- memos/_js/triple-extraction.d.ts.map +1 -0
- memos/_js/triple-extraction.js +116 -0
- memos/_js/triple-extraction.js.map +1 -0
- memos/_js/types.d.ts +2474 -0
- memos/_js/types.d.ts.map +1 -0
- memos/_js/types.js +20 -0
- memos/_js/types.js.map +1 -0
- memos/_js/updater.d.ts +95 -0
- memos/_js/updater.d.ts.map +1 -0
- memos/_js/updater.js +268 -0
- memos/_js/updater.js.map +1 -0
- memos/_js/version.d.ts +17 -0
- memos/_js/version.d.ts.map +1 -0
- memos/_js/version.js +34 -0
- memos/_js/version.js.map +1 -0
- memos/_js/vision/clip-query-cache.d.ts +58 -0
- memos/_js/vision/clip-query-cache.d.ts.map +1 -0
- memos/_js/vision/clip-query-cache.js +79 -0
- memos/_js/vision/clip-query-cache.js.map +1 -0
- memos/_js/vision/clip.d.ts +113 -0
- memos/_js/vision/clip.d.ts.map +1 -0
- memos/_js/vision/clip.js +224 -0
- memos/_js/vision/clip.js.map +1 -0
- memos/_js/vision/fake-clip.d.ts +55 -0
- memos/_js/vision/fake-clip.d.ts.map +1 -0
- memos/_js/vision/fake-clip.js +122 -0
- memos/_js/vision/fake-clip.js.map +1 -0
- memos/_js/vision/index.d.ts +15 -0
- memos/_js/vision/index.d.ts.map +1 -0
- memos/_js/vision/index.js +13 -0
- memos/_js/vision/index.js.map +1 -0
- memos/_js/vision/store.d.ts +137 -0
- memos/_js/vision/store.d.ts.map +1 -0
- memos/_js/vision/store.js +383 -0
- memos/_js/vision/store.js.map +1 -0
- memos/adapters/__init__.py +0 -0
- memos/adapters/crewai.py +385 -0
- memos/adapters/langchain.py +301 -0
- memos/adapters/ollama.py +396 -0
- memos/server/__init__.py +0 -0
- memos/server/main.py +547 -0
- 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
|