@vincemakes/kiso-core 0.1.19 → 0.1.20

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/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export * from "./kernel/mode.js";
8
8
  export * from "./kernel/permission.js";
9
9
  export * from "./kernel/loop.js";
10
10
  export * from "./kernel/compaction.js";
11
+ export * from "./kernel/summarize.js";
11
12
  export * from "./kernel/project.js";
12
13
  export * from "./kernel/ledger.js";
13
14
  export * from "./governance/delivery.js";
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ export * from "./kernel/mode.js";
8
8
  export * from "./kernel/permission.js";
9
9
  export * from "./kernel/loop.js";
10
10
  export * from "./kernel/compaction.js";
11
+ export * from "./kernel/summarize.js";
11
12
  export * from "./kernel/project.js";
12
13
  export * from "./kernel/ledger.js";
13
14
  export * from "./governance/delivery.js";
@@ -1,60 +1,26 @@
1
1
  /**
2
- * L2 — compaction primitives.
2
+ * L2 — context-economy primitives, the MECHANICAL half.
3
3
  *
4
4
  * The kernel's compaction policy is identity preservation, not summary
5
- * (mauri ADR-0007): keep the message SHELL (id, role, position), replace the
6
- * content with a marker, zero LLM calls. A summary is a NEW message; it never
7
- * rewrites an old one. Messages are immutable (ADR-0002) — "clearing" is
8
- * append, not mutation.
5
+ * (mauri ADR-0007): keep the message SHELL (id, role, position), replace
6
+ * the content with a marker, zero LLM calls. A summary is a NEW message; it
7
+ * never rewrites an old one. Messages are immutable (ADR-0002) — "clearing"
8
+ * is append, not mutation.
9
9
  *
10
- * The idempotence predicate is the first thing here because it is the FIRST
11
- * kernel function demanded by a fixture: the compaction-regrowth incident
12
- * (uooki 2026, video pipeline) was an O(N²) growth where repeated compaction
13
- * re-archived messages already marked cleared, overwriting their original
14
- * content. One line fixed it: a marked message is never archived again.
10
+ * ADR-0044 merged the classic auto-compaction (`config.compaction` +
11
+ * `compacted` events) INTO the microcompact boundary: the loop no longer
12
+ * produces `compacted` events (the boundary's projection derives the same
13
+ * cleared view deterministically), and this module now holds only what the
14
+ * live path shares. Old sessions' `compacted` events still replay verbatim
15
+ * — see kernel/project.ts.
16
+ *
17
+ * The model-generated half of context economy (the /compact summary layer)
18
+ * lives in kernel/summarize.ts.
15
19
  */
16
- import type { AssistantBlock, Message } from "../protocol/messages.js";
17
- /** Marker prefix for cleared tool results. Must be unambiguous and stable. */
18
- export declare const CLEARED_MARKER_PREFIX = "[content cleared \u2014 reference by revision]";
19
- export declare function isClearedMarker(content: string): boolean;
20
- /** Idempotence gate: a message whose content is already the clear marker is
21
- * never compacted again, never re-archived, never overwritten. */
22
- export declare function shouldClearContent(content: string): boolean;
20
+ import type { Message } from "../protocol/messages.js";
23
21
  /**
24
22
  * Rough token estimate (chars/4 + structural overhead). Calibration-free on
25
- * purpose: compaction only needs a stable MONOTONE proxy, not an exact
23
+ * purpose: context economy only needs a stable MONOTONE proxy, not an exact
26
24
  * count — the threshold absorbs the error (mauri ADR-0007).
27
25
  */
28
26
  export declare function estimateTokens(messages: readonly Message[]): number;
