dsh-context-mode 0.3.2 → 0.4.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/README.md CHANGED
@@ -124,6 +124,80 @@ node node_modules/dsh-context-mode/scripts/cleanup-injected.mjs --db <path>
124
124
  node node_modules/dsh-context-mode/scripts/cleanup-injected.mjs --db <path> --apply # delete
125
125
  ```
126
126
 
127
+ ## Checkpoint transcript and archive index
128
+
129
+ A checkpoint keeps only a summary of the span it replaces, and the shipped
130
+ checkpoint format has no section for tool output — a long tool result survives
131
+ only as whatever the summarizing model chose to keep. `precompact` files that
132
+ same span into the knowledge base beforehand, but nothing told the model so,
133
+ and a summary that omits a detail reads as if the detail never existed.
134
+
135
+ This package also exports a compaction engine that closes that gap. It extends
136
+ the shipped `BasicCompactionEngine` and replaces the lossy summary with the
137
+ conversation itself, plus a pointer to everything it did not carry:
138
+
139
+ ```ts
140
+ import DshContextModeCompaction from 'dsh-context-mode/compaction'
141
+ ```
142
+
143
+ Each checkpoint it writes carries three parts:
144
+
145
+ | Part | Content |
146
+ | --- | --- |
147
+ | The shipped summary | The eight-section checkpoint, unchanged |
148
+ | `## Conversation Transcript` | user turns kept whole; assistant replies kept head+tail; tool output reduced to one index line each |
149
+ | `## Archive Index` | The `source` labels to search, and how to query them |
150
+
151
+ Every region that was clipped or dropped leaves a pointer naming the `source`
152
+ holding the original and the `seq` it came from, so the checkpoint says *which*
153
+ event lost detail rather than merely that an archive exists:
154
+
155
+ ```
156
+ ## [assistant 447]
157
+ … first 200 chars …
158
+ [... 1581 of 1981 chars elided from seq 447; retrieve with ctx_search(source: "session/<id>/narrative") ...]
159
+ … last 200 chars …
160
+
161
+ ## [tool ctx_execute seq 3424, 29808 chars → search `session/<id>/finding`]
162
+ ```
163
+
164
+ A prior checkpoint is never transcribed forward: its text is already in the
165
+ summary, and copying it would grow the transcript on every compaction.
166
+
167
+ Mount it in place of the shipped backend, inside the isolate group the shipped
168
+ one requires:
169
+
170
+ ```yaml
171
+ - id: compaction
172
+ name: cordis:group
173
+ group: true
174
+ isolate:
175
+ compaction: true
176
+ toolResultPruner: true
177
+ config:
178
+ - id: compaction-basic
179
+ name: 'dsh-context-mode/compaction' # was @deepseek-ai/dsh-compaction-basic
180
+ config:
181
+ thresholdRatio: 0.8 # compact at 80% of the window
182
+ retainRatio: 0.1 # keep the newest 10% verbatim
183
+
184
+ - id: command-compact
185
+ name: '@deepseek-ai/dsh-command-compact'
186
+ ```
187
+
188
+ Only `compactRegion` (to capture the span) and `summarize` (to append) differ:
189
+ trigger policy, retention, the transaction bracket, token metering, and
190
+ prefix-cache-aligned replay all stay on the shipped engine. The appendices are
191
+ added to the returned summary rather than injected into the summarization
192
+ instruction, so the shipped output contract is untouched and no model has to
193
+ follow an amended format. Failing to build them returns the superseded summary
194
+ unchanged, and restoring the shipped `name` row disables them entirely.
195
+
196
+ `TRANSCRIPT` and per-entry clipping are tunable from
197
+ `dsh-context-mode/transcript`: `USER_CLIP_AT` / `USER_CLIP_KEEP` for long user
198
+ turns, `ASSISTANT_KEEP` for assistant head+tail, and `MAX_TRANSCRIPT_CHARS` for
199
+ the whole body.
200
+
127
201
  ## Development
128
202
 
