@vincemakes/kiso-core 0.1.0 → 0.1.2

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.
@@ -58,6 +58,17 @@ export interface LoopConfig {
58
58
  readonly compaction?: {
59
59
  readonly thresholdTokens: number;
60
60
  };
61
+ /**
62
+ * C 区: MICROCOMPACT — when the projected context exceeds the threshold,
63
+ * append ONE durable `microcompacted` boundary (clearing compactable tool
64
+ * results older than the recent turns). The decision is a persisted fact:
65
+ * the projection derives the same cleared view from the same events,
66
+ * byte for byte, across crash/resume. Never a per-turn progressive
67
+ * clearing.
68
+ */
69
+ readonly microcompact?: {
70
+ readonly thresholdTokens: number;
71
+ };
61
72
  readonly signal?: AbortSignalLike;
62
73
  readonly temperature?: number;
63
74
  readonly maxTokens?: number;
@@ -27,7 +27,7 @@
27
27
  * re-stream that duplicates output or tool calls.
28
28
  */
29
29
  import { isAdapterEvent } from "../protocol/adapter.js";
30
- import { estimateTokens, microcompact } from "./compaction.js";
30
+ import { estimateTokens, KEEP_RECENT_TURNS, microcompact } from "./compaction.js";
31
31
  import { EventLog } from "./event-log.js";
32
32
  import { ToolRegistry } from "../tools/registry.js";
33
33
  import { validateArgs } from "../tools/validate.js";
@@ -149,6 +149,17 @@ export async function* loop(config) {
149
149
  await hooks.onPostCompact(messages, {}).catch(() => { });
150
150
  }
151
151
  }
152
+ // ── C 区: one-shot microcompact boundary when over the threshold ──
153
+ if (config.microcompact !== undefined && estimateTokens(messages) > config.microcompact.thresholdTokens) {
154
+ const beforeSeq = microcompactBoundarySeq(log.all);
155
+ if (beforeSeq !== undefined) {
156
+ const full = log.append({ type: "microcompacted", beforeSeq });
157
+ if (hooks.onEvent)
158
+ await hooks.onEvent(full, {}).catch(() => { });
159
+ yield full;
160
+ messages = derive();
161
+ }
162
+ }
152
163
  if (hooks.onPreLlm)
153
164
  await hooks.onPreLlm({ model: config.model, turns }, {});
154
165
  if (aborted()) {
@@ -785,6 +796,21 @@ function sleep(ms, signal) {
785
796
  }, { once: true });
786
797
  });
787
798
  }
799
+ /**
800
+ * C 区: the boundary seq for a microcompact — the seq of the user input
801
+ * KEEP_RECENT_TURNS+1 places from the end (everything BEFORE that user
802
+ * input is old enough to clear; the recent turns stay intact). Undefined
803
+ * when the history is too short to clear anything.
804
+ */
805
+ function microcompactBoundarySeq(events) {
806
+ const userSeqs = [];
807
+ for (const ev of events) {
808
+ if (ev.type === "user_input")
809
+ userSeqs.push(ev.seq);
810
+ }
811
+ const boundary = userSeqs[userSeqs.length - KEEP_RECENT_TURNS - 1];
812
+ return boundary !== undefined ? boundary - 1 : undefined;
813
+ }
788
814
  /** A signal that never aborts — for executions outside any abort scope. */