29
- /**
30
- * Microcompact — zero-LLM context relief (ported shape from oohki runner,
31
- * adapted to kiso's Message union; identity preservation, ADR-0007):
32
- *
33
- * 1. Find the boundary: the KEEP_RECENT_TURNS-th user message from the end.
34
- * Recent turns stay fully intact — the model must reason about them.
35
- * 2. Tool results BEFORE the boundary have their content replaced by a stub
36
- * that keeps the name + char count (an information anchor, not a hole).
37
- * 3. Idempotent: already-cleared content is never touched again (the
38
- * compaction-regrowth incident's one-line fix, as a first-class rule).
39
- * 4. Returns a NEW array; messages are immutable (ADR-0002).
40
- */
41
- export declare const KEEP_RECENT_TURNS = 5;
42
- export interface MicrocompactResult {
43
- readonly messages: readonly Message[];
44
- /** How much content (chars) was cleared this pass. */
45
- readonly clearedChars: number;
46
- /**
47
- * ONLY the tool results cleared THIS pass — the delta, never the
48
- * cumulative marker set (五: a replayable trajectory must not record the
49
- * same replacement on every turn). `eventSeq` is the cleared result's
50
- * stable identity (the tool_result event's seq, attached by the
51
- * projection); it is undefined for hand-built messages outside the loop.
52
- */
53
- readonly cleared: readonly {
54
- readonly eventSeq?: number;
55
- readonly callId: string;
56
- readonly content: string;
57
- }[];
58
- }
59
- export declare function microcompact(messages: readonly Message[]): MicrocompactResult;
60
- export type { AssistantBlock };
@@ -1,31 +1,25 @@
1
1
  /**
2
- * L2 — compaction primitives.
2
+ * L2 — context-economy primitives, the MECHANICAL half.
3
3
  *
4
4
  * The kernel's compaction policy is identity preservation, not summary
5
- * (mauri ADR-0007): keep the message SHELL (id, role, position), replace the
6
- * content with a marker, zero LLM calls. A summary is a NEW message; it never
7
- * rewrites an old one. Messages are immutable (ADR-0002) — "clearing" is
8
- * append, not mutation.
5
+ * (mauri ADR-0007): keep the message SHELL (id, role, position), replace
6
+ * the content with a marker, zero LLM calls. A summary is a NEW message; it
7
+ * never rewrites an old one. Messages are immutable (ADR-0002) — "clearing"
8
+ * is append, not mutation.
9
9
  *
10
- * The idempotence predicate is the first thing here because it is the FIRST
11
- * kernel function demanded by a fixture: the compaction-regrowth incident
12
- * (uooki 2026, video pipeline) was an O(N²) growth where repeated compaction
13
- * re-archived messages already marked cleared, overwriting their original
14
- * content. One line fixed it: a marked message is never archived again.
10
+ * ADR-0044 merged the classic auto-compaction (`config.compaction` +
11
+ * `compacted` events) INTO the microcompact boundary: the loop no longer
12
+ * produces `compacted` events (the boundary's projection derives the same
13
+ * cleared view deterministically), and this module now holds only what the
14
+ * live path shares. Old sessions' `compacted` events still replay verbatim
15
+ * — see kernel/project.ts.
16
+ *
17
+ * The model-generated half of context economy (the /compact summary layer)
18
+ * lives in kernel/summarize.ts.
15
19
  */
16
- /** Marker prefix for cleared tool results. Must be unambiguous and stable. */
17
- export const CLEARED_MARKER_PREFIX = "[content cleared — reference by revision]";
18
- export function isClearedMarker(content) {
19
- return content.startsWith(CLEARED_MARKER_PREFIX);
20
- }
21
- /** Idempotence gate: a message whose content is already the clear marker is
22
- * never compacted again, never re-archived, never overwritten. */
23
- export function shouldClearContent(content) {
24
- return !isClearedMarker(content);
25
- }
26
20
  /**
27
21
  * Rough token estimate (chars/4 + structural overhead). Calibration-free on
28
- * purpose: compaction only needs a stable MONOTONE proxy, not an exact
22
+ * purpose: context economy only needs a stable MONOTONE proxy, not an exact
29
23
  * count — the threshold absorbs the error (mauri ADR-0007).
30
24
  */
31
25
  export function estimateTokens(messages) {
@@ -48,70 +42,3 @@ export function estimateTokens(messages) {
48
42
  }
49
43
  return total;
50
44
  }
