@lossless-claude/lcm 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/commands/lcm-compact.md +31 -0
- package/.claude-plugin/commands/lcm-curate.md +31 -0
- package/.claude-plugin/commands/lcm-diagnose.md +29 -0
- package/.claude-plugin/commands/lcm-doctor.md +23 -0
- package/.claude-plugin/commands/lcm-dogfood.md +101 -0
- package/.claude-plugin/commands/lcm-import.md +48 -0
- package/.claude-plugin/commands/lcm-promote.md +29 -0
- package/.claude-plugin/commands/lcm-sensitive.md +55 -0
- package/.claude-plugin/commands/lcm-stats.md +19 -0
- package/.claude-plugin/commands/lcm-status.md +27 -0
- package/.claude-plugin/hooks/README.md +47 -0
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/mcp.mjs +12 -0
- package/.claude-plugin/plugin.json +46 -0
- package/.claude-plugin/skills/lcm-context/SKILL.md +107 -0
- package/.claude-plugin/skills/lcm-dogfood/SKILL.md +102 -0
- package/.claude-plugin/skills/lcm-dogfood/references/checks.md +239 -0
- package/.claude-plugin/skills/lcm-dogfood/references/known-issues.md +11 -0
- package/.claude-plugin/skills/lcm-dogfood/scripts/db-integrity.js +40 -0
- package/.claude-plugin/skills/lcm-dogfood/scripts/prompt-search-test.js +35 -0
- package/.claude-plugin/skills/lcm-e2e/SKILL.md +61 -0
- package/.claude-plugin/skills/lcm-e2e/checklist.md +367 -0
- package/.claude-plugin/skills/lossless-claude-upgrade/SKILL.md +47 -0
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/dist/bin/lcm.d.ts +2 -0
- package/dist/bin/lcm.js +461 -0
- package/dist/bin/lcm.js.map +1 -0
- package/dist/installer/dry-run-deps.d.ts +23 -0
- package/dist/installer/dry-run-deps.js +66 -0
- package/dist/installer/dry-run-deps.js.map +1 -0
- package/dist/installer/install.d.ts +39 -0
- package/dist/installer/install.js +236 -0
- package/dist/installer/install.js.map +1 -0
- package/dist/installer/uninstall.d.ts +11 -0
- package/dist/installer/uninstall.js +80 -0
- package/dist/installer/uninstall.js.map +1 -0
- package/dist/src/batch-compact.d.ts +16 -0
- package/dist/src/batch-compact.js +121 -0
- package/dist/src/batch-compact.js.map +1 -0
- package/dist/src/compaction.d.ts +198 -0
- package/dist/src/compaction.js +964 -0
- package/dist/src/compaction.js.map +1 -0
- package/dist/src/connectors/constants.d.ts +5 -0
- package/dist/src/connectors/constants.js +6 -0
- package/dist/src/connectors/constants.js.map +1 -0
- package/dist/src/connectors/installer.d.ts +16 -0
- package/dist/src/connectors/installer.js +200 -0
- package/dist/src/connectors/installer.js.map +1 -0
- package/dist/src/connectors/registry.d.ts +4 -0
- package/dist/src/connectors/registry.js +264 -0
- package/dist/src/connectors/registry.js.map +1 -0
- package/dist/src/connectors/template-service.d.ts +5 -0
- package/dist/src/connectors/template-service.js +54 -0
- package/dist/src/connectors/template-service.js.map +1 -0
- package/dist/src/connectors/templates/base.md +1 -0
- package/dist/src/connectors/templates/mcp-base.md +1 -0
- package/dist/src/connectors/templates/sections/command-reference.md +15 -0
- package/dist/src/connectors/templates/sections/mcp-workflow.md +18 -0
- package/dist/src/connectors/templates/sections/workflow.md +29 -0
- package/dist/src/connectors/templates/skill/SKILL.md +74 -0
- package/dist/src/connectors/types.d.ts +19 -0
- package/dist/src/connectors/types.js +10 -0
- package/dist/src/connectors/types.js.map +1 -0
- package/dist/src/daemon/client.d.ts +9 -0
- package/dist/src/daemon/client.js +28 -0
- package/dist/src/daemon/client.js.map +1 -0
- package/dist/src/daemon/config.d.ts +48 -0
- package/dist/src/daemon/config.js +67 -0
- package/dist/src/daemon/config.js.map +1 -0
- package/dist/src/daemon/lifecycle.d.ts +19 -0
- package/dist/src/daemon/lifecycle.js +102 -0
- package/dist/src/daemon/lifecycle.js.map +1 -0
- package/dist/src/daemon/orientation.d.ts +1 -0
- package/dist/src/daemon/orientation.js +9 -0
- package/dist/src/daemon/orientation.js.map +1 -0
- package/dist/src/daemon/project-queue.d.ts +1 -0
- package/dist/src/daemon/project-queue.js +17 -0
- package/dist/src/daemon/project-queue.js.map +1 -0
- package/dist/src/daemon/project.d.ts +7 -0
- package/dist/src/daemon/project.js +25 -0
- package/dist/src/daemon/project.js.map +1 -0
- package/dist/src/daemon/proxy-manager.d.ts +21 -0
- package/dist/src/daemon/proxy-manager.js +205 -0
- package/dist/src/daemon/proxy-manager.js.map +1 -0
- package/dist/src/daemon/routes/compact.d.ts +13 -0
- package/dist/src/daemon/routes/compact.js +195 -0
- package/dist/src/daemon/routes/compact.js.map +1 -0
- package/dist/src/daemon/routes/describe.d.ts +3 -0
- package/dist/src/daemon/routes/describe.js +39 -0
- package/dist/src/daemon/routes/describe.js.map +1 -0
- package/dist/src/daemon/routes/expand.d.ts +3 -0
- package/dist/src/daemon/routes/expand.js +41 -0
- package/dist/src/daemon/routes/expand.js.map +1 -0
- package/dist/src/daemon/routes/grep.d.ts +3 -0
- package/dist/src/daemon/routes/grep.js +43 -0
- package/dist/src/daemon/routes/grep.js.map +1 -0
- package/dist/src/daemon/routes/ingest.d.ts +3 -0
- package/dist/src/daemon/routes/ingest.js +101 -0
- package/dist/src/daemon/routes/ingest.js.map +1 -0
- package/dist/src/daemon/routes/promote.d.ts +4 -0
- package/dist/src/daemon/routes/promote.js +104 -0
- package/dist/src/daemon/routes/promote.js.map +1 -0
- package/dist/src/daemon/routes/prompt-search.d.ts +3 -0
- package/dist/src/daemon/routes/prompt-search.js +65 -0
- package/dist/src/daemon/routes/prompt-search.js.map +1 -0
- package/dist/src/daemon/routes/recent.d.ts +3 -0
- package/dist/src/daemon/routes/recent.js +37 -0
- package/dist/src/daemon/routes/recent.js.map +1 -0
- package/dist/src/daemon/routes/restore.d.ts +3 -0
- package/dist/src/daemon/routes/restore.js +120 -0
- package/dist/src/daemon/routes/restore.js.map +1 -0
- package/dist/src/daemon/routes/search.d.ts +2 -0
- package/dist/src/daemon/routes/search.js +66 -0
- package/dist/src/daemon/routes/search.js.map +1 -0
- package/dist/src/daemon/routes/status.d.ts +3 -0
- package/dist/src/daemon/routes/status.js +80 -0
- package/dist/src/daemon/routes/status.js.map +1 -0
- package/dist/src/daemon/routes/store.d.ts +2 -0
- package/dist/src/daemon/routes/store.js +46 -0
- package/dist/src/daemon/routes/store.js.map +1 -0
- package/dist/src/daemon/server.d.ts +19 -0
- package/dist/src/daemon/server.js +183 -0
- package/dist/src/daemon/server.js.map +1 -0
- package/dist/src/daemon/summarizer.d.ts +11 -0
- package/dist/src/daemon/summarizer.js +51 -0
- package/dist/src/daemon/summarizer.js.map +1 -0
- package/dist/src/db/config.d.ts +31 -0
- package/dist/src/db/config.js +83 -0
- package/dist/src/db/config.js.map +1 -0
- package/dist/src/db/connection.d.ts +3 -0
- package/dist/src/db/connection.js +62 -0
- package/dist/src/db/connection.js.map +1 -0
- package/dist/src/db/features.d.ts +11 -0
- package/dist/src/db/features.js +36 -0
- package/dist/src/db/features.js.map +1 -0
- package/dist/src/db/migration.d.ts +4 -0
- package/dist/src/db/migration.js +499 -0
- package/dist/src/db/migration.js.map +1 -0
- package/dist/src/db/promoted.d.ts +47 -0
- package/dist/src/db/promoted.js +96 -0
- package/dist/src/db/promoted.js.map +1 -0
- package/dist/src/db/redaction-stats.d.ts +6 -0
- package/dist/src/db/redaction-stats.js +16 -0
- package/dist/src/db/redaction-stats.js.map +1 -0
- package/dist/src/diagnose.d.ts +39 -0
- package/dist/src/diagnose.js +432 -0
- package/dist/src/diagnose.js.map +1 -0
- package/dist/src/doctor/doctor.d.ts +4 -0
- package/dist/src/doctor/doctor.js +378 -0
- package/dist/src/doctor/doctor.js.map +1 -0
- package/dist/src/doctor/types.d.ts +24 -0
- package/dist/src/doctor/types.js +2 -0
- package/dist/src/doctor/types.js.map +1 -0
- package/dist/src/expansion.d.ts +100 -0
- package/dist/src/expansion.js +268 -0
- package/dist/src/expansion.js.map +1 -0
- package/dist/src/hooks/auto-heal.d.ts +12 -0
- package/dist/src/hooks/auto-heal.js +49 -0
- package/dist/src/hooks/auto-heal.js.map +1 -0
- package/dist/src/hooks/compact.d.ts +5 -0
- package/dist/src/hooks/compact.js +22 -0
- package/dist/src/hooks/compact.js.map +1 -0
- package/dist/src/hooks/dispatch.d.ts +7 -0
- package/dist/src/hooks/dispatch.js +36 -0
- package/dist/src/hooks/dispatch.js.map +1 -0
- package/dist/src/hooks/probe-precompact.d.ts +2 -0
- package/dist/src/hooks/probe-precompact.js +17 -0
- package/dist/src/hooks/probe-precompact.js.map +1 -0
- package/dist/src/hooks/probe-sessionstart.d.ts +2 -0
- package/dist/src/hooks/probe-sessionstart.js +18 -0
- package/dist/src/hooks/probe-sessionstart.js.map +1 -0
- package/dist/src/hooks/restore.d.ts +5 -0
- package/dist/src/hooks/restore.js +19 -0
- package/dist/src/hooks/restore.js.map +1 -0
- package/dist/src/hooks/session-end.d.ts +16 -0
- package/dist/src/hooks/session-end.js +73 -0
- package/dist/src/hooks/session-end.js.map +1 -0
- package/dist/src/hooks/user-prompt.d.ts +5 -0
- package/dist/src/hooks/user-prompt.js +31 -0
- package/dist/src/hooks/user-prompt.js.map +1 -0
- package/dist/src/import.d.ts +24 -0
- package/dist/src/import.js +119 -0
- package/dist/src/import.js.map +1 -0
- package/dist/src/large-files.d.ts +28 -0
- package/dist/src/large-files.js +413 -0
- package/dist/src/large-files.js.map +1 -0
- package/dist/src/llm/anthropic.d.ts +9 -0
- package/dist/src/llm/anthropic.js +54 -0
- package/dist/src/llm/anthropic.js.map +1 -0
- package/dist/src/llm/claude-process.d.ts +2 -0
- package/dist/src/llm/claude-process.js +55 -0
- package/dist/src/llm/claude-process.js.map +1 -0
- package/dist/src/llm/codex-process.d.ts +15 -0
- package/dist/src/llm/codex-process.js +142 -0
- package/dist/src/llm/codex-process.js.map +1 -0
- package/dist/src/llm/mock-summarizer.d.ts +9 -0
- package/dist/src/llm/mock-summarizer.js +17 -0
- package/dist/src/llm/mock-summarizer.js.map +1 -0
- package/dist/src/llm/openai.d.ts +10 -0
- package/dist/src/llm/openai.js +52 -0
- package/dist/src/llm/openai.js.map +1 -0
- package/dist/src/llm/types.d.ts +6 -0
- package/dist/src/llm/types.js +2 -0
- package/dist/src/llm/types.js.map +1 -0
- package/dist/src/mcp/server.d.ts +9 -0
- package/dist/src/mcp/server.js +128 -0
- package/dist/src/mcp/server.js.map +1 -0
- package/dist/src/mcp/tools/lcm-describe.d.ts +14 -0
- package/dist/src/mcp/tools/lcm-describe.js +12 -0
- package/dist/src/mcp/tools/lcm-describe.js.map +1 -0
- package/dist/src/mcp/tools/lcm-doctor.d.ts +8 -0
- package/dist/src/mcp/tools/lcm-doctor.js +9 -0
- package/dist/src/mcp/tools/lcm-doctor.js.map +1 -0
- package/dist/src/mcp/tools/lcm-expand.d.ts +18 -0
- package/dist/src/mcp/tools/lcm-expand.js +13 -0
- package/dist/src/mcp/tools/lcm-expand.js.map +1 -0
- package/dist/src/mcp/tools/lcm-grep.d.ts +27 -0
- package/dist/src/mcp/tools/lcm-grep.js +15 -0
- package/dist/src/mcp/tools/lcm-grep.js.map +1 -0
- package/dist/src/mcp/tools/lcm-search.d.ts +33 -0
- package/dist/src/mcp/tools/lcm-search.js +15 -0
- package/dist/src/mcp/tools/lcm-search.js.map +1 -0
- package/dist/src/mcp/tools/lcm-stats.d.ts +14 -0
- package/dist/src/mcp/tools/lcm-stats.js +11 -0
- package/dist/src/mcp/tools/lcm-stats.js.map +1 -0
- package/dist/src/mcp/tools/lcm-store.d.ts +26 -0
- package/dist/src/mcp/tools/lcm-store.js +22 -0
- package/dist/src/mcp/tools/lcm-store.js.map +1 -0
- package/dist/src/memory/index.d.ts +22 -0
- package/dist/src/memory/index.js +21 -0
- package/dist/src/memory/index.js.map +1 -0
- package/dist/src/promotion/dedup.d.ts +19 -0
- package/dist/src/promotion/dedup.js +42 -0
- package/dist/src/promotion/dedup.js.map +1 -0
- package/dist/src/promotion/detector.d.ts +15 -0
- package/dist/src/promotion/detector.js +31 -0
- package/dist/src/promotion/detector.js.map +1 -0
- package/dist/src/prompts/condensed-d1.yaml +38 -0
- package/dist/src/prompts/condensed-d2.yaml +32 -0
- package/dist/src/prompts/condensed-d3plus.yaml +32 -0
- package/dist/src/prompts/leaf-aggressive.yaml +34 -0
- package/dist/src/prompts/leaf-normal.yaml +34 -0
- package/dist/src/prompts/loader.d.ts +9 -0
- package/dist/src/prompts/loader.js +37 -0
- package/dist/src/prompts/loader.js.map +1 -0
- package/dist/src/prompts/promoted-merge.yaml +18 -0
- package/dist/src/prompts/system.yaml +5 -0
- package/dist/src/retrieval.d.ts +122 -0
- package/dist/src/retrieval.js +214 -0
- package/dist/src/retrieval.js.map +1 -0
- package/dist/src/scrub.d.ts +46 -0
- package/dist/src/scrub.js +184 -0
- package/dist/src/scrub.js.map +1 -0
- package/dist/src/sensitive.d.ts +4 -0
- package/dist/src/sensitive.js +258 -0
- package/dist/src/sensitive.js.map +1 -0
- package/dist/src/stats.d.ts +34 -0
- package/dist/src/stats.js +260 -0
- package/dist/src/stats.js.map +1 -0
- package/dist/src/store/conversation-store.d.ts +115 -0
- package/dist/src/store/conversation-store.js +447 -0
- package/dist/src/store/conversation-store.js.map +1 -0
- package/dist/src/store/fts5-sanitize.d.ts +23 -0
- package/dist/src/store/fts5-sanitize.js +30 -0
- package/dist/src/store/fts5-sanitize.js.map +1 -0
- package/dist/src/store/full-text-fallback.d.ts +16 -0
- package/dist/src/store/full-text-fallback.js +60 -0
- package/dist/src/store/full-text-fallback.js.map +1 -0
- package/dist/src/store/index.d.ts +4 -0
- package/dist/src/store/index.js +3 -0
- package/dist/src/store/index.js.map +1 -0
- package/dist/src/store/summary-store.d.ts +118 -0
- package/dist/src/store/summary-store.js +570 -0
- package/dist/src/store/summary-store.js.map +1 -0
- package/dist/src/summarize.d.ts +59 -0
- package/dist/src/summarize.js +619 -0
- package/dist/src/summarize.js.map +1 -0
- package/dist/src/transcript.d.ts +7 -0
- package/dist/src/transcript.js +51 -0
- package/dist/src/transcript.js.map +1 -0
- package/dist/src/types.d.ts +116 -0
- package/dist/src/types.js +8 -0
- package/dist/src/types.js.map +1 -0
- package/docs/agent-tools.md +187 -0
- package/docs/architecture.md +224 -0
- package/docs/configuration.md +168 -0
- package/docs/fts5.md +161 -0
- package/docs/hook-protocol.md +41 -0
- package/docs/privacy.md +101 -0
- package/mcp.mjs +17 -0
- package/package.json +79 -0
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { extractFileIdsFromContent } from "./large-files.js";
|
|
3
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
4
|
+
/** Estimate token count from character length (~4 chars per token). */
|
|
5
|
+
function estimateTokens(content) {
|
|
6
|
+
return Math.ceil(content.length / 4);
|
|
7
|
+
}
|
|
8
|
+
/** Format a timestamp as `YYYY-MM-DD HH:mm TZ` for prompt source text. */
|
|
9
|
+
export function formatTimestamp(value, timezone = "UTC") {
|
|
10
|
+
try {
|
|
11
|
+
const fmt = new Intl.DateTimeFormat("en-CA", {
|
|
12
|
+
timeZone: timezone,
|
|
13
|
+
year: "numeric",
|
|
14
|
+
month: "2-digit",
|
|
15
|
+
day: "2-digit",
|
|
16
|
+
hour: "2-digit",
|
|
17
|
+
minute: "2-digit",
|
|
18
|
+
hour12: false,
|
|
19
|
+
});
|
|
20
|
+
const parts = Object.fromEntries(fmt.formatToParts(value).map((p) => [p.type, p.value]));
|
|
21
|
+
const tzAbbr = timezone === "UTC" ? "UTC" : shortTzAbbr(value, timezone);
|
|
22
|
+
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute} ${tzAbbr}`;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Fallback to UTC on invalid timezone
|
|
26
|
+
const year = value.getUTCFullYear();
|
|
27
|
+
const month = String(value.getUTCMonth() + 1).padStart(2, "0");
|
|
28
|
+
const day = String(value.getUTCDate()).padStart(2, "0");
|
|
29
|
+
const hours = String(value.getUTCHours()).padStart(2, "0");
|
|
30
|
+
const minutes = String(value.getUTCMinutes()).padStart(2, "0");
|
|
31
|
+
return `${year}-${month}-${day} ${hours}:${minutes} UTC`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Extract short timezone abbreviation (e.g. "PST", "PDT", "EST"). */
|
|
35
|
+
function shortTzAbbr(value, timezone) {
|
|
36
|
+
try {
|
|
37
|
+
const abbr = new Intl.DateTimeFormat("en-US", {
|
|
38
|
+
timeZone: timezone,
|
|
39
|
+
timeZoneName: "short",
|
|
40
|
+
})
|
|
41
|
+
.formatToParts(value)
|
|
42
|
+
.find((p) => p.type === "timeZoneName")?.value;
|
|
43
|
+
return abbr ?? timezone;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return timezone;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Generate a deterministic summary ID from content + timestamp. */
|
|
50
|
+
function generateSummaryId(content) {
|
|
51
|
+
return ("sum_" +
|
|
52
|
+
createHash("sha256")
|
|
53
|
+
.update(content + Date.now().toString())
|
|
54
|
+
.digest("hex")
|
|
55
|
+
.slice(0, 16));
|
|
56
|
+
}
|
|
57
|
+
/** Maximum characters for the deterministic fallback truncation (512 tokens * 4 chars). */
|
|
58
|
+
const FALLBACK_MAX_CHARS = 512 * 4;
|
|
59
|
+
const DEFAULT_LEAF_CHUNK_TOKENS = 20_000;
|
|
60
|
+
const CONDENSED_MIN_INPUT_RATIO = 0.1;
|
|
61
|
+
function dedupeOrderedIds(ids) {
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
const ordered = [];
|
|
64
|
+
for (const id of ids) {
|
|
65
|
+
if (!seen.has(id)) {
|
|
66
|
+
seen.add(id);
|
|
67
|
+
ordered.push(id);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return ordered;
|
|
71
|
+
}
|
|
72
|
+
// ── CompactionEngine ─────────────────────────────────────────────────────────
|
|
73
|
+
export class CompactionEngine {
|
|
74
|
+
conversationStore;
|
|
75
|
+
summaryStore;
|
|
76
|
+
config;
|
|
77
|
+
constructor(conversationStore, summaryStore, config) {
|
|
78
|
+
this.conversationStore = conversationStore;
|
|
79
|
+
this.summaryStore = summaryStore;
|
|
80
|
+
this.config = config;
|
|
81
|
+
}
|
|
82
|
+
// ── evaluate ─────────────────────────────────────────────────────────────
|
|
83
|
+
/** Evaluate whether compaction is needed. */
|
|
84
|
+
async evaluate(conversationId, tokenBudget, observedTokenCount) {
|
|
85
|
+
const storedTokens = await this.summaryStore.getContextTokenCount(conversationId);
|
|
86
|
+
const liveTokens = typeof observedTokenCount === "number" &&
|
|
87
|
+
Number.isFinite(observedTokenCount) &&
|
|
88
|
+
observedTokenCount > 0
|
|
89
|
+
? Math.floor(observedTokenCount)
|
|
90
|
+
: 0;
|
|
91
|
+
const currentTokens = Math.max(storedTokens, liveTokens);
|
|
92
|
+
const threshold = Math.floor(this.config.contextThreshold * tokenBudget);
|
|
93
|
+
if (currentTokens > threshold) {
|
|
94
|
+
return {
|
|
95
|
+
shouldCompact: true,
|
|
96
|
+
reason: "threshold",
|
|
97
|
+
currentTokens,
|
|
98
|
+
threshold,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
shouldCompact: false,
|
|
103
|
+
reason: "none",
|
|
104
|
+
currentTokens,
|
|
105
|
+
threshold,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Evaluate whether the raw-message leaf trigger is active.
|
|
110
|
+
*
|
|
111
|
+
* Counts message tokens outside the protected fresh tail and compares against
|
|
112
|
+
* `leafChunkTokens`. This lets callers trigger a soft incremental leaf pass
|
|
113
|
+
* before the full context threshold is breached.
|
|
114
|
+
*/
|
|
115
|
+
async evaluateLeafTrigger(conversationId) {
|
|
116
|
+
const rawTokensOutsideTail = await this.countRawTokensOutsideFreshTail(conversationId);
|
|
117
|
+
const threshold = this.resolveLeafChunkTokens();
|
|
118
|
+
return {
|
|
119
|
+
shouldCompact: rawTokensOutsideTail >= threshold,
|
|
120
|
+
rawTokensOutsideTail,
|
|
121
|
+
threshold,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// ── compact ──────────────────────────────────────────────────────────────
|
|
125
|
+
/** Run a full compaction sweep for a conversation. */
|
|
126
|
+
async compact(input) {
|
|
127
|
+
return this.compactFullSweep(input);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Run a single leaf pass against the oldest compactable raw chunk.
|
|
131
|
+
*
|
|
132
|
+
* This is the soft-trigger path used for incremental maintenance.
|
|
133
|
+
*/
|
|
134
|
+
async compactLeaf(input) {
|
|
135
|
+
const { conversationId, tokenBudget, summarize, force } = input;
|
|
136
|
+
const tokensBefore = await this.summaryStore.getContextTokenCount(conversationId);
|
|
137
|
+
const threshold = Math.floor(this.config.contextThreshold * tokenBudget);
|
|
138
|
+
const leafTrigger = await this.evaluateLeafTrigger(conversationId);
|
|
139
|
+
if (!force && tokensBefore <= threshold && !leafTrigger.shouldCompact) {
|
|
140
|
+
return {
|
|
141
|
+
actionTaken: false,
|
|
142
|
+
tokensBefore,
|
|
143
|
+
tokensAfter: tokensBefore,
|
|
144
|
+
condensed: false,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const leafChunk = await this.selectOldestLeafChunk(conversationId);
|
|
148
|
+
if (leafChunk.items.length === 0) {
|
|
149
|
+
return {
|
|
150
|
+
actionTaken: false,
|
|
151
|
+
tokensBefore,
|
|
152
|
+
tokensAfter: tokensBefore,
|
|
153
|
+
condensed: false,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const previousSummaryContent = input.previousSummaryContent ??
|
|
157
|
+
(await this.resolvePriorLeafSummaryContext(conversationId, leafChunk.items));
|
|
158
|
+
const leafResult = await this.leafPass(conversationId, leafChunk.items, summarize, previousSummaryContent);
|
|
159
|
+
const tokensAfterLeaf = await this.summaryStore.getContextTokenCount(conversationId);
|
|
160
|
+
await this.persistCompactionEvents({
|
|
161
|
+
conversationId,
|
|
162
|
+
tokensBefore,
|
|
163
|
+
tokensAfterLeaf,
|
|
164
|
+
tokensAfterFinal: tokensAfterLeaf,
|
|
165
|
+
leafResult: { summaryId: leafResult.summaryId, level: leafResult.level },
|
|
166
|
+
condenseResult: null,
|
|
167
|
+
});
|
|
168
|
+
let tokensAfter = tokensAfterLeaf;
|
|
169
|
+
let condensed = false;
|
|
170
|
+
let createdSummaryId = leafResult.summaryId;
|
|
171
|
+
let level = leafResult.level;
|
|
172
|
+
const incrementalMaxDepth = this.resolveIncrementalMaxDepth();
|
|
173
|
+
const condensedMinChunkTokens = this.resolveCondensedMinChunkTokens();
|
|
174
|
+
if (incrementalMaxDepth > 0) {
|
|
175
|
+
for (let targetDepth = 0; targetDepth < incrementalMaxDepth; targetDepth++) {
|
|
176
|
+
const fanout = this.resolveFanoutForDepth(targetDepth, false);
|
|
177
|
+
const chunk = await this.selectOldestChunkAtDepth(conversationId, targetDepth);
|
|
178
|
+
if (chunk.items.length < fanout || chunk.summaryTokens < condensedMinChunkTokens) {
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
const passTokensBefore = await this.summaryStore.getContextTokenCount(conversationId);
|
|
182
|
+
const condenseResult = await this.condensedPass(conversationId, chunk.items, targetDepth, summarize);
|
|
183
|
+
const passTokensAfter = await this.summaryStore.getContextTokenCount(conversationId);
|
|
184
|
+
await this.persistCompactionEvents({
|
|
185
|
+
conversationId,
|
|
186
|
+
tokensBefore: passTokensBefore,
|
|
187
|
+
tokensAfterLeaf: passTokensBefore,
|
|
188
|
+
tokensAfterFinal: passTokensAfter,
|
|
189
|
+
leafResult: null,
|
|
190
|
+
condenseResult,
|
|
191
|
+
});
|
|
192
|
+
tokensAfter = passTokensAfter;
|
|
193
|
+
condensed = true;
|
|
194
|
+
createdSummaryId = condenseResult.summaryId;
|
|
195
|
+
level = condenseResult.level;
|
|
196
|
+
if (passTokensAfter >= passTokensBefore) {
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
actionTaken: true,
|
|
203
|
+
tokensBefore,
|
|
204
|
+
tokensAfter,
|
|
205
|
+
createdSummaryId,
|
|
206
|
+
condensed,
|
|
207
|
+
level,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Run a hard-trigger sweep:
|
|
212
|
+
*
|
|
213
|
+
* Phase 1: repeatedly compact raw-message chunks outside the fresh tail.
|
|
214
|
+
* Phase 2: repeatedly condense oldest summary chunks while chunk utilization
|
|
215
|
+
* remains high enough to be worthwhile.
|
|
216
|
+
*/
|
|
217
|
+
async compactFullSweep(input) {
|
|
218
|
+
const { conversationId, tokenBudget, summarize, force, hardTrigger } = input;
|
|
219
|
+
const tokensBefore = await this.summaryStore.getContextTokenCount(conversationId);
|
|
220
|
+
const threshold = Math.floor(this.config.contextThreshold * tokenBudget);
|
|
221
|
+
const leafTrigger = await this.evaluateLeafTrigger(conversationId);
|
|
222
|
+
if (!force && tokensBefore <= threshold && !leafTrigger.shouldCompact) {
|
|
223
|
+
return {
|
|
224
|
+
actionTaken: false,
|
|
225
|
+
tokensBefore,
|
|
226
|
+
tokensAfter: tokensBefore,
|
|
227
|
+
condensed: false,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const contextItems = await this.summaryStore.getContextItems(conversationId);
|
|
231
|
+
if (contextItems.length === 0) {
|
|
232
|
+
return {
|
|
233
|
+
actionTaken: false,
|
|
234
|
+
tokensBefore,
|
|
235
|
+
tokensAfter: tokensBefore,
|
|
236
|
+
condensed: false,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
let actionTaken = false;
|
|
240
|
+
let condensed = false;
|
|
241
|
+
let createdSummaryId;
|
|
242
|
+
let level;
|
|
243
|
+
let previousSummaryContent;
|
|
244
|
+
let previousTokens = tokensBefore;
|
|
245
|
+
// Phase 1: leaf passes over oldest raw chunks outside the protected tail.
|
|
246
|
+
while (true) {
|
|
247
|
+
const leafChunk = await this.selectOldestLeafChunk(conversationId);
|
|
248
|
+
if (leafChunk.items.length === 0) {
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
const passTokensBefore = await this.summaryStore.getContextTokenCount(conversationId);
|
|
252
|
+
const leafResult = await this.leafPass(conversationId, leafChunk.items, summarize, previousSummaryContent);
|
|
253
|
+
const passTokensAfter = await this.summaryStore.getContextTokenCount(conversationId);
|
|
254
|
+
await this.persistCompactionEvents({
|
|
255
|
+
conversationId,
|
|
256
|
+
tokensBefore: passTokensBefore,
|
|
257
|
+
tokensAfterLeaf: passTokensAfter,
|
|
258
|
+
tokensAfterFinal: passTokensAfter,
|
|
259
|
+
leafResult: { summaryId: leafResult.summaryId, level: leafResult.level },
|
|
260
|
+
condenseResult: null,
|
|
261
|
+
});
|
|
262
|
+
actionTaken = true;
|
|
263
|
+
createdSummaryId = leafResult.summaryId;
|
|
264
|
+
level = leafResult.level;
|
|
265
|
+
previousSummaryContent = leafResult.content;
|
|
266
|
+
if (passTokensAfter >= passTokensBefore || passTokensAfter >= previousTokens) {
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
previousTokens = passTokensAfter;
|
|
270
|
+
}
|
|
271
|
+
// Phase 2: depth-aware condensed passes, always processing shallowest depth first.
|
|
272
|
+
while (true) {
|
|
273
|
+
const candidate = await this.selectShallowestCondensationCandidate({
|
|
274
|
+
conversationId,
|
|
275
|
+
hardTrigger: hardTrigger === true,
|
|
276
|
+
});
|
|
277
|
+
if (!candidate) {
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
const passTokensBefore = await this.summaryStore.getContextTokenCount(conversationId);
|
|
281
|
+
const condenseResult = await this.condensedPass(conversationId, candidate.chunk.items, candidate.targetDepth, summarize);
|
|
282
|
+
const passTokensAfter = await this.summaryStore.getContextTokenCount(conversationId);
|
|
283
|
+
await this.persistCompactionEvents({
|
|
284
|
+
conversationId,
|
|
285
|
+
tokensBefore: passTokensBefore,
|
|
286
|
+
tokensAfterLeaf: passTokensBefore,
|
|
287
|
+
tokensAfterFinal: passTokensAfter,
|
|
288
|
+
leafResult: null,
|
|
289
|
+
condenseResult,
|
|
290
|
+
});
|
|
291
|
+
actionTaken = true;
|
|
292
|
+
condensed = true;
|
|
293
|
+
createdSummaryId = condenseResult.summaryId;
|
|
294
|
+
level = condenseResult.level;
|
|
295
|
+
if (passTokensAfter >= passTokensBefore || passTokensAfter >= previousTokens) {
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
previousTokens = passTokensAfter;
|
|
299
|
+
}
|
|
300
|
+
const tokensAfter = await this.summaryStore.getContextTokenCount(conversationId);
|
|
301
|
+
return {
|
|
302
|
+
actionTaken,
|
|
303
|
+
tokensBefore,
|
|
304
|
+
tokensAfter,
|
|
305
|
+
createdSummaryId,
|
|
306
|
+
condensed,
|
|
307
|
+
level,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
// ── compactUntilUnder ────────────────────────────────────────────────────
|
|
311
|
+
/** Compact until under the requested target, running up to maxRounds. */
|
|
312
|
+
async compactUntilUnder(input) {
|
|
313
|
+
const { conversationId, tokenBudget, summarize } = input;
|
|
314
|
+
const targetTokens = typeof input.targetTokens === "number" &&
|
|
315
|
+
Number.isFinite(input.targetTokens) &&
|
|
316
|
+
input.targetTokens > 0
|
|
317
|
+
? Math.floor(input.targetTokens)
|
|
318
|
+
: tokenBudget;
|
|
319
|
+
const storedTokens = await this.summaryStore.getContextTokenCount(conversationId);
|
|
320
|
+
const liveTokens = typeof input.currentTokens === "number" &&
|
|
321
|
+
Number.isFinite(input.currentTokens) &&
|
|
322
|
+
input.currentTokens > 0
|
|
323
|
+
? Math.floor(input.currentTokens)
|
|
324
|
+
: 0;
|
|
325
|
+
let lastTokens = Math.max(storedTokens, liveTokens);
|
|
326
|
+
// For forced overflow recovery, callers may pass an observed count that
|
|
327
|
+
// equals the context budget. Treat equality as still needing a compaction
|
|
328
|
+
// attempt so we can create headroom for provider-side framing overhead.
|
|
329
|
+
if (lastTokens < targetTokens) {
|
|
330
|
+
return { success: true, rounds: 0, finalTokens: lastTokens };
|
|
331
|
+
}
|
|
332
|
+
for (let round = 1; round <= this.config.maxRounds; round++) {
|
|
333
|
+
const result = await this.compact({
|
|
334
|
+
conversationId,
|
|
335
|
+
tokenBudget,
|
|
336
|
+
summarize,
|
|
337
|
+
force: true,
|
|
338
|
+
});
|
|
339
|
+
if (result.tokensAfter <= targetTokens) {
|
|
340
|
+
return {
|
|
341
|
+
success: true,
|
|
342
|
+
rounds: round,
|
|
343
|
+
finalTokens: result.tokensAfter,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
// No progress -- bail to avoid infinite loop
|
|
347
|
+
if (!result.actionTaken || result.tokensAfter >= lastTokens) {
|
|
348
|
+
return {
|
|
349
|
+
success: false,
|
|
350
|
+
rounds: round,
|
|
351
|
+
finalTokens: result.tokensAfter,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
lastTokens = result.tokensAfter;
|
|
355
|
+
}
|
|
356
|
+
// Exhausted all rounds
|
|
357
|
+
const finalTokens = await this.summaryStore.getContextTokenCount(conversationId);
|
|
358
|
+
return {
|
|
359
|
+
success: finalTokens <= targetTokens,
|
|
360
|
+
rounds: this.config.maxRounds,
|
|
361
|
+
finalTokens,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
// ── Private helpers ──────────────────────────────────────────────────────
|
|
365
|
+
/** Normalize configured leaf chunk size to a safe positive integer. */
|
|
366
|
+
resolveLeafChunkTokens() {
|
|
367
|
+
if (typeof this.config.leafChunkTokens === "number" &&
|
|
368
|
+
Number.isFinite(this.config.leafChunkTokens) &&
|
|
369
|
+
this.config.leafChunkTokens > 0) {
|
|
370
|
+
return Math.floor(this.config.leafChunkTokens);
|
|
371
|
+
}
|
|
372
|
+
return DEFAULT_LEAF_CHUNK_TOKENS;
|
|
373
|
+
}
|
|
374
|
+
/** Normalize configured fresh tail count to a safe non-negative integer. */
|
|
375
|
+
resolveFreshTailCount() {
|
|
376
|
+
if (typeof this.config.freshTailCount === "number" &&
|
|
377
|
+
Number.isFinite(this.config.freshTailCount) &&
|
|
378
|
+
this.config.freshTailCount > 0) {
|
|
379
|
+
return Math.floor(this.config.freshTailCount);
|
|
380
|
+
}
|
|
381
|
+
return 0;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Compute the ordinal boundary for protected fresh messages.
|
|
385
|
+
*
|
|
386
|
+
* Messages with ordinal >= returned value are preserved as fresh tail.
|
|
387
|
+
*/
|
|
388
|
+
resolveFreshTailOrdinal(contextItems) {
|
|
389
|
+
const freshTailCount = this.resolveFreshTailCount();
|
|
390
|
+
if (freshTailCount <= 0) {
|
|
391
|
+
return Infinity;
|
|
392
|
+
}
|
|
393
|
+
const rawMessageItems = contextItems.filter((item) => item.itemType === "message" && item.messageId != null);
|
|
394
|
+
if (rawMessageItems.length === 0) {
|
|
395
|
+
return Infinity;
|
|
396
|
+
}
|
|
397
|
+
const tailStartIdx = Math.max(0, rawMessageItems.length - freshTailCount);
|
|
398
|
+
return rawMessageItems[tailStartIdx]?.ordinal ?? Infinity;
|
|
399
|
+
}
|
|
400
|
+
/** Resolve message token count with a content-length fallback. */
|
|
401
|
+
async getMessageTokenCount(messageId) {
|
|
402
|
+
const message = await this.conversationStore.getMessageById(messageId);
|
|
403
|
+
if (!message) {
|
|
404
|
+
return 0;
|
|
405
|
+
}
|
|
406
|
+
if (typeof message.tokenCount === "number" &&
|
|
407
|
+
Number.isFinite(message.tokenCount) &&
|
|
408
|
+
message.tokenCount > 0) {
|
|
409
|
+
return message.tokenCount;
|
|
410
|
+
}
|
|
411
|
+
return estimateTokens(message.content);
|
|
412
|
+
}
|
|
413
|
+
/** Sum raw message tokens outside the protected fresh tail. */
|
|
414
|
+
async countRawTokensOutsideFreshTail(conversationId) {
|
|
415
|
+
const contextItems = await this.summaryStore.getContextItems(conversationId);
|
|
416
|
+
const freshTailOrdinal = this.resolveFreshTailOrdinal(contextItems);
|
|
417
|
+
let rawTokens = 0;
|
|
418
|
+
for (const item of contextItems) {
|
|
419
|
+
if (item.ordinal >= freshTailOrdinal) {
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
if (item.itemType !== "message" || item.messageId == null) {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
rawTokens += await this.getMessageTokenCount(item.messageId);
|
|
426
|
+
}
|
|
427
|
+
return rawTokens;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Select the oldest contiguous raw-message chunk outside fresh tail.
|
|
431
|
+
*
|
|
432
|
+
* The selected chunk size is capped by `leafChunkTokens`, but we always pick
|
|
433
|
+
* at least one message when any compactable message exists.
|
|
434
|
+
*/
|
|
435
|
+
async selectOldestLeafChunk(conversationId) {
|
|
436
|
+
const contextItems = await this.summaryStore.getContextItems(conversationId);
|
|
437
|
+
const freshTailOrdinal = this.resolveFreshTailOrdinal(contextItems);
|
|
438
|
+
const threshold = this.resolveLeafChunkTokens();
|
|
439
|
+
let rawTokensOutsideTail = 0;
|
|
440
|
+
for (const item of contextItems) {
|
|
441
|
+
if (item.ordinal >= freshTailOrdinal) {
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
if (item.itemType !== "message" || item.messageId == null) {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
rawTokensOutsideTail += await this.getMessageTokenCount(item.messageId);
|
|
448
|
+
}
|
|
449
|
+
const chunk = [];
|
|
450
|
+
let chunkTokens = 0;
|
|
451
|
+
let started = false;
|
|
452
|
+
for (const item of contextItems) {
|
|
453
|
+
if (item.ordinal >= freshTailOrdinal) {
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
if (!started) {
|
|
457
|
+
if (item.itemType !== "message" || item.messageId == null) {
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
started = true;
|
|
461
|
+
}
|
|
462
|
+
else if (item.itemType !== "message" || item.messageId == null) {
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
if (item.messageId == null) {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const messageTokens = await this.getMessageTokenCount(item.messageId);
|
|
469
|
+
if (chunk.length > 0 && chunkTokens + messageTokens > threshold) {
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
chunk.push(item);
|
|
473
|
+
chunkTokens += messageTokens;
|
|
474
|
+
if (chunkTokens >= threshold) {
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return { items: chunk, rawTokensOutsideTail, threshold };
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Resolve recent summary continuity for a leaf pass.
|
|
482
|
+
*
|
|
483
|
+
* Collects up to two most recent summary context items that precede the
|
|
484
|
+
* compacted raw-message chunk and returns their combined content.
|
|
485
|
+
*/
|
|
486
|
+
async resolvePriorLeafSummaryContext(conversationId, messageItems) {
|
|
487
|
+
if (messageItems.length === 0) {
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
const startOrdinal = Math.min(...messageItems.map((item) => item.ordinal));
|
|
491
|
+
const priorSummaryItems = (await this.summaryStore.getContextItems(conversationId))
|
|
492
|
+
.filter((item) => item.ordinal < startOrdinal &&
|
|
493
|
+
item.itemType === "summary" &&
|
|
494
|
+
typeof item.summaryId === "string")
|
|
495
|
+
.slice(-2);
|
|
496
|
+
if (priorSummaryItems.length === 0) {
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
const summaryContents = [];
|
|
500
|
+
for (const item of priorSummaryItems) {
|
|
501
|
+
if (typeof item.summaryId !== "string") {
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
const summary = await this.summaryStore.getSummary(item.summaryId);
|
|
505
|
+
const content = summary?.content.trim();
|
|
506
|
+
if (content) {
|
|
507
|
+
summaryContents.push(content);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (summaryContents.length === 0) {
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
return summaryContents.join("\n\n");
|
|
514
|
+
}
|
|
515
|
+
/** Resolve summary token count with content-length fallback. */
|
|
516
|
+
resolveSummaryTokenCount(summary) {
|
|
517
|
+
if (typeof summary.tokenCount === "number" &&
|
|
518
|
+
Number.isFinite(summary.tokenCount) &&
|
|
519
|
+
summary.tokenCount > 0) {
|
|
520
|
+
return summary.tokenCount;
|
|
521
|
+
}
|
|
522
|
+
return estimateTokens(summary.content);
|
|
523
|
+
}
|
|
524
|
+
/** Resolve message token count with content-length fallback. */
|
|
525
|
+
resolveMessageTokenCount(message) {
|
|
526
|
+
if (typeof message.tokenCount === "number" &&
|
|
527
|
+
Number.isFinite(message.tokenCount) &&
|
|
528
|
+
message.tokenCount > 0) {
|
|
529
|
+
return message.tokenCount;
|
|
530
|
+
}
|
|
531
|
+
return estimateTokens(message.content);
|
|
532
|
+
}
|
|
533
|
+
resolveLeafMinFanout() {
|
|
534
|
+
if (typeof this.config.leafMinFanout === "number" &&
|
|
535
|
+
Number.isFinite(this.config.leafMinFanout) &&
|
|
536
|
+
this.config.leafMinFanout > 0) {
|
|
537
|
+
return Math.floor(this.config.leafMinFanout);
|
|
538
|
+
}
|
|
539
|
+
return 8;
|
|
540
|
+
}
|
|
541
|
+
resolveCondensedMinFanout() {
|
|
542
|
+
if (typeof this.config.condensedMinFanout === "number" &&
|
|
543
|
+
Number.isFinite(this.config.condensedMinFanout) &&
|
|
544
|
+
this.config.condensedMinFanout > 0) {
|
|
545
|
+
return Math.floor(this.config.condensedMinFanout);
|
|
546
|
+
}
|
|
547
|
+
return 4;
|
|
548
|
+
}
|
|
549
|
+
resolveCondensedMinFanoutHard() {
|
|
550
|
+
if (typeof this.config.condensedMinFanoutHard === "number" &&
|
|
551
|
+
Number.isFinite(this.config.condensedMinFanoutHard) &&
|
|
552
|
+
this.config.condensedMinFanoutHard > 0) {
|
|
553
|
+
return Math.floor(this.config.condensedMinFanoutHard);
|
|
554
|
+
}
|
|
555
|
+
return 2;
|
|
556
|
+
}
|
|
557
|
+
resolveIncrementalMaxDepth() {
|
|
558
|
+
if (typeof this.config.incrementalMaxDepth === "number" &&
|
|
559
|
+
Number.isFinite(this.config.incrementalMaxDepth)) {
|
|
560
|
+
if (this.config.incrementalMaxDepth < 0)
|
|
561
|
+
return Infinity;
|
|
562
|
+
if (this.config.incrementalMaxDepth > 0)
|
|
563
|
+
return Math.floor(this.config.incrementalMaxDepth);
|
|
564
|
+
}
|
|
565
|
+
return 0;
|
|
566
|
+
}
|
|
567
|
+
resolveFanoutForDepth(targetDepth, hardTrigger) {
|
|
568
|
+
if (hardTrigger) {
|
|
569
|
+
return this.resolveCondensedMinFanoutHard();
|
|
570
|
+
}
|
|
571
|
+
if (targetDepth === 0) {
|
|
572
|
+
return this.resolveLeafMinFanout();
|
|
573
|
+
}
|
|
574
|
+
return this.resolveCondensedMinFanout();
|
|
575
|
+
}
|
|
576
|
+
/** Minimum condensed input size before we run another condensed pass. */
|
|
577
|
+
resolveCondensedMinChunkTokens() {
|
|
578
|
+
const chunkTarget = this.resolveLeafChunkTokens();
|
|
579
|
+
const ratioFloor = Math.floor(chunkTarget * CONDENSED_MIN_INPUT_RATIO);
|
|
580
|
+
return Math.max(this.config.condensedTargetTokens, ratioFloor);
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Find the shallowest depth with an eligible same-depth summary chunk.
|
|
584
|
+
*/
|
|
585
|
+
async selectShallowestCondensationCandidate(params) {
|
|
586
|
+
const { conversationId, hardTrigger } = params;
|
|
587
|
+
const contextItems = await this.summaryStore.getContextItems(conversationId);
|
|
588
|
+
const freshTailOrdinal = this.resolveFreshTailOrdinal(contextItems);
|
|
589
|
+
const minChunkTokens = this.resolveCondensedMinChunkTokens();
|
|
590
|
+
const depthLevels = await this.summaryStore.getDistinctDepthsInContext(conversationId, {
|
|
591
|
+
maxOrdinalExclusive: freshTailOrdinal,
|
|
592
|
+
});
|
|
593
|
+
for (const targetDepth of depthLevels) {
|
|
594
|
+
const fanout = this.resolveFanoutForDepth(targetDepth, hardTrigger);
|
|
595
|
+
const chunk = await this.selectOldestChunkAtDepth(conversationId, targetDepth, freshTailOrdinal);
|
|
596
|
+
if (chunk.items.length < fanout) {
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (chunk.summaryTokens < minChunkTokens) {
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
return { targetDepth, chunk };
|
|
603
|
+
}
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Select the oldest contiguous summary chunk at a specific summary depth.
|
|
608
|
+
*
|
|
609
|
+
* Once selection starts, any non-summary item or depth mismatch terminates
|
|
610
|
+
* the chunk to prevent mixed-depth condensation.
|
|
611
|
+
*/
|
|
612
|
+
async selectOldestChunkAtDepth(conversationId, targetDepth, freshTailOrdinalOverride) {
|
|
613
|
+
const contextItems = await this.summaryStore.getContextItems(conversationId);
|
|
614
|
+
const freshTailOrdinal = typeof freshTailOrdinalOverride === "number"
|
|
615
|
+
? freshTailOrdinalOverride
|
|
616
|
+
: this.resolveFreshTailOrdinal(contextItems);
|
|
617
|
+
const chunkTokenBudget = this.resolveLeafChunkTokens();
|
|
618
|
+
const chunk = [];
|
|
619
|
+
let summaryTokens = 0;
|
|
620
|
+
for (const item of contextItems) {
|
|
621
|
+
if (item.ordinal >= freshTailOrdinal) {
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
if (item.itemType !== "summary" || item.summaryId == null) {
|
|
625
|
+
if (chunk.length > 0) {
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
const summary = await this.summaryStore.getSummary(item.summaryId);
|
|
631
|
+
if (!summary) {
|
|
632
|
+
if (chunk.length > 0) {
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
if (summary.depth !== targetDepth) {
|
|
638
|
+
if (chunk.length > 0) {
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
const tokenCount = this.resolveSummaryTokenCount(summary);
|
|
644
|
+
if (chunk.length > 0 && summaryTokens + tokenCount > chunkTokenBudget) {
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
chunk.push(item);
|
|
648
|
+
summaryTokens += tokenCount;
|
|
649
|
+
if (summaryTokens >= chunkTokenBudget) {
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return { items: chunk, summaryTokens };
|
|
654
|
+
}
|
|
655
|
+
async resolvePriorSummaryContextAtDepth(conversationId, summaryItems, targetDepth) {
|
|
656
|
+
if (summaryItems.length === 0) {
|
|
657
|
+
return undefined;
|
|
658
|
+
}
|
|
659
|
+
const startOrdinal = Math.min(...summaryItems.map((item) => item.ordinal));
|
|
660
|
+
const priorSummaryItems = (await this.summaryStore.getContextItems(conversationId))
|
|
661
|
+
.filter((item) => item.ordinal < startOrdinal &&
|
|
662
|
+
item.itemType === "summary" &&
|
|
663
|
+
typeof item.summaryId === "string")
|
|
664
|
+
.slice(-4);
|
|
665
|
+
if (priorSummaryItems.length === 0) {
|
|
666
|
+
return undefined;
|
|
667
|
+
}
|
|
668
|
+
const summaryContents = [];
|
|
669
|
+
for (const item of priorSummaryItems) {
|
|
670
|
+
if (typeof item.summaryId !== "string") {
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const summary = await this.summaryStore.getSummary(item.summaryId);
|
|
674
|
+
if (!summary || summary.depth !== targetDepth) {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
const content = summary.content.trim();
|
|
678
|
+
if (content) {
|
|
679
|
+
summaryContents.push(content);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (summaryContents.length === 0) {
|
|
683
|
+
return undefined;
|
|
684
|
+
}
|
|
685
|
+
return summaryContents.slice(-2).join("\n\n");
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Run three-level summarization escalation:
|
|
689
|
+
* normal -> aggressive -> deterministic fallback.
|
|
690
|
+
*/
|
|
691
|
+
async summarizeWithEscalation(params) {
|
|
692
|
+
const rawText = params.sourceText.trim();
|
|
693
|
+
const sourceText = this.config.scrubber ? this.config.scrubber.scrub(rawText) : rawText;
|
|
694
|
+
if (!sourceText) {
|
|
695
|
+
return {
|
|
696
|
+
content: "[Truncated from 0 tokens]",
|
|
697
|
+
level: "fallback",
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
const inputTokens = Math.max(1, estimateTokens(sourceText));
|
|
701
|
+
let summaryText = await params.summarize(sourceText, false, params.options);
|
|
702
|
+
let level = "normal";
|
|
703
|
+
if (estimateTokens(summaryText) >= inputTokens) {
|
|
704
|
+
summaryText = await params.summarize(sourceText, true, params.options);
|
|
705
|
+
level = "aggressive";
|
|
706
|
+
if (estimateTokens(summaryText) >= inputTokens) {
|
|
707
|
+
const truncated = sourceText.length > FALLBACK_MAX_CHARS
|
|
708
|
+
? sourceText.slice(0, FALLBACK_MAX_CHARS)
|
|
709
|
+
: sourceText;
|
|
710
|
+
summaryText = `${truncated}\n[Truncated from ${inputTokens} tokens]`;
|
|
711
|
+
level = "fallback";
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return { content: summaryText, level };
|
|
715
|
+
}
|
|
716
|
+
// ── Private: Leaf Pass ───────────────────────────────────────────────────
|
|
717
|
+
/**
|
|
718
|
+
* Summarize a chunk of messages into one leaf summary.
|
|
719
|
+
*/
|
|
720
|
+
async leafPass(conversationId, messageItems, summarize, previousSummaryContent) {
|
|
721
|
+
// Fetch full message content for each context item
|
|
722
|
+
const messageContents = [];
|
|
723
|
+
for (const item of messageItems) {
|
|
724
|
+
if (item.messageId == null) {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const msg = await this.conversationStore.getMessageById(item.messageId);
|
|
728
|
+
if (msg) {
|
|
729
|
+
messageContents.push({
|
|
730
|
+
messageId: msg.messageId,
|
|
731
|
+
content: msg.content,
|
|
732
|
+
createdAt: msg.createdAt,
|
|
733
|
+
tokenCount: this.resolveMessageTokenCount(msg),
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const concatenated = messageContents
|
|
738
|
+
.map((message) => `[${formatTimestamp(message.createdAt, this.config.timezone)}]\n${message.content}`)
|
|
739
|
+
.join("\n\n");
|
|
740
|
+
const fileIds = dedupeOrderedIds(messageContents.flatMap((message) => extractFileIdsFromContent(message.content)));
|
|
741
|
+
const summary = await this.summarizeWithEscalation({
|
|
742
|
+
sourceText: concatenated,
|
|
743
|
+
summarize,
|
|
744
|
+
options: {
|
|
745
|
+
previousSummary: previousSummaryContent,
|
|
746
|
+
isCondensed: false,
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
// Persist the leaf summary
|
|
750
|
+
const summaryId = generateSummaryId(summary.content);
|
|
751
|
+
const tokenCount = estimateTokens(summary.content);
|
|
752
|
+
await this.summaryStore.insertSummary({
|
|
753
|
+
summaryId,
|
|
754
|
+
conversationId,
|
|
755
|
+
kind: "leaf",
|
|
756
|
+
depth: 0,
|
|
757
|
+
content: summary.content,
|
|
758
|
+
tokenCount,
|
|
759
|
+
fileIds,
|
|
760
|
+
earliestAt: messageContents.length > 0
|
|
761
|
+
? new Date(Math.min(...messageContents.map((message) => message.createdAt.getTime())))
|
|
762
|
+
: undefined,
|
|
763
|
+
latestAt: messageContents.length > 0
|
|
764
|
+
? new Date(Math.max(...messageContents.map((message) => message.createdAt.getTime())))
|
|
765
|
+
: undefined,
|
|
766
|
+
descendantCount: 0,
|
|
767
|
+
descendantTokenCount: 0,
|
|
768
|
+
sourceMessageTokenCount: messageContents.reduce((sum, message) => sum + Math.max(0, Math.floor(message.tokenCount)), 0),
|
|
769
|
+
});
|
|
770
|
+
// Link to source messages
|
|
771
|
+
const messageIds = messageContents.map((m) => m.messageId);
|
|
772
|
+
await this.summaryStore.linkSummaryToMessages(summaryId, messageIds);
|
|
773
|
+
// Replace the message range in context with the new summary
|
|
774
|
+
const ordinals = messageItems.map((ci) => ci.ordinal);
|
|
775
|
+
const startOrdinal = Math.min(...ordinals);
|
|
776
|
+
const endOrdinal = Math.max(...ordinals);
|
|
777
|
+
await this.summaryStore.replaceContextRangeWithSummary({
|
|
778
|
+
conversationId,
|
|
779
|
+
startOrdinal,
|
|
780
|
+
endOrdinal,
|
|
781
|
+
summaryId,
|
|
782
|
+
});
|
|
783
|
+
return { summaryId, level: summary.level, content: summary.content };
|
|
784
|
+
}
|
|
785
|
+
// ── Private: Condensed Pass ──────────────────────────────────────────────
|
|
786
|
+
/**
|
|
787
|
+
* Condense one ratio-sized summary chunk into a single condensed summary.
|
|
788
|
+
*/
|
|
789
|
+
async condensedPass(conversationId, summaryItems, targetDepth, summarize) {
|
|
790
|
+
// Fetch full summary records
|
|
791
|
+
const summaryRecords = [];
|
|
792
|
+
for (const item of summaryItems) {
|
|
793
|
+
if (item.summaryId == null) {
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
const rec = await this.summaryStore.getSummary(item.summaryId);
|
|
797
|
+
if (rec) {
|
|
798
|
+
summaryRecords.push(rec);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
const concatenated = summaryRecords
|
|
802
|
+
.map((summary) => {
|
|
803
|
+
const earliestAt = summary.earliestAt ?? summary.createdAt;
|
|
804
|
+
const latestAt = summary.latestAt ?? summary.createdAt;
|
|
805
|
+
const tz = this.config.timezone;
|
|
806
|
+
const header = `[${formatTimestamp(earliestAt, tz)} - ${formatTimestamp(latestAt, tz)}]`;
|
|
807
|
+
return `${header}\n${summary.content}`;
|
|
808
|
+
})
|
|
809
|
+
.join("\n\n");
|
|
810
|
+
const fileIds = dedupeOrderedIds(summaryRecords.flatMap((summary) => [
|
|
811
|
+
...summary.fileIds,
|
|
812
|
+
...extractFileIdsFromContent(summary.content),
|
|
813
|
+
]));
|
|
814
|
+
const previousSummaryContent = targetDepth === 0
|
|
815
|
+
? await this.resolvePriorSummaryContextAtDepth(conversationId, summaryItems, targetDepth)
|
|
816
|
+
: undefined;
|
|
817
|
+
const condensed = await this.summarizeWithEscalation({
|
|
818
|
+
sourceText: concatenated,
|
|
819
|
+
summarize,
|
|
820
|
+
options: {
|
|
821
|
+
previousSummary: previousSummaryContent,
|
|
822
|
+
isCondensed: true,
|
|
823
|
+
depth: targetDepth + 1,
|
|
824
|
+
},
|
|
825
|
+
});
|
|
826
|
+
// Persist the condensed summary
|
|
827
|
+
const summaryId = generateSummaryId(condensed.content);
|
|
828
|
+
const tokenCount = estimateTokens(condensed.content);
|
|
829
|
+
await this.summaryStore.insertSummary({
|
|
830
|
+
summaryId,
|
|
831
|
+
conversationId,
|
|
832
|
+
kind: "condensed",
|
|
833
|
+
depth: targetDepth + 1,
|
|
834
|
+
content: condensed.content,
|
|
835
|
+
tokenCount,
|
|
836
|
+
fileIds,
|
|
837
|
+
earliestAt: summaryRecords.length > 0
|
|
838
|
+
? new Date(Math.min(...summaryRecords.map((summary) => (summary.earliestAt ?? summary.createdAt).getTime())))
|
|
839
|
+
: undefined,
|
|
840
|
+
latestAt: summaryRecords.length > 0
|
|
841
|
+
? new Date(Math.max(...summaryRecords.map((summary) => (summary.latestAt ?? summary.createdAt).getTime())))
|
|
842
|
+
: undefined,
|
|
843
|
+
descendantCount: summaryRecords.reduce((count, summary) => {
|
|
844
|
+
const childDescendants = typeof summary.descendantCount === "number" && Number.isFinite(summary.descendantCount)
|
|
845
|
+
? Math.max(0, Math.floor(summary.descendantCount))
|
|
846
|
+
: 0;
|
|
847
|
+
return count + childDescendants + 1;
|
|
848
|
+
}, 0),
|
|
849
|
+
descendantTokenCount: summaryRecords.reduce((count, summary) => {
|
|
850
|
+
const childDescendantTokens = typeof summary.descendantTokenCount === "number" &&
|
|
851
|
+
Number.isFinite(summary.descendantTokenCount)
|
|
852
|
+
? Math.max(0, Math.floor(summary.descendantTokenCount))
|
|
853
|
+
: 0;
|
|
854
|
+
return count + Math.max(0, Math.floor(summary.tokenCount)) + childDescendantTokens;
|
|
855
|
+
}, 0),
|
|
856
|
+
sourceMessageTokenCount: summaryRecords.reduce((count, summary) => {
|
|
857
|
+
const sourceTokens = typeof summary.sourceMessageTokenCount === "number" &&
|
|
858
|
+
Number.isFinite(summary.sourceMessageTokenCount)
|
|
859
|
+
? Math.max(0, Math.floor(summary.sourceMessageTokenCount))
|
|
860
|
+
: 0;
|
|
861
|
+
return count + sourceTokens;
|
|
862
|
+
}, 0),
|
|
863
|
+
});
|
|
864
|
+
// Link to parent summaries
|
|
865
|
+
const parentSummaryIds = summaryRecords.map((s) => s.summaryId);
|
|
866
|
+
await this.summaryStore.linkSummaryToParents(summaryId, parentSummaryIds);
|
|
867
|
+
// Replace all summary items in context with the condensed summary
|
|
868
|
+
const ordinals = summaryItems.map((ci) => ci.ordinal);
|
|
869
|
+
const startOrdinal = Math.min(...ordinals);
|
|
870
|
+
const endOrdinal = Math.max(...ordinals);
|
|
871
|
+
await this.summaryStore.replaceContextRangeWithSummary({
|
|
872
|
+
conversationId,
|
|
873
|
+
startOrdinal,
|
|
874
|
+
endOrdinal,
|
|
875
|
+
summaryId,
|
|
876
|
+
});
|
|
877
|
+
return { summaryId, level: condensed.level };
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Persist durable compaction events into canonical history as message parts.
|
|
881
|
+
*
|
|
882
|
+
* Event persistence is best-effort: failures are swallowed to avoid
|
|
883
|
+
* compromising the core compaction path.
|
|
884
|
+
*/
|
|
885
|
+
async persistCompactionEvents(input) {
|
|
886
|
+
const { conversationId, tokensBefore, tokensAfterLeaf, tokensAfterFinal, leafResult, condenseResult, } = input;
|
|
887
|
+
if (!leafResult && !condenseResult) {
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
const conversation = await this.conversationStore.getConversation(conversationId);
|
|
891
|
+
if (!conversation) {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
const createdSummaryIds = [leafResult?.summaryId, condenseResult?.summaryId].filter((id) => typeof id === "string" && id.length > 0);
|
|
895
|
+
const condensedPassOccurred = condenseResult !== null;
|
|
896
|
+
if (leafResult) {
|
|
897
|
+
await this.persistCompactionEvent({
|
|
898
|
+
conversationId,
|
|
899
|
+
sessionId: conversation.sessionId,
|
|
900
|
+
pass: "leaf",
|
|
901
|
+
level: leafResult.level,
|
|
902
|
+
tokensBefore,
|
|
903
|
+
tokensAfter: tokensAfterLeaf,
|
|
904
|
+
createdSummaryId: leafResult.summaryId,
|
|
905
|
+
createdSummaryIds,
|
|
906
|
+
condensedPassOccurred,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
if (condenseResult) {
|
|
910
|
+
await this.persistCompactionEvent({
|
|
911
|
+
conversationId,
|
|
912
|
+
sessionId: conversation.sessionId,
|
|
913
|
+
pass: "condensed",
|
|
914
|
+
level: condenseResult.level,
|
|
915
|
+
tokensBefore: tokensAfterLeaf,
|
|
916
|
+
tokensAfter: tokensAfterFinal,
|
|
917
|
+
createdSummaryId: condenseResult.summaryId,
|
|
918
|
+
createdSummaryIds,
|
|
919
|
+
condensedPassOccurred,
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
/** Write one compaction event message + part atomically where possible. */
|
|
924
|
+
async persistCompactionEvent(input) {
|
|
925
|
+
const content = `LCM compaction ${input.pass} pass (${input.level}): ${input.tokensBefore} -> ${input.tokensAfter}`;
|
|
926
|
+
const metadata = JSON.stringify({
|
|
927
|
+
conversationId: input.conversationId,
|
|
928
|
+
pass: input.pass,
|
|
929
|
+
level: input.level,
|
|
930
|
+
tokensBefore: input.tokensBefore,
|
|
931
|
+
tokensAfter: input.tokensAfter,
|
|
932
|
+
createdSummaryId: input.createdSummaryId,
|
|
933
|
+
createdSummaryIds: input.createdSummaryIds,
|
|
934
|
+
condensedPassOccurred: input.condensedPassOccurred,
|
|
935
|
+
});
|
|
936
|
+
const writeEvent = async () => {
|
|
937
|
+
const seq = (await this.conversationStore.getMaxSeq(input.conversationId)) + 1;
|
|
938
|
+
const eventMessage = await this.conversationStore.createMessage({
|
|
939
|
+
conversationId: input.conversationId,
|
|
940
|
+
seq,
|
|
941
|
+
role: "system",
|
|
942
|
+
content,
|
|
943
|
+
tokenCount: estimateTokens(content),
|
|
944
|
+
});
|
|
945
|
+
const parts = [
|
|
946
|
+
{
|
|
947
|
+
sessionId: input.sessionId,
|
|
948
|
+
partType: "compaction",
|
|
949
|
+
ordinal: 0,
|
|
950
|
+
textContent: content,
|
|
951
|
+
metadata,
|
|
952
|
+
},
|
|
953
|
+
];
|
|
954
|
+
await this.conversationStore.createMessageParts(eventMessage.messageId, parts);
|
|
955
|
+
};
|
|
956
|
+
try {
|
|
957
|
+
await this.conversationStore.withTransaction(() => writeEvent());
|
|
958
|
+
}
|
|
959
|
+
catch {
|
|
960
|
+
// Compaction should still succeed if event persistence fails.
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
//# sourceMappingURL=compaction.js.map
|