@logictan/dsh-verbatim-compaction 0.3.0 → 0.3.1

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/lib/index.js CHANGED
@@ -153,9 +153,9 @@ export function isErrorResult(event) {
153
153
  /** Directive appended as the final user message of the extraction call. */
154
154
  const EXTRACT_INSTRUCTION = [
155
155
  'You are summarizing a batch of turns from a coding session for a compaction checkpoint.',
156
- 'Each turn is introduced by a line `=== TURN <n> ===` and contains the user request, the assistant prose (may be empty), and a trace of the tool calls it made (descriptions only) plus any results the log marked as errors.',
156
+ 'Each item is introduced by a line `=== SLOT <key> (turn <n>) ===` and contains the user request, the assistant prose (may be empty), and a trace of the tool calls it made (descriptions only) plus any results the log marked as errors.',
157
157
  '',
158
- 'For EVERY turn, emit exactly one block, introduced by the same `=== TURN <n> ===` line, containing these three Markdown headings in order. Under each, at most two terse lines. If a section has no real information, write "(none)" — never invent.',
158
+ 'For EVERY item, emit exactly one block, introduced by `=== SLOT <key> ===` using that same key, containing these three Markdown headings in order. Under each, at most two terse lines. If a section has no real information, write "(none)" — never invent.',
159
159
  '',
160
160
  '## Did',
161
161
  '- what was actually done, concretely',
@@ -362,13 +362,26 @@ export function buildSections(events) {
362
362
  * @returns the same sections with `extracted` set where available.
363
363
  */
364
364
  function withExtractions(sections, extracted) {
365
- return sections.map((section) => ({
366
- turns: section.turns.map((turn) => {
367
- const block = extracted.get(turn.turn);
365
+ return sections.map((section, sectionIndex) => ({
366
+ turns: section.turns.map((turn, turnIndex) => {
367
+ const block = extracted.get(turnKey(sectionIndex, turnIndex));
368
368
  return block === undefined ? turn : { ...turn, extracted: block };
369
369
  }),
370
370
  }));
371
371
  }
372
+ /**
373
+ * Position-based key for one turn: `<section index>:<turn index>`.
374
+ *
375
+ * Not the turn number. `turn/start` is emitted per step in some sessions and only
376
+ * once in others, so the number in the log is neither unique nor one-per-request.
377
+ * A real session carried 2 user requests under a single `turn/start` (turn 1), so
378
+ * both sections were keyed `1`; the second extraction overwrote the first, and
379
+ * the digest printed the same block twice — once under a request about source
380
+ * refresh, once under 「模拟器使用硬件加速!」.
381
+ */
382
+ function turnKey(sectionIndex, turnIndex) {
383
+ return `${sectionIndex}:${turnIndex}`;
384
+ }
372
385
  /**
373
386
  * Render one section to digest text, truncating assistant prose to `capChars`.
374
387
  *
@@ -653,17 +666,20 @@ async function extractBatch(ctx, agent, batch, signal) {
653
666
  const model = route?.model ?? agent.options.model;
654
667
  if (provider === undefined || model === undefined)
655
668
  return out;
669
+ // The label the model echoes back is the slot key, not the turn number: two
670
+ // requests can share a number, and a duplicate label would collapse two blocks
671
+ // into one entry.
656
672
  const parts = [];
657
- for (const [turn, events] of batch) {
658
- const prose = events
673
+ for (const slot of batch) {
674
+ const prose = slot.events
659
675
  .filter((event) => event.type === 'assistant/message')
660
676
  .map((event) => textOf(event))
661
677
  .filter((text) => text.length > 0)
662
678
  .join('\n');
663
- const material = buildExtractionMaterial(events, prose);
679
+ const material = buildExtractionMaterial(slot.events, prose);
664
680
  if (material.length === 0)
665
681
  continue;
666
- parts.push(`=== TURN ${turn} ===\n${material}`);
682
+ parts.push(`=== SLOT ${slot.key} (turn ${slot.turn ?? '?'}) ===\n${material}`);
667
683
  }
668
684
  if (parts.length === 0)
669
685
  return out;
@@ -675,17 +691,12 @@ async function extractBatch(ctx, agent, batch, signal) {
675
691
  messages: [
676
692
  { role: 'user', content: [{ type: 'text', text: `${parts.join('\n\n')}\n\n${EXTRACT_INSTRUCTION}` }] },
677
693
  ],
678
- // One block per turn, so the cap scales with the batch rather than being
679
- // a per-turn 400 that a dozen turns would multiply past the digest ceiling.
680
694
  maxTokens: EXTRACT_TOKENS_PER_TURN * parts.length,
681
695
  sessionId: agent.session.id,
682
696
  purpose: 'compaction',
683
697
  ...(signal === undefined ? {} : { signal }),
684
698
  });
685
699
  for await (const chunk of stream) {
686
- // Chunk kinds come from `@deepseek-ai/dsh-llm`'s BlockAssembler: text
687
- // arrives as `text-delta`, and `reasoning-delta` is deliberately ignored —
688
- // reasoning is 98.8% of a long session's output and is not the record.
689
700
  const delta = chunk;
690
701
  if (delta.type === 'text-delta' && typeof delta.text === 'string')
691
702
  chunks.push(delta.text);
@@ -694,17 +705,15 @@ async function extractBatch(ctx, agent, batch, signal) {
694
705
  catch {
695
706
  return out;
696
707
  }
697
- // Split the answer back per turn. A turn the model skipped simply keeps its
698
- // prose, so a partial answer degrades per turn instead of losing the batch.
699
708
  const answer = chunks.join('').trim();
700
709
  if (answer.length === 0)
701
710
  return out;
702
- const sections = answer.split(/^=== TURN (\d+) ===$/m);
703
- for (let index = 1; index < sections.length; index += 2) {
704
- const turn = Number(sections[index]);
705
- const body = (sections[index + 1] ?? '').trim();
706
- if (Number.isFinite(turn) && body.length > 0)
707
- out.set(turn, body);
711
+ const blocks = answer.split(/^=== SLOT ([^\s(]+)[^=]*===$/m);
712
+ for (let index = 1; index < blocks.length; index += 2) {
713
+ const key = blocks[index];
714
+ const body = (blocks[index + 1] ?? '').trim();
715
+ if (key !== undefined && body.length > 0)
716
+ out.set(key, body);
708
717
  }
709
718
  return out;
710
719
  }
@@ -779,33 +788,47 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
779
788
  * @returns turn number to extracted block, omitting turns that yielded none.
780
789
  */
781
790
  async extractAll(source, agent, signal) {
782
- const turns = new Map();
791
+ // Walk the same sections the renderer will use, so a key addresses the exact
792
+ // slot the block will be written into. Grouping by turn number instead lost
793
+ // blocks whenever the log reused a number.
794
+ const sections = buildSections(source);
795
+ const slots = [];
783
796
  let lastTurn;
784
- for (const event of source) {
785
- if (event.type === 'turn/start') {
786
- const turn = turnOf(event);
787
- if (turn !== undefined)
788
- lastTurn = turn;
789
- continue;
797
+ let cursor = 0;
798
+ for (const [sectionIndex, section] of sections.entries()) {
799
+ for (const [turnIndex, turn] of section.turns.entries()) {
800
+ // Collect this turn's events by walking the source in order; the sections
801
+ // were built from the same array, so the cursor never rewinds.
802
+ const collected = [];
803
+ while (cursor < source.length) {
804
+ const event = source[cursor];
805
+ cursor += 1;
806
+ if (event === undefined)
807
+ break;
808
+ if (event.type === 'turn/start') {
809
+ const turnNumber = turnOf(event);
810
+ if (turnNumber !== undefined)
811
+ lastTurn = turnNumber;
812
+ collected.push(event);
813
+ continue;
814
+ }
815
+ collected.push(event);
816
+ if (event.type === 'user/message' || event.type === 'assistant/message') {
817
+ if (!isInjection(event) && !isCheckpoint(event))
818
+ break;
819
+ }
820
+ }
821
+ slots.push({ key: turnKey(sectionIndex, turnIndex), turn: turn.turn, events: collected });
790
822
  }
791
- const turn = turnOf(event) ?? lastTurn;
792
- if (turn === undefined)
793
- continue;
794
- const bucket = turns.get(turn);
795
- if (bucket === undefined)
796
- turns.set(turn, [event]);
797
- else
798
- bucket.push(event);
799
823
  }
800
- const ordered = [...turns.entries()].sort((left, right) => left[0] - right[0]);
801
824
  const extracted = new Map();
802
- for (let index = 0; index < ordered.length; index += TURNS_PER_CALL) {
825
+ for (let index = 0; index < slots.length; index += TURNS_PER_CALL) {
803
826
  if (signal?.aborted === true)
804
827
  break;
805
- const batch = ordered.slice(index, index + TURNS_PER_CALL);
828
+ const batch = slots.slice(index, index + TURNS_PER_CALL);
806
829
  const blocks = await extractBatch(this.ctx, agent, batch, signal);
807
- for (const [turn, block] of blocks)
808
- extracted.set(turn, block);
830
+ for (const [key, block] of blocks)
831
+ extracted.set(key, block);
809
832
  }
810
833
  return extracted;
811
834
  }
@@ -156,7 +156,7 @@ export declare function buildSections(events: readonly SessionEvent[]): Section[
156
156
  * ceiling; the tiers exist for the turns where extraction was unavailable and
157
157
  * the prose is all that remains.
158
158
  */
159
- export declare function renderDigest(events: readonly SessionEvent[], budgetTokens: number, extracted?: ReadonlyMap<number | undefined, string>): string;
159
+ export declare function renderDigest(events: readonly SessionEvent[], budgetTokens: number, extracted?: ReadonlyMap<string, string>): string;
160
160
  /**
161
161
  * The surface seqs this compaction shadows, recovered from the summarization
162
162
  * input's message count.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logictan/dsh-verbatim-compaction",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Deterministic compaction backend that keeps conversation prose verbatim in the checkpoint instead of re-summarizing it: user and assistant text survive word for word, tool results collapse to one-line pointers, reasoning is never read, and no model call is made.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",