789
815
  const NEVER_ABORT = {
790
816
  aborted: false,
@@ -22,9 +22,15 @@
22
22
  import type { Event } from "../protocol/events.js";
23
23
  import type { EventInput } from "./event-log.js";
24
24
  import type { AssistantBlock, Message, MessageSource } from "../protocol/messages.js";
25
+ /** The tag that makes a tool result un-clearable (C 区). */
26
+ export declare const DO_NOT_COMPACT = "do-not-compact";
25
27
  /**
26
28
  * Rebuild the message array from events. Deterministic and order-sensitive:
27
- * replaying the same log always produces the same messages.
29
+ * replaying the same log always produces the same messages — BYTE FOR BYTE
30
+ * (D 区): the same event prefix derives the same message prefix; the only
31
+ * events that change already-derived messages are `microcompacted`
32
+ * boundaries, which are themselves persisted facts (their replay derives
33
+ * the same projection every time).
28
34
  *
29
35
  * Text block boundaries are preserved: `text_end` closes the current text
30
36
  * block (an explicit boundary); `text_start` after a block opens a new one.
@@ -19,9 +19,41 @@
19
19
  * microcompact is deterministic and idempotent, so the replay equals the
20
20
  * live run. See ADR-0002.
21
21
  */
22
+ /**
23
+ * C 区: tools whose output is eligible for microcompact clearing — reads,
24
+ * listings, searches, and shell output. write/edit outputs are short and
25
+ * never cleared.
26
+ */
27
+ const MICROCOMPACTABLE = new Set(["read_file", "list_dir", "search_text", "shell"]);
28
+ /** The tag that makes a tool result un-clearable (C 区). */
29
+ export const DO_NOT_COMPACT = "do-not-compact";
30
+ /**
31
+ * C 区: the fixed placeholder for a cleared tool output, derived ONLY from
32
+ * the event stream (deterministic across replay): the tool's name and its
33
+ * primary argument (the first string-valued field of its input).
34
+ */
35
+ function microPlaceholder(name, arg) {
36
+ return arg === undefined
37
+ ? `[old tool output cleared: ${name}]`
38
+ : `[old tool output cleared: ${name} ${arg}]`;
39
+ }
40
+ /** The first string-valued argument of a tool input, if any. */
41
+ function primaryArg(input) {
42
+ if (input === null || input === undefined)
43
+ return undefined;
44
+ for (const value of Object.values(input)) {
45
+ if (typeof value === "string" && value !== "")
46
+ return value;
47
+ }
48
+ return undefined;
49
+ }
22
50
  /**
23
51
  * Rebuild the message array from events. Deterministic and order-sensitive:
24
- * replaying the same log always produces the same messages.
52
+ * replaying the same log always produces the same messages — BYTE FOR BYTE
53
+ * (D 区): the same event prefix derives the same message prefix; the only
54
+ * events that change already-derived messages are `microcompacted`
55
+ * boundaries, which are themselves persisted facts (their replay derives
56
+ * the same projection every time).
25
57
  *
26
58
  * Text block boundaries are preserved: `text_end` closes the current text
27
59
  * block (an explicit boundary); `text_start` after a block opens a new one.
@@ -39,16 +71,23 @@ export function projectMessages(events) {
39
71
  text = null;
40
72
  }
41
73
  };
74
+ // 自举 P1: the reasoning of the turn being built, accumulated from its
75
+ // `thinking` events and attached to the assistant message at flush —
76
+ // deterministic (same events → same messages → same request body, D 区).
77
+ let pendingReasoning = null;
42
78
  const flushAssistant = () => {
43
79
  pushText();
44
80
  if (blocks.length === 0) {
45
81
  assistantSource = undefined;
46
82
  return;
47
83
  }
84
+ const reasoning = pendingReasoning;
85
+ pendingReasoning = null;
48
86
  out.push({
49
87
  role: "assistant",
50
88
  blocks: [...blocks],
51
89
  ...(assistantSource !== undefined ? { source: assistantSource } : {}),
90
+ ...(reasoning !== null ? { reasoning } : {}),
52
91
  });
53
92
  blocks = [];
54
93
  assistantSource = undefined;
@@ -68,6 +107,12 @@ export function projectMessages(events) {
68
107
  });
69
108
  }
70
109
  }
110
+ // C 区: callId → {name, input} for the microcompact placeholder.
111
+ const callMeta = new Map();
112
+ for (const ev of events) {
113
+ if (ev.type === "tool_call_end")
114
+ callMeta.set(ev.callId, { name: ev.name, input: ev.input });
115
+ }
71
116
  let explicitAssistant = false;
72
117
  for (const ev of events) {
73
118
  switch (ev.type) {
@@ -171,6 +216,27 @@ export function projectMessages(events) {
171
216
  out.push(message);
172
217
  break;
173
218
  }
219
+ case "microcompacted": {
220
+ flushAssistant();
221
+ // C 区: replace every eligible OLD tool result with the fixed
222
+ // placeholder. Eligibility: the result's own event seq <= the
223
+ // boundary, its tool in the whitelist, and no do-not-compact
224
+ // tag. Deterministic — the boundary event IS the decision.
225
+ const replaced = out.map((m) => {
226
+ if (m.role !== "tool")
227
+ return m;
228
+ if (m.eventSeq === undefined || m.eventSeq > ev.beforeSeq)
229
+ return m;
230
+ const meta = callMeta.get(m.callId);
231
+ if (meta === undefined || !MICROCOMPACTABLE.has(meta.name))
232
+ return m;
233
+ if ((m.tags ?? []).includes(DO_NOT_COMPACT))
234
+ return m;
235
+ return { ...m, content: microPlaceholder(meta.name, primaryArg(meta.input)) };
236
+ });
237
+ out.splice(0, out.length, ...replaced);
238
+ break;
239
+ }
174
240
  case "compacted": {
175
241
  flushAssistant();
176
242
  // Apply the EXACT persisted replacements — never re-run the
@@ -197,9 +263,16 @@ export function projectMessages(events) {
197
263
  break;
198
264
  }
199
265
  case "thinking":
266
+ // 自举 P1: accumulate the turn's reasoning — the flush (an
267
+ // empty one at the turn's start) keeps the pending text, and
268
+ // the assistant message that follows carries it.
269
+ flushAssistant();
270
+ pendingReasoning = (pendingReasoning ?? "") + ev.text;
271
+ break;
200
272
  case "usage":
201
273
  case "stop":
202
274
  case "terminal":
275
+ case "microcompacted":
203
276
  case "tool_execution_started":
204
277
  case "tool_execution_succeeded":
205
278
  case "tool_execution_failed":
@@ -237,6 +310,11 @@ export function messagesToEvents(messages) {
237
310
  case "assistant": {
238
311
  // D 组: an explicit assistant_start/assistant_end pair frames
239
312
  // the message — adjacent and empty assistants round-trip.
313
+ // 自举 P1: the reasoning re-enters the log as its ORIGINAL
314
+ // thinking event — the projection re-attaches it identically.
315
+ if (msg.reasoning !== undefined) {
316
+ out.push({ type: "thinking", text: msg.reasoning });
317
+ }
240
318
  out.push({
241
319
  type: "assistant_start",
242
320
  ...(msg.source !== undefined ? { source: msg.source } : {}),
@@ -310,6 +310,20 @@ export interface UserInputReplaced {
310
310
  /** Provenance of the replacement — preserved from the hook (三). */
311
311
  readonly source?: import("./messages.js").MessageSource;
312
312
  }
