feat(session): opt-in packed chunk rows in the JSONL log

Providers stream token-sized deltas, so a session log stores hundreds of
near-identical assistant/chunk lines whose JSON envelopes dwarf their
payloads (~56x measured on a real DeepSeek session, 73% of file bytes).

Add a lossless storage codec to dsh-session: packChunkRuns() folds each
run of >=3 consecutive same-block delta chunks into one storage row --
text-chunks / reasoning-chunks / tool-call-chunks, bare slash-less tags
like the header line's 'session' so rows cannot be confused with session
events -- and decodeStorageRecord() expands rows back to the exact
original events (seq0/time0 + dt gap array reconstruct every member's
seq/time; tool-call rows carry the run-constant id/name). The encoder
whitelists exact shapes and stores anything unrecognized verbatim; the
decoder validates row-tagged values and fails loud on malformation.

The JSONL backend gains a packChunks config (default false). Writing
packs only when enabled -- default-off output stays byte-identical to
the previous layout, so snapshot goldens are untouched. Reading is
layout-blind: scanLog always decodes rows and now checks seq contiguity
with a cursor instead of the line index, so packed, unpacked, and mixed
files all load identically. Fixture readers (llm-replay parseSessionLog,
acp-snapshot normalizeSessionLog) share the codec; the normalizer zeroes
a row's time0/dt exactly like an event's time. The two demo bundles
plumb packChunks from cordis.yml to the backend.

Measured on a real coding session: 105 KB -> 42 KB (-60%), 475 lines ->
74, with reasoning/tool-call heavy sessions saving the most. Covered by
example + fast-check round-trip codec tests, backend packed/mixed/torn-
tail specs, and an end-to-end demo run loading a packed log through a
default-config backend.
This commit is contained in:
kingwl
2026-07-15 21:26:36 +08:00
parent b045b553a9
commit 7ef21239ca
21 changed files with 816 additions and 51 deletions

View File

@@ -31,6 +31,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
| `packChunks` | `false` | write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`) |
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor.

View File

@@ -39,6 +39,8 @@ export interface Config {
tools?: ToolsConfig
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */
packChunks?: boolean
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
skills?: agentCore.SkillConfig
}
@@ -57,6 +59,7 @@ export const Config: z<Config> = z.object({
// TODO(single-default-literal): share this schema default and the defensive
// apply() fallback through one named constant while retaining both boundaries.
persistenceRoot: z.string().default('./.sessions'),
packChunks: z.boolean().default(false),
skills: agentCore.SkillConfigSchema,
})
/* jscpd:ignore-end */
@@ -76,6 +79,9 @@ export function apply(ctx: Context, config: Config): void {
...config.skills !== undefined ? { skills: config.skills } : {},
})
ctx.plugin(UserInteractionService)
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(SessionPersistenceJsonl, {
root: config.persistenceRoot ?? './.sessions',
...config.packChunks !== undefined ? { packChunks: config.packChunks } : {},
})
ctx.plugin(acp, { model: config.model })
}

View File

@@ -31,6 +31,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
| `packChunks` | `false` | write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`) |
| `welcome` | `ready.` | the stdin-chat banner |
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |

View File

@@ -44,6 +44,8 @@ export interface Config {
tools?: ToolsConfig
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */
packChunks?: boolean
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
welcome?: string
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
@@ -67,6 +69,7 @@ export const Config: z<Config> = z.object({
// TODO(single-default-literal): share these schema defaults and defensive
// apply() fallbacks through named constants while retaining both boundaries.
persistenceRoot: z.string().default('./.sessions'),
packChunks: z.boolean().default(false),
welcome: z.string().default('ready.'),
skills: agentCore.SkillConfigSchema,
resumeSessionId: z.string(),
@@ -93,7 +96,10 @@ export function apply(ctx: Context, config: Config): void {
}],
...config.skills !== undefined ? { skills: config.skills } : {},
})
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(SessionPersistenceJsonl, {
root: config.persistenceRoot ?? './.sessions',
...config.packChunks !== undefined ? { packChunks: config.packChunks } : {},
})
ctx.plugin(UserInteractionService)
ctx.plugin(toolAskUser)
ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' })