Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions kernel/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion kernel/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "acp-kernel",
"version": "0.0.159",
"version": "0.0.160",
"description": "Framework-agnostic context-compression engine (model-driven, 3-tier LSM). Pure core: no host dependency.",
"license": "MIT",
"author": "ranxianglei",
Expand Down
71 changes: 65 additions & 6 deletions kernel/src/hide-consumed.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { clampPrefix } from "./truncate.js";
import { BLOCKED_REF } from "./refs.js";
import { COMPRESS_TOOL_NAME } from "./compress-tools.js";
import { parseLineContent, type CompressLineContentEntry } from "./parse-compress-input.js";
import type { CompressionState, CoreMessage } from "./types.js";

// Orphaned compress calls (no matching block — failed attempts, or historical
Expand Down Expand Up @@ -104,6 +105,9 @@ interface ParsedCallText {
obj: Record<string, unknown>;
content: unknown[];
contentWasString: boolean;
/** Non-null iff `content` was a plain-text LINE-FORM string (#3975); the
* object pipeline (`content`) is empty then. */
lineEntries: CompressLineContentEntry[] | null;
}

// Adapters (pi) persist the rendered ref tag in front of the tool-call text,
Expand All @@ -122,24 +126,34 @@ function parseCallText(text: string | undefined): ParsedCallText | null {
if (!parsed || typeof parsed !== "object") return null;
const obj = parsed as Record<string, unknown>;
let content: unknown[] | null = null;
let lineEntries: CompressLineContentEntry[] | null = null;
let contentWasString = false;
if (Array.isArray(obj.content)) {
content = obj.content;
} else if (typeof obj.content === "string") {
// Non-strict-tool providers (qwen etc.) sometimes stringify the content
// array inside the JSON args; the compress tool accepts it, so the
// rewrite must too. Measured: ALL 52 calls in the billion-context-pi
// #336 storm session used this form (#230).
// #336 storm session used this form (#230). When the inner value is NOT a
// JSON array, the kernel's own parser falls through to the plain-text
// LINE FORM (first line "m00100–m00120 topic", remaining lines the
// summary) — the cleanup must resolve ranges from that shape too, or such
// calls mis-resolve as orphans and keep their full summary text forever
// (#3975). parseLineContent mirrors the tool's acceptance ladder exactly.
contentWasString = true;
try {
const inner: unknown = JSON.parse(obj.content);
if (Array.isArray(inner)) content = inner;
if (Array.isArray(inner)) {
content = inner;
} else {
lineEntries = parseLineContent(obj.content);
}
} catch {
content = null;
lineEntries = parseLineContent(obj.content);
}
}
if (!content || content.length === 0) return null;
return { prefix: raw.slice(0, start), obj, content, contentWasString };
if ((!content || content.length === 0) && !lineEntries) return null;
return { prefix: raw.slice(0, start), obj, content: content ?? [], contentWasString, lineEntries };
}