51
- /**
52
- * Microcompact — zero-LLM context relief (ported shape from oohki runner,
53
- * adapted to kiso's Message union; identity preservation, ADR-0007):
54
- *
55
- * 1. Find the boundary: the KEEP_RECENT_TURNS-th user message from the end.
56
- * Recent turns stay fully intact — the model must reason about them.
57
- * 2. Tool results BEFORE the boundary have their content replaced by a stub
58
- * that keeps the name + char count (an information anchor, not a hole).
59
- * 3. Idempotent: already-cleared content is never touched again (the
60
- * compaction-regrowth incident's one-line fix, as a first-class rule).
61
- * 4. Returns a NEW array; messages are immutable (ADR-0002).
62
- */
63
- export const KEEP_RECENT_TURNS = 5;
64
- export function microcompact(messages) {
65
- const userIndices = [];
66
- for (let i = 0; i < messages.length; i++) {
67
- if (messages[i]?.role === "user")
68
- userIndices.push(i);
69
- }
70
- if (userIndices.length <= KEEP_RECENT_TURNS) {
71
- return { messages, clearedChars: 0, cleared: [] };
72
- }
73
- const recentBoundary = userIndices[userIndices.length - KEEP_RECENT_TURNS];
74
- const nameByCallId = buildToolNameMap(messages);
75
- let clearedChars = 0;
76
- const cleared = [];
77
- let changed = false;
78
- const result = messages.map((msg, i) => {
79
- if (i >= recentBoundary || msg.role !== "tool")
80
- return msg;
81
- if (typeof msg.content !== "string")
82
- return msg; // binary content is untouched
83
- if (!shouldClearContent(msg.content))
84
- return msg; // idempotence gate
85
- const toolName = nameByCallId.get(msg.callId) ?? "unknown";
86
- const eventSeq = msg.eventSeq;
87
- clearedChars += msg.content.length;
88
- cleared.push({
89
- ...(eventSeq !== undefined ? { eventSeq } : {}),
90
- callId: msg.callId,
91
- content: `${CLEARED_MARKER_PREFIX} ${toolName} returned ${msg.content.length.toLocaleString()} chars — compacted`,
92
- });
93
- changed = true;
94
- return {
95
- ...msg,
96
- content: `${CLEARED_MARKER_PREFIX} ${toolName} returned ${msg.content.length.toLocaleString()} chars — compacted`,
97
- };
98
- });
99
- return {
100
- messages: changed ? result : messages,
101
- clearedChars,
102
- cleared,
103
- };
104
- }
105
- /** callId → tool name, from assistant tool_use blocks (for the stub). */
106
- function buildToolNameMap(messages) {
107
- const map = new Map();
108
- for (const msg of messages) {
109
- if (msg.role !== "assistant")
110
- continue;
111
- for (const block of msg.blocks) {
112
- if (block.type === "tool_use")
113
- map.set(block.callId, block.name);
114
- }
115
- }
116
- return map;
117
- }
@@ -39,9 +39,13 @@ export interface HookHost {
39
39
  onPreTool?(call: ToolCallPayload, ctx: HookContext): Promise<PermissionDecision>;
40
40
  /** Execute: rewrite the result a tool returns. */
41
41
  onPostTool?(call: ToolCallPayload, result: ToolResult, ctx: HookContext): Promise<ToolResult>;
42
- /** Lifecycle: compaction is about to replace history. */
42
+ /**
43
+ * DEPRECATED (ADR-0044): the classic auto-compaction path is retired —
44
+ * the loop never fires these (the microcompact boundary replaced it).
45
+ * Kept so the extension contract type-checks; inert. Removed at 1.0.
46
+ */
43
47
  onPreCompact?(messages: readonly Message[], ctx: HookContext): Promise<void>;
44
- /** Lifecycle: compaction finished. */
48
+ /** DEPRECATED (ADR-0044): see onPreCompact — never invoked. */
45
49
  onPostCompact?(messages: readonly Message[], ctx: HookContext): Promise<void>;
46
50
  /** Lifecycle: the loop paused (human decision pending). */
47
51
  onPause?(reason: string, ctx: HookContext): Promise<void>;
@@ -9,8 +9,9 @@
9
9
  * stored alongside it — every adapter call derives them via
10
10
  * `projectMessages(log.all)` (kernel/project.ts). A fresh log encodes the
11
11
  * seed `messages` into events first, so even a one-shot call replays
12
- * exactly. Compaction is recorded as a `compacted` event and re-applied by
13
- * the projection, keeping the replay identical to the live run.
12
+ * exactly. Compaction is recorded as a `microcompacted` boundary (old
13
+ * sessions: a `compacted` event) and re-applied by the projection, keeping
14
+ * the replay identical to the live run.
14
15
  *
15
16
  * Per iteration:
16
17
  * assemble (onUserMessage / onPreLlm)
@@ -54,8 +55,13 @@ export interface LoopConfig {
54
55
  readonly messages?: readonly Message[];
55
56
  /** The run's event log. Pass the session's log to make this run durable. */
56
57
  readonly log?: EventLog;
57
- /** Auto-compaction: when the estimated context exceeds the threshold,
58
- * microcompact old tool results before the next model call. */
58
+ /**
59
+ * DEPRECATED (ADR-0044): the classic auto-compaction path is retired
60
+ * the loop no longer produces `compacted` events; the microcompact
61
+ * boundary (below) absorbed the responsibility. Kept so old configs
62
+ * type-check; IGNORED. Old sessions' `compacted` events still replay
63
+ * verbatim (the projection). Removed at 1.0.
64
+ */
59
65
  readonly compaction?: {
60
66
  readonly thresholdTokens: number;
61
67
  };
@@ -9,8 +9,9 @@
9
9
  * stored alongside it — every adapter call derives them via
10
10
  * `projectMessages(log.all)` (kernel/project.ts). A fresh log encodes the
11
11
  * seed `messages` into events first, so even a one-shot call replays
12
- * exactly. Compaction is recorded as a `compacted` event and re-applied by
13
- * the projection, keeping the replay identical to the live run.
12
+ * exactly. Compaction is recorded as a `microcompacted` boundary (old
13
+ * sessions: a `compacted` event) and re-applied by the projection, keeping
14
+ * the replay identical to the live run.
14
15
  *
15
16
  * Per iteration:
16
17
  * assemble (onUserMessage / onPreLlm)
@@ -27,7 +28,7 @@
27
28
  * re-stream that duplicates output or tool calls.
28
29
  */
29
30
  import { isAdapterEvent } from "../protocol/adapter.js";
30
- import { estimateTokens, microcompact } from "./compaction.js";
31
+ import { estimateTokens } from "./compaction.js";
31
32
  import { EventLog } from "./event-log.js";
32
33
  import { ToolRegistry } from "../tools/registry.js";
33
34
  import { validateArgs } from "../tools/validate.js";
@@ -124,31 +125,6 @@ export async function* loop(config) {
124
125
  return;
125
126
  }
126
127
  turns += 1;
127
- // ── Auto-compaction: ONLY this turn's NEWLY cleared results are
128
- // persisted, keyed by the replaced tool-result event's seq; the
129
- // projection applies them verbatim (A 组/D 组/五).
130
- if (config.compaction && estimateTokens(messages) > config.compaction.thresholdTokens) {
131
- if (hooks.onPreCompact)
132
- await hooks.onPreCompact(messages, {}).catch(() => { });
133
- const result = microcompact(messages);
134
- // 五: the delta only — messages already carrying the clear marker
135
- // are never re-cleared (microcompact's idempotence gate), so the
136
- // same replacement is never recorded twice across turns.
137
- const cleared = result.cleared.map((c) => ({
138
- eventSeq: c.eventSeq,
139
- callId: c.callId,
140
- content: c.content,
141
- }));
142
- if (cleared.length > 0) {
143
- const full = log.append({ type: "compacted", cleared });
144
- if (hooks.onEvent)
145
- await hooks.onEvent(full, {}).catch(() => { });
146
- yield full;
147
- messages = derive();
148
- if (hooks.onPostCompact)
149
- await hooks.onPostCompact(messages, {}).catch(() => { });
150
- }
151
- }
152
128
  // ── C 区: one-shot microcompact boundary when over the threshold ──
153
129
  if (config.microcompact !== undefined && estimateTokens(messages) > config.microcompact.thresholdTokens) {
154
130
  const beforeSeq = microcompactBoundarySeq(log.all, config.microcompact.keepResults ?? KEEP_COMPACTABLE_RESULTS);
@@ -13,11 +13,14 @@
13
13
  * derives from the log, so there is one store and the replay of `seq` 0..N
14
14
  * reproduces the run exactly.
15
15
  *
16
- * Events with no message shape (usage, stop, thinking, terminal, compacted's
17
- * own record) are skipped by the projection; `compacted` REPLAYS the
18
- * compaction by re-running microcompact at that point in the sequence —
19
- * microcompact is deterministic and idempotent, so the replay equals the
20
- * live run. See ADR-0002.
16
+ * Events with no message shape (usage, stop, thinking, terminal, the
17
+ * compaction events' own records) are skipped by the projection;
18
+ * `compacted` applies the EXACT persisted replacements verbatim it never
19
+ * re-runs the compaction algorithm (a future version could differ, A 组/D
20
+ * 组); `microcompacted` boundaries re-derive the cleared view from the
21
+ * stream itself (deterministic and idempotent); `summarized` (ADR-0044)
22
+ * replaces its covered range with one assistant summary message. All
23
+ * three are persisted facts — the replay equals the live run. See ADR-0002.
21
24
  */
22
25
  import type { Event } from "../protocol/events.js";
23
26
  import type { EventInput } from "./event-log.js";
@@ -36,8 +39,8 @@ export declare const DO_NOT_COMPACT = "do-not-compact";
36
39
  * replaying the same log always produces the same messages — BYTE FOR BYTE
37
40
  * (D 区): the same event prefix derives the same message prefix; the only
38
41
  * events that change already-derived messages are `microcompacted`
39
- * boundaries, which are themselves persisted facts (their replay derives
40
- * the same projection every time).
42
+ * boundaries and `summarized` facts, which are themselves persisted facts
43
+ * (their replay derives the same projection every time).
41
44
  *
42
45
  * Text block boundaries are preserved: `text_end` closes the current text
43
46
  * block (an explicit boundary); `text_start` after a block opens a new one.
@@ -13,11 +13,14 @@
13
13
  * derives from the log, so there is one store and the replay of `seq` 0..N
14
14
  * reproduces the run exactly.
15
15
  *
16
- * Events with no message shape (usage, stop, thinking, terminal, compacted's
17
- * own record) are skipped by the projection; `compacted` REPLAYS the
18
- * compaction by re-running microcompact at that point in the sequence —
19
- * microcompact is deterministic and idempotent, so the replay equals the
20
- * live run. See ADR-0002.
16
+ * Events with no message shape (usage, stop, thinking, terminal, the
17
+ * compaction events' own records) are skipped by the projection;
18
+ * `compacted` applies the EXACT persisted replacements verbatim it never
19
+ * re-runs the compaction algorithm (a future version could differ, A 组/D
20
+ * 组); `microcompacted` boundaries re-derive the cleared view from the
21
+ * stream itself (deterministic and idempotent); `summarized` (ADR-0044)
22
+ * replaces its covered range with one assistant summary message. All
23
+ * three are persisted facts — the replay equals the live run. See ADR-0002.
21
24
  */
22
25
  /**
23
26
  * C 区: tools whose output is eligible for microcompact clearing — reads,
@@ -53,8 +56,8 @@ function primaryArg(input) {
53
56
  * replaying the same log always produces the same messages — BYTE FOR BYTE
54
57
  * (D 区): the same event prefix derives the same message prefix; the only
55
58
  * events that change already-derived messages are `microcompacted`
56
- * boundaries, which are themselves persisted facts (their replay derives
57
- * the same projection every time).
59
+ * boundaries and `summarized` facts, which are themselves persisted facts
60
+ * (their replay derives the same projection every time).
58
61
  *
59
62
  * Text block boundaries are preserved: `text_end` closes the current text
60
63
  * block (an explicit boundary); `text_start` after a block opens a new one.
@@ -114,8 +117,49 @@ export function projectMessages(events) {
114
117
  if (ev.type === "tool_call_end")
115
118
  callMeta.set(ev.callId, { name: ev.name, input: ev.input });
116
119
  }
120
+ // ADR-0044: summarized coverage — each `summarized` event covers the
121
+ // range (previous coversToSeq, coversToSeq] of ORDINARY events. The
122
+ // ranges are disjoint and in seq order; boundaries are turn boundaries
123
+ // by construction (summaryBoundarySeq cuts before a user_input), so a
124
+ // skipped event never splits a message. Each summary message renders
125
+ // AT ITS BOUNDARY — the first event after the covered range — NOT at
126
+ // the summarized event itself: the event sits at the log's END (the
127
+ // kept rounds live between the boundary and it), and the summary must
128
+ // precede the kept conversation in reading order.
129
+ const summaryRanges = [];
130
+ {
131
+ let prev = -1;
132
+ for (const ev of events) {
133
+ if (ev.type === "summarized") {
134
+ summaryRanges.push({ from: prev, to: ev.coversToSeq, summary: ev.summary });
135
+ prev = ev.coversToSeq;
136
+ }
137
+ }
138
+ }
139
+ const isCovered = (seq) => summaryRanges.some((r) => seq > r.from && seq <= r.to);
140
+ // Summaries render in range order as the pass crosses their boundaries.
141
+ let renderedSummaries = 0;
117
142
  let explicitAssistant = false;
118
143
  for (const ev of events) {
144
+ // ADR-0044: covered events are replaced by their summary — seed
145
+ // events (EventInput, no seq) precede any summarized fact and are
146
+ // never covered. The summarized events themselves are exempt (their
147
+ // own event sits inside the NEXT range's coverage; the boundary
148
+ // render below is where their message lands).
149
+ if (ev.type !== "summarized" && isCovered(ev.seq ?? -1))
150
+ continue;
151
+ // The boundary render: every range whose end this event crosses
152
+ // yields its assistant summary message, before this event renders.
153
+ while (renderedSummaries < summaryRanges.length &&
154
+ ev.seq !== undefined &&
155
+ ev.seq > summaryRanges[renderedSummaries].to) {
156
+ flushAssistant();
157
+ out.push({
158
+ role: "assistant",
159
+ blocks: [{ type: "text", text: summaryRanges[renderedSummaries].summary }],
160
+ });
161
+ renderedSummaries += 1;
162
+ }
119
163
  switch (ev.type) {
120
164
  case "user_input": {
121
165
  // 六: the final replacement renders HERE, at the input's own
@@ -270,6 +314,11 @@ export function projectMessages(events) {
270
314
  flushAssistant();
271
315
  pendingReasoning = (pendingReasoning ?? "") + ev.text;
272
316
  break;
317
+ case "summarized":
318
+ // ADR-0044: this event produced nothing itself — its summary
319
+ // message was rendered at the boundary render above, in the
320
+ // covered range's position.
321
+ break;
273
322
  case "usage":
274
323
  case "stop":
275
324
  case "terminal":
@@ -0,0 +1,58 @@
1
+ /**
2
+ * L2 — the /compact summary layer (ADR-0044): the MODEL-GENERATED half of
3
+ * context economy. The mechanical half (microcompact, compaction.ts)
4
+ * clears TOOL RESULTS only; this layer compresses the CONVERSATION itself
5
+ * into one durable `summarized` event per call, replacing the covered
6
+ * range with a single assistant summary message in the projection.
7
+ *
8
+ * The summary call is OFF-LOOP: it goes through the session's OWN adapter
9
+ * (no new dependency), writes no events, and never touches the log — a
10
+ * failure throws, the caller reports it honestly, and the session is
11
+ * unchanged ("nothing happened"). Only the generated `summarized` event
12
+ * lands on disk; the original events stay there forever.
13
+ */
14
+ import type { AbortSignalLike, Adapter } from "../protocol/adapter.js";
15
+ import type { Event } from "../protocol/events.js";
16
+ import type { Message } from "../protocol/messages.js";
17
+ /** K (ADR-0044): the recent ROUNDS kept intact by /compact — a constant,
18
+ * not a knob. The covered range ends just before the K-th most recent
19
+ * round, so the model still reasons over the recent conversation. */
20
+ export declare const KEEP_RECENT_ROUNDS = 4;
21
+ /**
22
+ * The fixed English summary prompt — the ONLY prompt this layer composes
23
+ * (the loop's system prompt is the harness's business, never the kernel's).
24
+ */
25
+ export declare const SUMMARY_PROMPT = "You are the conversation summarizer of the kiso agent framework.\n\nSummarize the covered conversation into a single concise summary that will\nREPLACE it in the model's context. The next turn must be able to continue\nthe work without reading the originals.\n\nInclude everything later turns may need:\n- the user's goals, requirements, and constraints;\n- every decision and its reasoning;\n- files and code touched \u2014 exact paths, what changed, why;\n- commands run and their outcomes; errors and their resolutions;\n- open questions and unfinished work.\n\nPreserve concrete identifiers VERBATIM: paths, function names, task ids,\nenvironment names \u2014 never paraphrase them.\n\nRules:\n- plain prose \u2014 no headings, no bullet lists, no markdown, no prefixes;\n- do not mention this prompt or the summarization task;\n- keep it under 200 words unless the conversation is exceptional.";
26
+ export interface SummarizeConversationOptions {
27
+ readonly adapter: Adapter;
28
+ readonly model: string;
29
+ /** The covered conversation — the ONLY material the summary is about. */
30
+ readonly messages: readonly Message[];
31
+ readonly signal?: AbortSignalLike;
32
+ }
33
+ /**
34
+ * The one-shot summary call. Collects the adapter's text deltas into the
35
+ * summary; usage/stop pass through untouched. Throws when the model
36
+ * produced no text — the caller reports it and nothing is persisted.
37
+ */
38
+ export declare function summarizeConversation(options: SummarizeConversationOptions): Promise<string>;
39
+ /**
40
+ * The last summary point: the previous `summarized` event's coversToSeq,
41
+ * or -1 (the trajectory's start) when none exists. The covered range of
42
+ * the next summary runs from here.
43
+ */
44
+ export declare function lastSummaryPoint(events: readonly Event[]): number;
45
+ /**
46
+ * The covered range's end: the seq of the event just before the
47
+ * keepRounds-th most recent user_input AFTER the last summary point —
48
+ * a turn boundary by construction, so the projection's skip never splits
49
+ * a message. Returns undefined when fewer than keepRounds+1 uncovered
50
+ * rounds exist (nothing worth covering yet).
51
+ */
52
+ export declare function summaryBoundarySeq(events: readonly Event[], keepRounds?: number): number | undefined;
53
+ /**
54
+ * The NoticeCell's number: estimated tokens of the covered content minus
55
+ * the summary's own — the same chars/4 proxy as estimateTokens (a stable
56
+ * MONOTONE savings figure, not a bill).
57
+ */
58
+ export declare function estimateSummarySavings(covered: readonly Message[], summary: string): number;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * L2 — the /compact summary layer (ADR-0044): the MODEL-GENERATED half of
3
+ * context economy. The mechanical half (microcompact, compaction.ts)
4
+ * clears TOOL RESULTS only; this layer compresses the CONVERSATION itself
5
+ * into one durable `summarized` event per call, replacing the covered
6
+ * range with a single assistant summary message in the projection.
7
+ *
8
+ * The summary call is OFF-LOOP: it goes through the session's OWN adapter
9
+ * (no new dependency), writes no events, and never touches the log — a
10
+ * failure throws, the caller reports it honestly, and the session is
11
+ * unchanged ("nothing happened"). Only the generated `summarized` event
12
+ * lands on disk; the original events stay there forever.
13
+ */
14
+ import { estimateTokens } from "./compaction.js";
15
+ /** K (ADR-0044): the recent ROUNDS kept intact by /compact — a constant,
16
+ * not a knob. The covered range ends just before the K-th most recent
17
+ * round, so the model still reasons over the recent conversation. */
18
+ export const KEEP_RECENT_ROUNDS = 4;
19
+ /**
20
+ * The fixed English summary prompt — the ONLY prompt this layer composes
21
+ * (the loop's system prompt is the harness's business, never the kernel's).
22
+ */
23
+ export const SUMMARY_PROMPT = `You are the conversation summarizer of the kiso agent framework.
24
+
25
+ Summarize the covered conversation into a single concise summary that will
26
+ REPLACE it in the model's context. The next turn must be able to continue
27
+ the work without reading the originals.
28
+
29
+ Include everything later turns may need:
30
+ - the user's goals, requirements, and constraints;
31
+ - every decision and its reasoning;
32
+ - files and code touched — exact paths, what changed, why;
33
+ - commands run and their outcomes; errors and their resolutions;
34
+ - open questions and unfinished work.
35
+
36
+ Preserve concrete identifiers VERBATIM: paths, function names, task ids,
37
+ environment names — never paraphrase them.
38
+
39
+ Rules:
40
+ - plain prose — no headings, no bullet lists, no markdown, no prefixes;
41
+ - do not mention this prompt or the summarization task;
42
+ - keep it under 200 words unless the conversation is exceptional.`;
43
+ /**
44
+ * The one-shot summary call. Collects the adapter's text deltas into the
45
+ * summary; usage/stop pass through untouched. Throws when the model
46
+ * produced no text — the caller reports it and nothing is persisted.
47
+ */
48
+ export async function summarizeConversation(options) {
49
+ const { adapter, model, messages } = options;
50
+ let text = "";
51
+ for await (const ev of adapter.stream({
52
+ model,
53
+ messages,
54
+ systemPrompt: SUMMARY_PROMPT,
55
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
56
+ })) {
57
+ if (ev.type === "text_delta")
58
+ text += ev.text;
59
+ }
60
+ const trimmed = text.trim();
61
+ if (trimmed === "") {
62
+ throw new Error("the summary call produced no text");
63
+ }
64
+ return trimmed;
65
+ }
66
+ /**
67
+ * The last summary point: the previous `summarized` event's coversToSeq,
68
+ * or -1 (the trajectory's start) when none exists. The covered range of
69
+ * the next summary runs from here.
70
+ */
71
+ export function lastSummaryPoint(events) {
72
+ let prev = -1;
73
+ for (const ev of events) {
74
+ if (ev.type === "summarized" && ev.coversToSeq > prev)
75
+ prev = ev.coversToSeq;
76
+ }
77
+ return prev;
78
+ }
79
+ /**
80
+ * The covered range's end: the seq of the event just before the
81
+ * keepRounds-th most recent user_input AFTER the last summary point —
82
+ * a turn boundary by construction, so the projection's skip never splits
83
+ * a message. Returns undefined when fewer than keepRounds+1 uncovered
84
+ * rounds exist (nothing worth covering yet).
85
+ */
86
+ export function summaryBoundarySeq(events, keepRounds = KEEP_RECENT_ROUNDS) {
87
+ const prevPoint = lastSummaryPoint(events);
88
+ const uncoveredInputs = [];
89
+ for (const ev of events) {
90
+ if (ev.type === "user_input" && ev.seq > prevPoint)
91
+ uncoveredInputs.push(ev.seq);
92
+ }
93
+ if (uncoveredInputs.length <= keepRounds)
94
+ return undefined;
95
+ // The input at m - keepRounds opens the FIRST KEPT round; everything
96
+ // before it (m - keepRounds ≥ 1 covered rounds) is summarizable.
97
+ return uncoveredInputs[uncoveredInputs.length - keepRounds] - 1;
98
+ }
99
+ /**
100
+ * The NoticeCell's number: estimated tokens of the covered content minus
101
+ * the summary's own — the same chars/4 proxy as estimateTokens (a stable
102
+ * MONOTONE savings figure, not a bill).
103
+ */
104
+ export function estimateSummarySavings(covered, summary) {
105
+ return Math.max(0, estimateTokens(covered) - Math.ceil(summary.length / 4));
106
+ }
@@ -51,11 +51,11 @@ export interface StreamOptions {
51
51
  /**
52
52
  * The NARROW event set an adapter may produce (五). Everything else in the
53
53
  * union is kernel-owned — `terminal`, `tool_execution_*`, `permission_*`,
54
- * `user_input`, `compacted`, `uncertain_pending`, `user_input_replaced`,
55
- * `assistant_start`/`assistant_end` — and a provider that yields any of
56
- * those is FORGING kernel state. The type narrows the adapter contract;
57
- * the loop ALSO enforces it at runtime (JS and third-party adapters are
58
- * not trusted — see kernel/loop.ts).
54
+ * `user_input`, `compacted`, `summarized`, `uncertain_pending`,
55
+ * `user_input_replaced`, `assistant_start`/`assistant_end` — and a
56
+ * provider that yields any of those is FORGING kernel state. The type
57
+ * narrows the adapter contract; the loop ALSO enforces it at runtime (JS
58
+ * and third-party adapters are not trusted — see kernel/loop.ts).
59
59
  */
60
60
  export type AdapterEvent = Extract<Event, {
61
61
  type: "text_start" | "text_delta" | "text_end" | "tool_call_start" | "tool_call_input_delta" | "tool_call_end" | "thinking" | "usage" | "stop";
@@ -327,6 +327,29 @@ export interface MicroCompactEvent {
327
327
  readonly type: "microcompacted";
328
328
  readonly beforeSeq: number;
329
329
  }
330
+ /**
331
+ * ADR-0044 — a model-generated summary replaced the covered conversation
332
+ * range. `coversToSeq` is the seq of the LAST covered event; the covered
333
+ * range runs from just past the previous `summarized` event's coversToSeq
334
+ * (or the trajectory's start for the first) up to coversToSeq. The
335
+ * projection replaces exactly those events with ONE assistant summary
336
+ * message (`summary`); every `summarized` event always renders its own
337
+ * message. Byte-stable: a summarized event is a persisted fact, so the
338
+ * same events derive the same messages on every replay.
339
+ *
340
+ * The summary is generated OFF-LOOP through the session's own adapter —
341
+ * the summary request itself never enters the log, and a failed summary
342
+ * never leaves a record ("nothing happened"). The ORIGINAL events stay on
343
+ * disk forever: /last, /think, and the raw log still reach them.
344
+ */
345
+ export interface SummarizedEvent {
346
+ readonly seq: number;
347
+ readonly type: "summarized";
348
+ /** The last covered event's seq; the range is (previous coversToSeq, coversToSeq]. */
349
+ readonly coversToSeq: number;
350
+ /** The model's compression — replaces the covered range in the projection. */
351
+ readonly summary: string;
352
+ }
330
353
  /** Extended-thinking content. Providers without it emit nothing here. */
331
354
  export interface Thinking {
332
355
  readonly seq: number;
@@ -417,7 +440,7 @@ export interface TerminalEvent {
417
440
  * The union. Consume it with a `switch (event.type)`; with
418
441
  * `strictNullChecks` on, an unhandled variant is a compile error.
419
442
  */
420
- export type Event = AssistantStart | AssistantEnd | TextStart | TextDelta | TextEnd | ToolCallStart | ToolCallInputDelta | ToolCallEnd | ToolResultEvent | Thinking | Usage | Stop | UserInputEvent | CompactedEvent | ToolExecutionStarted | ToolExecutionSucceeded | ToolExecutionFailed | ToolExecutionResolved | PermissionRequested | PermissionDecided | PermissionExpired | UncertainPending | UserInputReplaced | MicroCompactEvent | TerminalEvent;
443
+ export type Event = AssistantStart | AssistantEnd | TextStart | TextDelta | TextEnd | ToolCallStart | ToolCallInputDelta | ToolCallEnd | ToolResultEvent | Thinking | Usage | Stop | UserInputEvent | CompactedEvent | ToolExecutionStarted | ToolExecutionSucceeded | ToolExecutionFailed | ToolExecutionResolved | PermissionRequested | PermissionDecided | PermissionExpired | UncertainPending | UserInputReplaced | MicroCompactEvent | SummarizedEvent | TerminalEvent;
421
444
  /**
422
445
  * Runtime type guard for the union. The store validates every JSONL record
423
446
  * with it: valid JSON that is not a kiso event is corruption, not history.
@@ -214,6 +214,12 @@ const EVENT_VALIDATORS = {
214
214
  permission_expired: (v) => typeof v.decisionId === "string" && typeof v.reason === "string",
215
215
  uncertain_pending: (v) => typeof v.executionId === "string" && typeof v.callId === "string" && typeof v.name === "string" && typeof v.error === "string",
216
216
  microcompacted: (v) => isNonNegativeInt(v.beforeSeq),
217
+ // ADR-0044: coversToSeq is a seq boundary BEFORE this event (a summary
218
+ // covers only what preceded it), and the summary is non-empty text.
219
+ summarized: (v) => isNonNegativeInt(v.coversToSeq) &&
220
+ typeof v.summary === "string" &&
221
+ v.summary.length > 0 &&
222
+ v.coversToSeq < v.seq,
217
223
  user_input_replaced: (v) => isNonNegativeInt(v.replaces) && (v.content === null || isContent(v.content)) && isSource(v),
218
224
  terminal: (v) => isTerminal(v.outcome),
219
225
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-core",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "kiso(基礎) core — protocol, event log, loop, hooks, modes, permissions, compaction, delivery truth. The 2,000-line kernel at the bottom of the kiso framework.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "openai"
34
34
  ],
35
35
  "devDependencies": {
36
- "@vincemakes/kiso-evals": "0.1.19",
36
+ "@vincemakes/kiso-evals": "0.1.20",
37
37
  "@types/node": "^26.1.2",
38
38
  "typescript": "^5.7.2",
39
39
  "vitest": "^3.0.0"