313
+ /**
314
+ * C 区: a MICROCOMPACT boundary — the durable record of a context-clearing
315
+ * decision. `beforeSeq` is the event seq up to which eligible tool results
316
+ * are cleared: the projection replaces every tool_result with seq <=
317
+ * beforeSeq (whose tool is in the compactable whitelist and carries no
318
+ * do-not-compact tag) with a fixed placeholder derived from the stream
319
+ * itself. The decision is a PERSISTED FACT — replaying the same events
320
+ * derives the same messages, byte for byte, after a crash or resume.
321
+ */
322
+ export interface MicroCompactEvent {
323
+ readonly seq: number;
324
+ readonly type: "microcompacted";
325
+ readonly beforeSeq: number;
326
+ }
313
327
  /** Extended-thinking content. Providers without it emit nothing here. */
314
328
  export interface Thinking {
315
329
  readonly seq: number;
@@ -400,7 +414,7 @@ export interface TerminalEvent {
400
414
  * The union. Consume it with a `switch (event.type)`; with
401
415
  * `strictNullChecks` on, an unhandled variant is a compile error.
402
416
  */
403
- 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 | TerminalEvent;
417
+ 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;
404
418
  /**
405
419
  * Runtime type guard for the union. The store validates every JSONL record
406
420
  * with it: valid JSON that is not a kiso event is corruption, not history.
@@ -212,6 +212,7 @@ const EVENT_VALIDATORS = {
212
212
  (v.reason === undefined || typeof v.reason === "string"),
213
213
  permission_expired: (v) => typeof v.decisionId === "string" && typeof v.reason === "string",
214
214
  uncertain_pending: (v) => typeof v.executionId === "string" && typeof v.callId === "string" && typeof v.name === "string" && typeof v.error === "string",
215
+ microcompacted: (v) => isNonNegativeInt(v.beforeSeq),
215
216
  user_input_replaced: (v) => isNonNegativeInt(v.replaces) && (v.content === null || isContent(v.content)) && isSource(v),
216
217
  terminal: (v) => isTerminal(v.outcome),
217
218
  };
@@ -71,6 +71,12 @@ export interface AssistantMessage {
71
71
  readonly role: "assistant";
72
72
  readonly blocks: readonly AssistantBlock[];
73
73
  readonly source?: MessageSource;
74
+ /**
75
+ * 自举 P1: the turn's reasoning, derived deterministically from its
76
+ * `thinking` events (DeepSeek's thinking mode requires it back on
77
+ * follow-up requests). Present only when the turn actually reasoned.
78
+ */
79
+ readonly reasoning?: string;
74
80
  }
75
81
  /**
76
82
  * Sent back to the model after a tool ran.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-core",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "kiso(\u57fa\u790e) core \u2014 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.0",
36
+ "@vincemakes/kiso-evals": "0.1.2",
37
37
  "@types/node": "^26.1.2",
38
38
  "typescript": "^5.7.2",
39
39
  "vitest": "^3.0.0"