// Range keys a compress call's args quote, using the same startId/endId
Expand All @@ -149,6 +163,12 @@ function quotedRangeKeys(text: string | undefined): Set<string> {
const parsed = parseCallText(text);
if (!parsed) return new Set<string>();
const out = new Set<string>();
if (parsed.lineEntries !== null) {
for (const entry of parsed.lineEntries) {
if (entry.startRef && entry.endRef) out.add(rangeKey(entry.startRef, entry.endRef));
}
return out;
}
for (const entry of parsed.content) {
if (!entry || typeof entry !== "object") continue;
const e = entry as Record<string, unknown>;
Expand All @@ -175,6 +195,20 @@ function rewriteCompressText(
): string | null {
const parsed = parseCallText(text);
if (!parsed) return null;
if (parsed.lineEntries !== null) {
const kept = parsed.lineEntries.filter(
(entry) =>
entry.startRef !== undefined &&
entry.endRef !== undefined &&
liveKeys.has(rangeKey(entry.startRef, entry.endRef)),
);
if (kept.length === 0) return null;
const { text: rebuilt } = serializeLineCompacted(kept);
// Re-emit the FULL args object with the line-form string in the content
// slot — the string shape is preserved (#230 convention for stringified
// content).
return parsed.prefix + JSON.stringify({ ...parsed.obj, content: rebuilt });
}
const { prefix, obj, content, contentWasString } = parsed;

const kept = content.filter((entry): entry is Record<string, unknown> => {
Expand Down Expand Up @@ -206,17 +240,36 @@ function rewriteCompressText(
// leading stub for recall; the block remains the durable record.
const SUMMARY_STUB_CHARS = 200;

function stubSummary(summary: string): string {
return `${clampPrefix(summary, SUMMARY_STUB_CHARS - 1)}…`;
}

function compactEntry(entry: unknown): unknown {
if (!entry || typeof entry !== "object") return entry;
const e = entry as Record<string, unknown>;
if (typeof e.summary !== "string" || e.summary.length <= SUMMARY_STUB_CHARS)
return entry;
return {
...e,
summary: `${clampPrefix(e.summary, SUMMARY_STUB_CHARS - 1)}…`,
summary: stubSummary(e.summary),
};
}

// Line-form compaction (#3975): rebuild each kept entry as "<header>\n<stubbed
// summary>" — headers stay verbatim, short or unparsable entries byte-identical.
function serializeLineCompacted(
entries: CompressLineContentEntry[],
): { text: string; changed: boolean } {
let changed = false;
const out = entries.map((entry) => {
if (entry.startRef === undefined || entry.summary.length <= SUMMARY_STUB_CHARS)
return entry.source;
changed = true;
return `${entry.header}\n${stubSummary(entry.summary)}`;
});
return { text: out.join("\n"), changed };
}

function serializeCompacted(
obj: Record<string, unknown>,
content: unknown[],
Expand All @@ -237,6 +290,12 @@ function serializeCompacted(
function compactCompressText(text: string | undefined): string | null {
const parsed = parseCallText(text);
if (!parsed) return null;
if (parsed.lineEntries !== null) {
const { text: rebuilt, changed } = serializeLineCompacted(parsed.lineEntries);
return changed
? parsed.prefix + JSON.stringify({ ...parsed.obj, content: rebuilt })
: null;
}
const { prefix, obj, content, contentWasString } = parsed;
const { text: out, changed } = serializeCompacted(
obj,
Expand Down
46 changes: 46 additions & 0 deletions kernel/src/parse-compress-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,52 @@ function parseLineEntry(
return { range };
}

/** One entry of a plain-text (line-form) compress `content` string. Entries
* whose header carries no parsable refs keep only `source`/`header` so a
* consumer can re-emit them verbatim; they never claim a range. */
export interface CompressLineContentEntry {
/** Normalized start ref (m-refs padded to 5 digits, lowercased). */
startRef?: string;
endRef?: string;
topic?: string;
/** Summary body verbatim (empty when the entry is header-only or unparsable). */
summary: string;
/** Original (trimmed, residue-stripped) entry text for faithful re-layout. */
source: string;
/** Trimmed first line of `source` — the refs/topic header. */
header: string;
}

/** Parse a plain-text (line-form) compress `content` string into structured
* entries using the SAME ladder the compress tool accepts when `content` is
* not a JSON array (#3975): stripJsonWrapperResidue → splitLineEntries →
* per-entry parseLineEntry. hide-consumed must resolve ranges from exactly
* what the tool accepts — a second, hand-rolled reader would drift. The
* single-source input is already split at ref headers by splitLineEntries,
* so coalesceLineEntries (array-of-siblings pairing) is not needed here. */
export function parseLineContent(content: string): CompressLineContentEntry[] {
const bare = stripJsonWrapperResidue(content);
return splitLineEntries(bare).map((part): CompressLineContentEntry => {
const s = typeof part === "string" ? part : "";
const nl = s.indexOf("\n");
const base: CompressLineContentEntry = {
header: (nl === -1 ? s : s.slice(0, nl)).trim(),
summary: "",
source: s,
};
if (typeof part !== "string") return base;
const outcome = parseLineEntry(part, undefined);
if ("reason" in outcome) return base;
return {
...base,
startRef: outcome.range.startRef,
endRef: outcome.range.endRef,
summary: outcome.range.summary,
topic: outcome.range.topic,
};
});
}

/** Display/retrieval label for line-form entries without an explicit topic:
* the summary's first markdown heading, else its first non-empty line,
* truncated. The topic is metadata (panels, T2 source headers, search) —
Expand Down
Loading
Loading