129
203
  ```sh
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Compaction backend that appends an archive index to every checkpoint.
3
+ *
4
+ * DSH's shipped backend condenses an older span of the conversation into a
5
+ * summary and lets the raw events fall out of the derived history. The summary
6
+ * is lossy by design: its instruction fixes the sections a checkpoint may
7
+ * carry, and tool output has no section of its own, so a long tool result
8
+ * survives only as whatever the summarizing model chose to keep.
9
+ *
10
+ * This plugin's `precompact` listener files the same span into the
11
+ * context-mode knowledge base first, so the detail still exists and
12
+ * `ctx_search` can reach it. What it cannot do from outside is tell the model
13
+ * that — the checkpoint text is written here, inside the engine, and the
14
+ * shipped engine has no reason to mention a knowledge base it does not know
15
+ * about. That is the gap this subclass closes.
16
+ *
17
+ * Two deliberate choices keep the risk low:
18
+ *
19
+ * - Only `summarize()` is overridden. Trigger policy, retention, the
20
+ * bracket-first transaction, token metering, and KV-cache-aligned replay
21
+ * all stay on the shipped implementation, so this cannot diverge from the
22
+ * behavior the rest of DSH expects.
23
+ * - The index is appended to the *returned* summary, never injected into the
24
+ * summarization instruction. The summarizing model never sees this text,
25
+ * so the shipped output contract ("keep every section, in order") stays
26
+ * intact and no model has to be trusted to follow an amended format.
27
+ *
28
+ * Nothing here is allowed to fail a compaction: if the index cannot be built,
29
+ * the superseded summary is returned unchanged.
30
+ *
31
+ * @module dsh-context-mode/compaction
32
+ */
33
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm';
34
+ import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic';
35
+ /**
36
+ * The shipped summarization input and result types, derived from the base
37
+ * class rather than restated.
38
+ *
39
+ * The package does not re-export them from its root, and deep-importing its
40
+ * private `lib/types/summarizer.js` path would break on any internal move.
41
+ * Deriving from the method signature keeps this module aligned with whatever
42
+ * the installed version declares, and needs no import of its own.
43
+ */
44
+ type SummarizeArgs = Parameters<BasicCompactionEngine['summarize']>;
45
+ type SummarizedResult = Awaited<ReturnType<BasicCompactionEngine['summarize']>>;
46
+ /** Session-event shapes this module reads. */
47
+ interface SessionEventLike {
48
+ readonly type: string;
49
+ readonly seq?: number;
50
+ readonly data?: unknown;
51
+ }
52
+ interface SessionLike {
53
+ readonly id?: string;
54
+ snapshotEvents(): readonly SessionEventLike[];
55
+ /** Current surface node sequence; the compacted span is a slice of it. */
56
+ readonly surface?: {
57
+ readonly nodes: readonly number[];
58
+ };
59
+ /** One event by sequence number, or undefined when absent. */
60
+ eventAt?(seq: number): SessionEventLike | undefined;
61
+ }
62
+ interface AgentLike {
63
+ readonly session?: SessionLike;
64
+ }
65
+ /**
66
+ * A compaction engine that names the session archive in each checkpoint.
67
+ *
68
+ * Constructed by DSH exactly like the shipped engine it extends, so the row
69
+ * that mounts it needs no additional wiring.
70
+ */
71
+ export declare class DshContextModeCompaction extends BasicCompactionEngine {
72
+ #private;
73
+ /** Capture the compacted range for the summarizer, then run the shipped path. */
74
+ compactRegion(...args: Parameters<BasicCompactionEngine['compactRegion']>): ReturnType<BasicCompactionEngine['compactRegion']>;
75
+ /**
76
+ * Summarize the replayed region, then append the transcript and archive index.
77
+ *
78
+ * The shipped call runs first and unmodified, so prefix-cache alignment,
79
+ * token accounting, and the returned `SummaryResult` envelope are unchanged.
80
+ */
81
+ protected summarize(input: SummarizeArgs[0], agent: SummarizeArgs[1], signal?: SummarizeArgs[2]): Promise<SummarizedResult>;
82
+ }
83
+ /**
84
+ * Build the archive-index block for one agent's session.
85
+ *
86
+ * The source labels are derivable without waiting on the archiver: the
87
+ * `precompact` listener files each layer under `session/<id>/<layer>`, and the
88
+ * session id is available here. The index therefore states *where* the detail
89
+ * lives rather than claiming anything about what it contains.
90
+ *
91
+ * @param agent - owner of the session being compacted.
92
+ * @returns the markdown block, or an empty string when no session is reachable.
93
+ */
94
+ export declare function buildArchiveIndex(agent: AgentLike): string;
95
+ /**
96
+ * Build the archive-index block for one archive source root.
97
+ *
98
+ * @param base - archive source root, e.g. `session/<id>`.
99
+ * @returns the markdown block.
100
+ */
101
+ export declare function buildArchiveIndexFromBase(base: string): string;
102
+ /**
103
+ * Append an index block to the text of a summary.
104
+ *
105
+ * Only text blocks are touched. A summary may also carry non-text blocks, and
106
+ * rewriting or dropping those is the shipped engine's business, not this
107
+ * module's; they are copied through untouched.
108
+ *
109
+ * @param summary - the superseded summary blocks.
110
+ * @param index - the block to append.
111
+ * @returns new blocks with the index appended to the trailing text.
112
+ */
113
+ export declare function appendToSummary(summary: readonly ContentBlock[], index: string): ContentBlock[];
114
+ export default DshContextModeCompaction;
115
+ //# sourceMappingURL=compaction.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compaction.d.ts","sourceRoot":"","sources":["../../src/compaction.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AACxD,OAAO,EAAE,qBAAqB,EAAE,MAAM,mCAAmC,CAAA;AAQzE;;;;;;;;GAQG;AACH,KAAK,aAAa,GAAG,UAAU,CAAC,qBAAqB,CAAC,WAAW,CAAC,CAAC,CAAA;AACnE,KAAK,gBAAgB,GAAG,OAAO,CAAC,UAAU,CAAC,qBAAqB,CAAC,WAAW,CAAC,CAAC,CAAC,CAAA;AAQ/E,8CAA8C;AAC9C,UAAU,gBAAgB;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CACxB;AAED,UAAU,WAAW;IACnB,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAA;IACpB,cAAc,IAAI,SAAS,gBAAgB,EAAE,CAAA;IAC7C,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,CAAA;IACxD,8DAA8D;IAC9D,OAAO,CAAC,CAAC,GAAG,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAAA;CACpD;AAED,UAAU,SAAS;IACjB,QAAQ,CAAC,OAAO,CAAC,EAAE,WAAW,CAAA;CAC/B;AAQD;;;;;GAKG;AACH,qBAAa,wBAAyB,SAAQ,qBAAqB;;IAWjE,iFAAiF;IAClE,aAAa,CAC1B,GAAG,IAAI,EAAE,UAAU,CAAC,qBAAqB,CAAC,eAAe,CAAC,CAAC,GAC1D,UAAU,CAAC,qBAAqB,CAAC,eAAe,CAAC,CAAC;IAUrD;;;;;OAKG;cACsB,SAAS,CAChC,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,EACvB,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,EACvB,MAAM,CAAC,EAAE,aAAa,CAAC,CAAC,CAAC,GACxB,OAAO,CAAC,gBAAgB,CAAC;CAe7B;AAqDD;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAG1D;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAiB9D;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,SAAS,YAAY,EAAE,EAChC,KAAK,EAAE,MAAM,GACZ,YAAY,EAAE,CAUhB;AAED,eAAe,wBAAwB,CAAA"}
@@ -0,0 +1,169 @@
1
+ import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic';
2
+ import { MAX_TRANSCRIPT_CHARS, buildTranscript, renderTranscript, } from './transcript.js';
3
+ /** Heading of the appended section. Kept short; it costs context on every later request. */
4
+ const INDEX_HEADING = '## Archive Index';
5
+ /** Upper bound on the appended section, so a checkpoint can never be grown without limit. */
6
+ const MAX_INDEX_CHARS = 1_200;
7
+ /**
8
+ * A compaction engine that names the session archive in each checkpoint.
9
+ *
10
+ * Constructed by DSH exactly like the shipped engine it extends, so the row
11
+ * that mounts it needs no additional wiring.
12
+ */
13
+ export class DshContextModeCompaction extends BasicCompactionEngine {
14
+ /**
15
+ * Surface range of the compaction in flight.
16
+ *
17
+ * `summarize()` receives only the replayed messages, not the sequence
18
+ * numbers behind them, so the range is captured here — at the one seam that
19
+ * knows it — and read back inside `summarize()`. Only two numbers are
20
+ * stashed; the shipped transaction, selection, and validation are untouched.
21
+ */
22
+ #range;
23
+ /** Capture the compacted range for the summarizer, then run the shipped path. */
24
+ async compactRegion(...args) {
25
+ this.#range = { start: args[0], end: args[1] };
26
+ try {
27
+ return await super.compactRegion(...args);
28
+ }
29
+ finally {
30
+ // Cleared unconditionally: a stale range must never label a later summary.
31
+ this.#range = undefined;
32
+ }
33
+ }
34
+ /**
35
+ * Summarize the replayed region, then append the transcript and archive index.
36
+ *
37
+ * The shipped call runs first and unmodified, so prefix-cache alignment,
38
+ * token accounting, and the returned `SummaryResult` envelope are unchanged.
39
+ */
40
+ async summarize(input, agent, signal) {
41
+ const result = await super.summarize(input, agent, signal);
42
+ try {
43
+ const session = agent.session;
44
+ const base = archiveBase(session);
45
+ if (base === undefined)
46
+ return result;
47
+ const appended = buildAppendices(session, this.#range, base);
48
+ if (appended.length === 0)
49
+ return result;
50
+ return { ...result, summary: appendToSummary(result.summary, appended.join('\n\n')) };
51
+ }
52
+ catch {
53
+ // An appendix is an improvement, never a requirement: a failure here must
54
+ // not turn into a failed compaction.
55
+ return result;
56
+ }
57
+ }
58
+ }
59
+ /** Archive source root for one session, or undefined when it cannot be named. */
60
+ function archiveBase(session) {
61
+ const id = session?.id;
62
+ if (typeof id !== 'string' || id.length === 0)
63
+ return undefined;
64
+ return `session/${id}`;
65
+ }
66
+ /**
67
+ * Build the transcript and index blocks appended below the summary.
68
+ *
69
+ * The compacted events are resolved from the captured range against the
70
+ * session's own surface, so a range that no longer matches yields no
71
+ * transcript rather than a mislabelled one.
72
+ */
73
+ function buildAppendices(session, range, base) {
74
+ const blocks = [];
75
+ const events = compactedEvents(session, range);
76
+ if (events.length > 0) {
77
+ const lines = buildTranscript(events, { base });
78
+ const body = renderTranscript(lines, MAX_TRANSCRIPT_CHARS);
79
+ if (body.length > 0)
80
+ blocks.push(`## Conversation Transcript\n\n${body}`);
81
+ }
82
+ const index = buildArchiveIndexFromBase(base);
83
+ if (index.length > 0)
84
+ blocks.push(index);
85
+ return blocks;
86
+ }
87
+ /** Resolve the compacted events for a captured surface range. */
88
+ function compactedEvents(session, range) {
89
+ if (session === undefined)
90
+ return [];
91
+ const eventAt = session.eventAt;
92
+ const nodes = session.surface?.nodes;
93
+ // Without a range or a live surface, fall back to nothing rather than
94
+ // guessing: a transcript of the wrong span is worse than no transcript.
95
+ if (range === undefined || eventAt === undefined || nodes === undefined)
96
+ return [];
97
+ const out = [];
98
+ for (const seq of nodes) {
99
+ if (seq < range.start || seq > range.end)
100
+ continue;
101
+ const event = eventAt.call(session, seq);
102
+ if (event !== undefined)
103
+ out.push(event);
104
+ }
105
+ return out;
106
+ }
107
+ /**
108
+ * Build the archive-index block for one agent's session.
109
+ *
110
+ * The source labels are derivable without waiting on the archiver: the
111
+ * `precompact` listener files each layer under `session/<id>/<layer>`, and the
112
+ * session id is available here. The index therefore states *where* the detail
113
+ * lives rather than claiming anything about what it contains.
114
+ *
115
+ * @param agent - owner of the session being compacted.
116
+ * @returns the markdown block, or an empty string when no session is reachable.
117
+ */
118
+ export function buildArchiveIndex(agent) {
119
+ const base = archiveBase(agent.session);
120
+ return base === undefined ? '' : buildArchiveIndexFromBase(base);
121
+ }
122
+ /**
123
+ * Build the archive-index block for one archive source root.
124
+ *
125
+ * @param base - archive source root, e.g. `session/<id>`.
126
+ * @returns the markdown block.
127
+ */
128
+ export function buildArchiveIndexFromBase(base) {
129
+ const lines = [
130
+ INDEX_HEADING,
131
+ '',
132
+ 'The raw transcript of this span was archived to the context-mode knowledge',
133
+ 'base before it was condensed, so detail the summary above omits is still',
134
+ 'retrievable with `ctx_search`. Scope each query to one layer by `source`:',
135
+ '',
136
+ `- \`source: "${base}/constraint"\` — user messages: requirements, decisions, limits`,
137
+ `- \`source: "${base}/finding"\` — tool results and stated conclusions`,
138
+ `- \`source: "${base}/narrative"\` — assistant reasoning and plans`,
139
+ '',
140
+ 'Search for a concrete token you expect in the original (a command, an error',
141
+ 'string, a path, an identifier) rather than a paraphrase of the question.',
142
+ ];
143
+ const block = lines.join('\n');
144
+ return block.length <= MAX_INDEX_CHARS ? block : `${block.slice(0, MAX_INDEX_CHARS - 1)}…`;
145
+ }
146
+ /**
147
+ * Append an index block to the text of a summary.
148
+ *
149
+ * Only text blocks are touched. A summary may also carry non-text blocks, and
150
+ * rewriting or dropping those is the shipped engine's business, not this
151
+ * module's; they are copied through untouched.
152
+ *
153
+ * @param summary - the superseded summary blocks.
154
+ * @param index - the block to append.
155
+ * @returns new blocks with the index appended to the trailing text.
156
+ */
157
+ export function appendToSummary(summary, index) {
158
+ const blocks = summary.map(block => ({ ...block }));
159
+ for (let position = blocks.length - 1; position >= 0; position -= 1) {
160
+ const block = blocks[position];
161
+ if (block.type !== 'text' || typeof block.text !== 'string')
162
+ continue;
163
+ blocks[position] = { ...block, text: `${block.text}\n\n${index}` };
164
+ return blocks;
165
+ }
166
+ // A summary with no text block at all: add one rather than dropping the index.
167
+ return [...blocks, { type: 'text', text: index }];
168
+ }
169
+ export default DshContextModeCompaction;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Conversation transcript construction for compaction checkpoints.
3
+ *
4
+ * DSH's shipped checkpoint is a lossy summary: its instruction fixes eight
5
+ * sections, tool output has no section of its own, and the summarizer keeps
6
+ * whatever prose it chose. In practice a compacted span loses most of what was
7
+ * actually said.
8
+ *
9
+ * This module rebuilds the *conversation* from the compacted events instead of
10
+ * asking a model to retell it:
11
+ *
12
+ * user/message kept whole — a requirement is a requirement. A very long
13
+ * one is clipped head+tail with an archive pointer.
14
+ * assistant/message head and tail only; the middle is archived and reachable.
15
+ * tool/result never copied. One index line names the tool, its size,
16
+ * and the archive source that holds the output.
17
+ *
18
+ * Every clipped or dropped region leaves a pointer naming the `source` to
19
+ * search and the `seq` it came from, so the checkpoint says *which* event lost
20
+ * detail rather than merely that an archive exists.
21
+ *
22
+ * A prior checkpoint is never transcribed: its own text is already in the
23
+ * summary, and copying it forward would make the transcript grow without bound
24
+ * on every compaction.
25
+ *
26
+ * @module dsh-context-mode/transcript
27
+ */
28
+ /** Event shapes this module reads. Mirrors the session log. */
29
+ export interface TranscriptEventLike {
30
+ readonly type: string;
31
+ readonly seq?: number;
32
+ readonly data?: unknown;
33
+ }
34
+ /** Head and tail kept for a user message above the clip threshold, in characters. */
35
+ export declare const USER_CLIP_AT = 1000;
36
+ export declare const USER_CLIP_KEEP = 500;
37
+ /** Head and tail kept for every assistant message, in characters. */
38
+ export declare const ASSISTANT_KEEP = 200;
39
+ /** Upper bound on the whole transcript, so one checkpoint cannot grow without limit. */
40
+ export declare const MAX_TRANSCRIPT_CHARS = 400000;
41
+ /** One rendered transcript entry. */
42
+ export interface TranscriptLine {
43
+ readonly kind: 'user' | 'assistant' | 'tool';
44
+ readonly seq: number;
45
+ readonly text: string;
46
+ /** Characters in the source event before any clipping. */
47
+ readonly sourceChars: number;
48
+ }
49
+ /** Options controlling how much of each event survives into the transcript. */
50
+ export interface TranscriptOptions {
51
+ /** Archive source root, e.g. `session/<id>`. */
52
+ readonly base: string;
53
+ /** Include assistant head/tail entries. Defaults to true. */
54
+ readonly keepAssistant?: boolean;
55
+ }
56
+ /**
57
+ * Whether an event is a checkpoint written by a previous compaction.
58
+ *
59
+ * Matched on `source` when the event still carries it, and on the checkpoint
60
+ * preamble otherwise: a compacted replacement message is replayed back through
61
+ * this module as an ordinary `user/message`, so the structural marker is not
62
+ * always present by the time the text is read.
63
+ */
64
+ export declare function isCheckpointEvent(event: TranscriptEventLike): boolean;
65
+ /**
66
+ * Build the transcript for a compacted span.
67
+ *
68
+ * @param events - the compacted events, in session order.
69
+ * @param options - archive root and assistant policy.
70
+ * @returns the rendered lines, oldest first.
71
+ */
72
+ export declare function buildTranscript(events: readonly TranscriptEventLike[], options: TranscriptOptions): TranscriptLine[];
73
+ /**
74
+ * The archive pointer left where content was clipped.
75
+ *
76
+ * @param total - characters in the source event.
77
+ * @param dropped - characters not carried into the transcript.
78
+ * @param base - archive source root.
79
+ * @param layer - archive layer holding the original.
80
+ * @param seq - the source event's sequence number.
81
+ * @returns the model-facing notice.
82
+ */
83
+ export declare function clipNotice(total: string, dropped: string, base: string, layer: string, seq: number): string;
84
+ /**
85
+ * Render transcript lines into one text body, stopping at the size bound.
86
+ *
87
+ * The bound is enforced from the end: the newest entries matter most to a
88
+ * resuming model, so an over-long transcript keeps its tail and reports how
89
+ * many older entries were dropped.
90
+ *
91
+ * @param lines - transcript lines in session order.
92
+ * @param maxChars - upper bound on the rendered body.
93
+ * @returns the rendered body.
94
+ */
95
+ export declare function renderTranscript(lines: readonly TranscriptLine[], maxChars: number): string;
96
+ //# sourceMappingURL=transcript.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transcript.d.ts","sourceRoot":"","sources":["../../src/transcript.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,+DAA+D;AAC/D,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CACxB;AAMD,qFAAqF;AACrF,eAAO,MAAM,YAAY,OAAQ,CAAA;AACjC,eAAO,MAAM,cAAc,MAAM,CAAA;AAEjC,qEAAqE;AACrE,eAAO,MAAM,cAAc,MAAM,CAAA;AAKjC,wFAAwF;AACxF,eAAO,MAAM,oBAAoB,SAAU,CAAA;AAK3C,qCAAqC;AACrC,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,MAAM,CAAA;IAC5C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,0DAA0D;IAC1D,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAC7B;AAED,+EAA+E;AAC/E,MAAM,WAAW,iBAAiB;IAChC,gDAAgD;IAChD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,6DAA6D;IAC7D,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAA;CACjC;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,mBAAmB,GAAG,OAAO,CAIrE;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,SAAS,mBAAmB,EAAE,EACtC,OAAO,EAAE,iBAAiB,GACzB,cAAc,EAAE,CAQlB;AAqID;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CACxB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,GACV,MAAM,CAKR;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,cAAc,EAAE,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAa3F"}
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Conversation transcript construction for compaction checkpoints.
3
+ *
4
+ * DSH's shipped checkpoint is a lossy summary: its instruction fixes eight
5
+ * sections, tool output has no section of its own, and the summarizer keeps
6
+ * whatever prose it chose. In practice a compacted span loses most of what was
7
+ * actually said.
8
+ *
9
+ * This module rebuilds the *conversation* from the compacted events instead of
10
+ * asking a model to retell it:
11
+ *
12
+ * user/message kept whole — a requirement is a requirement. A very long
13
+ * one is clipped head+tail with an archive pointer.
14
+ * assistant/message head and tail only; the middle is archived and reachable.
15
+ * tool/result never copied. One index line names the tool, its size,
16
+ * and the archive source that holds the output.
17
+ *
18
+ * Every clipped or dropped region leaves a pointer naming the `source` to
19
+ * search and the `seq` it came from, so the checkpoint says *which* event lost
20
+ * detail rather than merely that an archive exists.
21
+ *
22
+ * A prior checkpoint is never transcribed: its own text is already in the
23
+ * summary, and copying it forward would make the transcript grow without bound
24
+ * on every compaction.
25
+ *
26
+ * @module dsh-context-mode/transcript
27
+ */
28
+ /** Layer names used in the archive source label. Mirrors `precompact`'s LAYERS. */
29
+ const LAYER_CONSTRAINT = 'constraint';
30
+ const LAYER_FINDING = 'finding';
31
+ /** Head and tail kept for a user message above the clip threshold, in characters. */
32
+ export const USER_CLIP_AT = 1_000;
33
+ export const USER_CLIP_KEEP = 500;
34
+ /** Head and tail kept for every assistant message, in characters. */
35
+ export const ASSISTANT_KEEP = 200;
36
+ /** Assistant messages shorter than this are kept whole rather than split. */
37
+ const ASSISTANT_MIN_SPLIT = ASSISTANT_KEEP * 2;
38
+ /** Upper bound on the whole transcript, so one checkpoint cannot grow without limit. */
39
+ export const MAX_TRANSCRIPT_CHARS = 400_000;
40
+ /** The tag the shipped engine uses; its presence marks a prior checkpoint. */
41
+ const CHECKPOINT_MARKER = 'This is an automatically generated checkpoint';
42
+ /**
43
+ * Whether an event is a checkpoint written by a previous compaction.
44
+ *
45
+ * Matched on `source` when the event still carries it, and on the checkpoint
46
+ * preamble otherwise: a compacted replacement message is replayed back through
47
+ * this module as an ordinary `user/message`, so the structural marker is not
48
+ * always present by the time the text is read.
49
+ */
50
+ export function isCheckpointEvent(event) {
51
+ const source = sourceOf(event);
52
+ if (source !== undefined && source.kind === 'plugin' && source.plugin === 'compact')
53
+ return true;
54
+ return messageText(event).includes(CHECKPOINT_MARKER);
55
+ }
56
+ /**
57
+ * Build the transcript for a compacted span.
58
+ *
59
+ * @param events - the compacted events, in session order.
60
+ * @param options - archive root and assistant policy.
61
+ * @returns the rendered lines, oldest first.
62
+ */
63
+ export function buildTranscript(events, options) {
64
+ const names = toolNames(events);
65
+ const lines = [];
66
+ for (const event of events) {
67
+ const line = transcriptLine(event, options, names);
68
+ if (line !== undefined)
69
+ lines.push(line);
70
+ }
71
+ return lines;
72
+ }
73
+ /**
74
+ * Map each tool-call id to its tool name.
75
+ *
76
+ * A tool call is not a surface event of its own: it is a `tool-call` content
77
+ * block inside an `assistant/message`, which is why the compacted span holds
78
+ * no `tool/call` events at all. The result answers it by id, so the pairing is
79
+ * collected from assistant blocks up front and looked up while rendering.
80
+ */
81
+ function toolNames(events) {
82
+ const names = new Map();
83
+ for (const event of events) {
84
+ if (event.type !== 'assistant/message')
85
+ continue;
86
+ const data = asRecord(event.data);
87
+ const message = asRecord(data?.message) ?? data;
88
+ const content = message?.content;
89
+ if (!Array.isArray(content))
90
+ continue;
91
+ for (const block of content) {
92
+ if (block === null || typeof block !== 'object')
93
+ continue;
94
+ const record = block;
95
+ if (record.type !== 'tool-call')
96
+ continue;
97
+ // An assistant tool-call block carries `id`; its paired result carries
98
+ // the same value as `toolCallId`, so both spellings are accepted.
99
+ const id = typeof record.id === 'string'
100
+ ? record.id
101
+ : typeof record.toolCallId === 'string' ? record.toolCallId : undefined;
102
+ if (id === undefined)
103
+ continue;
104
+ const name = typeof record.name === 'string' ? record.name : undefined;
105
+ if (name !== undefined && name.length > 0)
106
+ names.set(id, name);
107
+ }
108
+ }
109
+ return names;
110
+ }
111
+ /** Render one event into a transcript line, or undefined when it contributes nothing. */
112
+ function transcriptLine(event, options, names) {
113
+ const seq = typeof event.seq === 'number' ? event.seq : -1;
114
+ if (event.type === 'tool/result')
115
+ return toolLine(event, seq, options, names);
116
+ // A prior checkpoint carries the previous summary; transcribing it would
117
+ // duplicate the current summary and grow without bound across compactions.
118
+ if (event.type === 'user/message') {
119
+ if (isCheckpointEvent(event))
120
+ return undefined;
121
+ if (isInjected(event))
122
+ return undefined;
123
+ return userLine(event, seq, options);
124
+ }
125
+ if (event.type === 'assistant/message') {
126
+ if (options.keepAssistant === false)
127
+ return undefined;
128
+ return assistantLine(event, seq, options);
129
+ }
130
+ return undefined;
131
+ }
132
+ /** A user message is kept whole unless it is long, then clipped head and tail. */
133
+ function userLine(event, seq, options) {
134
+ const text = messageText(event).replace(/\s+/g, ' ').trim();
135
+ if (text.length === 0)
136
+ return undefined;
137
+ const source = `session/<id>/${LAYER_CONSTRAINT}`;
138
+ if (text.length <= USER_CLIP_AT) {
139
+ return { kind: 'user', seq, text: `## [user ${seq}]\n${text}`, sourceChars: text.length };
140
+ }
141
+ const head = text.slice(0, USER_CLIP_KEEP);
142
+ const tail = text.slice(-USER_CLIP_KEEP);
143
+ const dropped = text.length - head.length - tail.length;
144
+ return {
145
+ kind: 'user',
146
+ seq,
147
+ sourceChars: text.length,
148
+ text: [
149
+ `## [user ${seq}]`,
150
+ head,
151
+ clipNotice(`${text.length}`, `${dropped}`, options.base, LAYER_CONSTRAINT, seq),
152
+ tail,
153
+ ].join('\n'),
154
+ };
155
+ }
156
+ /** An assistant message keeps its head and tail; the middle stays in the archive. */
157
+ function assistantLine(event, seq, options) {
158
+ const text = messageText(event).replace(/\s+/g, ' ').trim();
159
+ if (text.length === 0)
160
+ return undefined;
161
+ if (text.length <= ASSISTANT_MIN_SPLIT) {
162
+ return { kind: 'assistant', seq, text: `## [assistant ${seq}]\n${text}`, sourceChars: text.length };
163
+ }
164
+ const head = text.slice(0, ASSISTANT_KEEP);
165
+ const tail = text.slice(-ASSISTANT_KEEP);
166
+ const dropped = text.length - head.length - tail.length;
167
+ return {
168
+ kind: 'assistant',
169
+ seq,
170
+ sourceChars: text.length,
171
+ text: [
172
+ `## [assistant ${seq}]`,
173
+ head,
174
+ clipNotice(`${text.length}`, `${dropped}`, options.base, LAYER_FINDING, seq),
175
+ tail,
176
+ ].join('\n'),
177
+ };
178
+ }
179
+ /** Tool output is never copied: one line names the call and where its output lives. */
180
+ function toolLine(event, seq, options, names) {
181
+ const data = asRecord(event.data);
182
+ const message = asRecord(data?.message) ?? data;
183
+ const callId = firstCallId(message?.content);
184
+ const name = toolNameFor(data, callId, names);
185
+ const chars = messageText(event).length;
186
+ return {
187
+ kind: 'tool',
188
+ seq,
189
+ sourceChars: chars,
190
+ text: `## [tool ${name} seq ${seq}, ${chars} chars \u2192 search \`${options.base}/${LAYER_FINDING}\`]`,
191
+ };
192
+ }
193
+ /**
194
+ * The archive pointer left where content was clipped.
195
+ *
196
+ * @param total - characters in the source event.
197
+ * @param dropped - characters not carried into the transcript.
198
+ * @param base - archive source root.
199
+ * @param layer - archive layer holding the original.
200
+ * @param seq - the source event's sequence number.
201
+ * @returns the model-facing notice.
202
+ */
203
+ export function clipNotice(total, dropped, base, layer, seq) {
204
+ return (`[... ${dropped} of ${total} chars elided from seq ${seq}; ` +
205
+ `retrieve with ctx_search(source: "${base}/${layer}") ...]`);
206
+ }
207
+ /**
208
+ * Render transcript lines into one text body, stopping at the size bound.
209
+ *
210
+ * The bound is enforced from the end: the newest entries matter most to a
211
+ * resuming model, so an over-long transcript keeps its tail and reports how
212
+ * many older entries were dropped.
213
+ *
214
+ * @param lines - transcript lines in session order.
215
+ * @param maxChars - upper bound on the rendered body.
216
+ * @returns the rendered body.
217
+ */
218
+ export function renderTranscript(lines, maxChars) {
219
+ const rendered = lines.map(line => line.text);
220
+ let total = rendered.reduce((sum, text) => sum + text.length + 2, 0);
221
+ if (total <= maxChars)
222
+ return rendered.join('\n\n');
223
+ // Drop from the oldest until the body fits, then say how many were dropped.
224
+ let first = 0;
225
+ while (first < rendered.length && total > maxChars) {
226
+ total -= rendered[first].length + 2;
227
+ first += 1;
228
+ }
229
+ const droppedCount = first;
230
+ const notice = `[... ${droppedCount} older transcript entries elided; the archived span is searchable via the archive index below ...]`;
231
+ return [notice, ...rendered.slice(first)].join('\n\n');
232
+ }
233
+ /** Text of a message-shaped event, reading nested tool-result content too. */
234
+ function messageText(event) {
235
+ const data = asRecord(event.data);
236
+ if (data === undefined)
237
+ return '';
238
+ if (event.type === 'tool/call') {
239
+ const name = typeof data.name === 'string' ? data.name : '';
240
+ const args = typeof data.arguments === 'string' ? data.arguments : '';
241
+ return name.length === 0 ? '' : `${name} ${args}`.trim();
242
+ }
243
+ const message = asRecord(data.message) ?? data;
244
+ return blocksToText(message?.content);
245
+ }
246
+ /** Flatten a content value (string, block array, or tool-result envelope) to text. */
247
+ function blocksToText(content) {
248
+ if (typeof content === 'string')
249
+ return content;
250
+ if (!Array.isArray(content))
251
+ return '';
252
+ const parts = [];
253
+ for (const block of content) {
254
+ if (block === null || typeof block !== 'object')
255
+ continue;
256
+ const record = block;
257
+ if (record.type === 'text' && typeof record.text === 'string') {
258
+ parts.push(record.text);
259
+ continue;
260
+ }
261
+ // tool/result wraps its payload one level down.
262
+ if (Array.isArray(record.content)) {
263
+ const inner = blocksToText(record.content);
264
+ if (inner.length > 0)
265
+ parts.push(inner);
266
+ }
267
+ }
268
+ return parts.join('\n');
269
+ }
270
+ /** First tool-call id found in a tool-result content value. */
271
+ function firstCallId(content) {
272
+ if (!Array.isArray(content))
273
+ return undefined;
274
+ for (const block of content) {
275
+ if (block === null || typeof block !== 'object')
276
+ continue;
277
+ const record = block;
278
+ if (typeof record.toolCallId === 'string')
279
+ return record.toolCallId;
280
+ }
281
+ return undefined;
282
+ }
283
+ /** The tool name a result belongs to, resolved from its paired call. */
284
+ function toolNameFor(data, callId, names) {
285
+ if (data !== undefined && typeof data.name === 'string' && data.name.length > 0)
286
+ return data.name;
287
+ if (callId === undefined)
288
+ return 'tool';
289
+ const paired = names.get(callId);
290
+ if (paired !== undefined)
291
+ return paired;
292
+ // The call may sit outside the compacted span; name the result by its leading
293
+ // id segment rather than silently labelling every such line "tool".
294
+ const head = callId.split('|')[0];
295
+ return head.length > 0 ? head : 'tool';
296
+ }
297
+ /** Whether a user message is harness-injected context rather than a real turn. */
298
+ function isInjected(event) {
299
+ const text = messageText(event).trimStart();
300
+ return (text.startsWith('<current_runtime_context') ||
301
+ text.startsWith('<active_memory') ||
302
+ text.startsWith('<system-reminder') ||
303
+ text.startsWith('<resume_snapshot') ||
304
+ text.startsWith('Current runtime context') ||
305
+ text.startsWith('The available skill catalog changed'));
306
+ }
307
+ /** The `source` record of a message-shaped event, when present. */
308
+ function sourceOf(event) {
309
+ const data = asRecord(event.data);
310
+ if (data === undefined)
311
+ return undefined;
312
+ const message = asRecord(data.message);
313
+ const source = asRecord(message?.source) ?? asRecord(data.source);
314
+ if (source === undefined)
315
+ return undefined;
316
+ return {
317
+ kind: typeof source.kind === 'string' ? source.kind : undefined,
318
+ plugin: typeof source.plugin === 'string' ? source.plugin : undefined,
319
+ };
320
+ }
321
+ function asRecord(value) {
322
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
323
+ ? value
324
+ : undefined;
325
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-context-mode",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Expose context-mode MCP tools as native DeepSeek Harness tools",
5
5
  "keywords": [
6
6
  "dsh",
@@ -24,6 +24,14 @@
24
24
  "types": "./lib/types/index.d.ts",
25
25
  "import": "./lib/types/index.js"
26
26
  },
27
+ "./compaction": {
28
+ "types": "./lib/types/compaction.d.ts",
29
+ "import": "./lib/types/compaction.js"
30
+ },
31
+ "./transcript": {
32
+ "types": "./lib/types/transcript.d.ts",
33
+ "import": "./lib/types/transcript.js"
34
+ },
27
35
  "./package.json": "./package.json",
28
36
  "./cordis.patch.yml": "./cordis.patch.yml"
29
37
  },
@@ -59,16 +67,24 @@
59
67
  },
60
68
  "peerDependencies": {
61
69
  "@deepseek-ai/cordis": "^4.0.2",
70
+ "@deepseek-ai/dsh-compaction-basic": "^0.1.5-rc.2",
62
71
  "@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
63
72
  "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.2",
64
73
  "@deepseek-ai/dsh-tools": "^0.1.5-rc.2"
65
74
  },
75
+ "peerDependenciesMeta": {
76
+ "@deepseek-ai/dsh-compaction-basic": {
77
+ "optional": true
78
+ }
79
+ },
66
80
  "devDependencies": {
67
81
  "@deepseek-ai/cordis": "^4.0.2",
82
+ "@deepseek-ai/dsh-agent": "0.1.5-rc.2",
83
+ "@deepseek-ai/dsh-compaction-basic": "0.1.5-rc.2",
68
84
  "@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
85
+ "@deepseek-ai/dsh-skill": "^0.1.5-rc.2",
69
86
  "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.2",
70
87
  "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
71
- "@deepseek-ai/dsh-skill": "^0.1.5-rc.2",
72
88
  "@types/node": "^22.0.0",
73
89
  "typescript": "^6.0.3"
74
90
  }