@vincemakes/kiso-core 0.44.0 → 0.45.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/dist/index.d.ts CHANGED
@@ -7,5 +7,4 @@ export * from "./kernel/event-log.js";
7
7
  export * from "./kernel/hooks.js";
8
8
  export * from "./kernel/permission.js";
9
9
  export * from "./kernel/loop.js";
10
- export * from "./kernel/compaction.js";
11
10
  export * from "./kernel/project.js";
package/dist/index.js CHANGED
@@ -7,5 +7,4 @@ export * from "./kernel/event-log.js";
7
7
  export * from "./kernel/hooks.js";
8
8
  export * from "./kernel/permission.js";
9
9
  export * from "./kernel/loop.js";
10
- export * from "./kernel/compaction.js";
11
10
  export * from "./kernel/project.js";
@@ -33,14 +33,25 @@
33
33
  * re-stream that duplicates output or tool calls.
34
34
  */
35
35
  import { type Adapter, type AbortSignalLike } from "../protocol/adapter.js";
36
- import type { ContinuationScope, Event, StructuredError } from "../protocol/events.js";
36
+ import type { ContinuationScope, Event, StructuredError, UserInputVia } from "../protocol/events.js";
37
37
  import type { ApprovalChain } from "../protocol/extension.js";
38
38
  import { EventLog } from "./event-log.js";
39
39
  import type { EventInput } from "./event-log.js";
40
- import type { AssistantBlock, AssistantMessage, Message, ToolResultMessage } from "../protocol/messages.js";
40
+ import type { AssistantBlock, AssistantMessage, ContentBlock, Message, MessageSource, ToolResultMessage } from "../protocol/messages.js";
41
41
  import { ToolRegistry } from "../tools/registry.js";
42
42
  import type { HookHost } from "./hooks.js";
43
43
  import { type PermissionDecision } from "./permission.js";
44
+ /**
45
+ * ADR-0057 (Safe Admission): input handed to a running run at a quiescent
46
+ * boundary. A "human" input passes onUserMessage once; a "runtime" input —
47
+ * a fact the runtime owes the model — never does.
48
+ */
49
+ export interface AdmissionInput {
50
+ readonly kind: "human" | "runtime";
51
+ readonly content: string | readonly ContentBlock[];
52
+ readonly source?: MessageSource;
53
+ readonly via?: UserInputVia;
54
+ }
44
55
  export interface LoopConfig {
45
56
  readonly adapter: Adapter;
46
57
  readonly model: string;
@@ -78,6 +89,14 @@ export interface LoopConfig {
78
89
  * run that gets nothing back then ends on that refusal.
79
90
  */
80
91
  readonly compact?: (events: readonly Event[], messages: readonly Message[], why: "request" | "overflow") => Promise<readonly EventInput[]>;
92
+ /**
93
+ * ADR-0057 (Safe Admission): the run's ingress. The kernel asks at the
94
+ * three sites — "take", or "takeOrSeal" where the run would otherwise
95
+ * end — and every terminal asks "seal" first. What it returns is
96
+ * appended as `user_input`. Absent: nothing is admitted, and no request
97
+ * byte changes.
98
+ */
99
+ readonly admit?: (mode: "take" | "takeOrSeal" | "seal") => Promise<readonly AdmissionInput[]>;
81
100
  readonly signal?: AbortSignalLike;
82
101
  readonly temperature?: number;
83
102
  readonly maxTokens?: number;
@@ -105,6 +105,7 @@ export async function* loop(config) {
105
105
  const derive = () => projectMessages(log.all);
106
106
  /** Yield a terminal: onStop (lifecycle) → event → onEvent (observer). */
107
107
  const terminal = async (outcome) => {
108
+ await config.admit?.("seal"); // ADR-0057: the ingress closes before onStop can be awaited
108
109
  if (hooks.onStop)
109
110
  await hooks.onStop(outcome.kind, {}).catch(() => { });
110
111
  const full = log.append({ type: "terminal", outcome });
@@ -129,6 +130,35 @@ export async function* loop(config) {
129
130
  // every later turn of the run sees.
130
131
  let messages = derive();
131
132
  let vetoed = false;
133
+ // ADR-0057 §4 — the hook speaks ONCE per human input, the run's own or
134
+ // one admitted mid-run; its outcome is persisted at the input's
135
+ // position. Returns true for a veto.
136
+ async function* hook(seq, msg) {
137
+ const rewritten = await hooks.onUserMessage(msg, {});
138
+ yield log.append({ type: "user_input_replaced", replaces: seq, content: rewritten?.content ?? null, ...(rewritten !== null && rewritten.source !== undefined ? { source: rewritten.source } : {}) });
139
+ messages = derive();
140
+ return rewritten === null;
141
+ }
142
+ // An input admitted mid-run follows a committed model turn of the same run
143
+ // (a stop after the last terminal); the run's own input — or a seeded
144
+ // history, which carries no stop — never does. A veto of the run's own
145
+ // input ends the run; a veto of an admitted one drops only that input.
146
+ const midRun = (seq) => {
147
+ const after = [...log.all].reverse().find((e) => e.type === "terminal")?.seq ?? -1;
148
+ return log.all.some((e) => e.type === "stop" && e.seq > after && e.seq < seq);
149
+ };
150
+ /** ADR-0057 — admit what has arrived; returns how many inputs landed (a veto does not). */
151
+ async function* admit(mode) {
152
+ let landed = 0;
153
+ for (const input of (await config.admit?.(mode)) ?? []) {
154
+ const src = input.source !== undefined ? { source: input.source } : {};
155
+ yield* emit({ type: "user_input", content: input.content, ...src, ...(input.via !== undefined ? { via: input.via } : {}) });
156
+ messages = derive();
157
+ if (!(input.kind === "human" && hooks.onUserMessage && (yield* hook(log.lastSeq, { role: "user", content: input.content, ...src }))))
158
+ landed += 1;
159
+ }
160
+ return landed;
161
+ }
132
162
  if (hooks.onUserMessage && messages.length > 0) {
133
163
  const last = messages.at(-1);
134
164
  if (last?.role === "user") {
@@ -147,21 +177,12 @@ export async function* loop(config) {
147
177
  if (replacement.content === null)
148
178
  vetoed = true;
149
179
  }
150
- if (replacement === undefined && inputEvent) {
151
- const rewritten = await hooks.onUserMessage(last, {});
152
- // round 1: the rewrite/veto is a NORMAL stream event — persisted by
153
- // the harness and visible to consumers, never a hidden append.
154
- const replaced = log.append({
155
- type: "user_input_replaced",
156
- replaces: inputEvent.seq,
157
- content: rewritten?.content ?? null,
158
- ...(rewritten !== null && rewritten.source !== undefined ? { source: rewritten.source } : {}),
159
- });
160
- messages = derive();
161
- yield replaced;
162
- if (rewritten === null)
163
- vetoed = true; // round 3: a true veto ends the run
164
- }
180
+ // round 1: the rewrite/veto is a NORMAL stream event — persisted by
181
+ // the harness and visible to consumers, never a hidden append.
182
+ // round 3: a true veto ends the run — unless the input was admitted
183
+ // mid-run (ADR-0057: a resume decides as the live path did).
184
+ if (replacement === undefined && inputEvent && (yield* hook(inputEvent.seq, last)) && !midRun(inputEvent.seq))
185
+ vetoed = true;
165
186
  }
166
187
  }
167
188
  // round 3: a true veto ends the run — the provider is NEVER called, even
@@ -491,6 +512,12 @@ export async function* loop(config) {
491
512
  yield await terminal({ kind: "aborted", by: "user" });
492
513
  return;
493
514
  }
515
+ // ADR-0057 site A: the turn head — quiescent, eligible (maxTurns wins),
516
+ // and BEFORE the END_TURN scan, which stops at an admitted input. The
517
+ // overflow retry is left alone (request mode only).
518
+ const requestMode = overflow === null;
519
+ if (requestMode && turns < maxTurns)
520
+ yield* admit("take");
494
521
  // 0.42.0: a settled result tagged END_TURN in the OPEN turn — after
495
522
  // the most recent stop, user_input or terminal — completes the run
496
523
  // instead of asking again. Read from the log at the HEAD of every
@@ -522,6 +549,9 @@ export async function* loop(config) {
522
549
  if (added.length > 0)
523
550
  messages = derive();
524
551
  }
552
+ // ADR-0057 site C: what arrived during a request-mode compaction.
553
+ if (requestMode)
554
+ yield* admit("take");
525
555
  if (hooks.onPreLlm)
526
556
  await hooks.onPreLlm({ model: config.model, turns }, {});
527
557
  if (aborted()) {
@@ -891,6 +921,9 @@ export async function* loop(config) {
891
921
  overflow = t.error;
892
922
  continue;
893
923
  }
924
+ // ADR-0057 site B: the run would end — take what arrived, or seal.
925
+ if (t.kind === "completed" && turns < maxTurns && (yield* admit("takeOrSeal")) > 0)
926
+ continue;
894
927
  yield await terminal(t);
895
928
  return;
896
929
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-core",
3
- "version": "0.44.0",
3
+ "version": "0.45.1",
4
4
  "description": "kiso 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.44.0",
36
+ "@vincemakes/kiso-evals": "0.45.1",
37
37
  "@types/node": "^26.1.2",
38
38
  "typescript": "^5.7.2",
39
39
  "vitest": "^3.0.0"
@@ -1,28 +0,0 @@
1
- /**
2
- * L2 — context-economy primitives, the MECHANICAL half.
3
- *
4
- * The kernel's compaction policy is identity preservation, not summary
5
- * (the predecessor's 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
- *
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.
19
- */
20
- import type { Message } from "../protocol/messages.js";
21
- /**
22
- * Rough token estimate (chars/4 + structural overhead). Calibration-free on
23
- * purpose: context economy only needs a stable MONOTONE proxy, not an exact
24
- * count — the threshold absorbs the error (the predecessor's ADR-0007). The proxy's
25
- * three-word contract, and the pins that hold it, are
26
- * packages/core/tests/sc1b-estimator.test.ts.
27
- */
28
- export declare function estimateTokens(messages: readonly Message[]): number;
@@ -1,90 +0,0 @@
1
- /**
2
- * L2 — context-economy primitives, the MECHANICAL half.
3
- *
4
- * The kernel's compaction policy is identity preservation, not summary
5
- * (the predecessor's 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
- *
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.
19
- */
20
- /**
21
- * What a non-text block (an image) contributes. There is no character count
22
- * to proxy, and this module refuses to guess a provider's image
23
- * tokenization; the figure exists so a block is never worth ZERO, which is
24
- * what MONOTONE requires. Deliberately small: under-stating an image is a
25
- * threshold that fires slightly late, over-stating it is one that fires on
26
- * a conversation that was never large.
27
- */
28
- const NON_TEXT_BLOCK_TOKENS = 8;
29
- /**
30
- * chars/4 over a `string | ContentBlock[]` content field.
31
- *
32
- * SC-1b ①: the array arm must be summed BLOCK BY BLOCK. Reading `.length`
33
- * off it yields the block COUNT — a five-block 50 KB message scored ~2
34
- * tokens, and the live microcompact threshold believed it.
35
- */
36
- function contentTokens(content) {
37
- if (typeof content === "string")
38
- return textTokens(content);
39
- let tokens = 0;
40
- for (const block of content) {
41
- tokens += block.type === "text" ? textTokens(block.text) : NON_TEXT_BLOCK_TOKENS;
42
- }
43
- return tokens;
44
- }
45
- /**
46
- * 0.40.0 (the owner's session): chars/4, except that a CJK character counts
47
- * as one token — which is about what a tokenizer spends on one. Plain
48
- * chars/4 read a Chinese-heavy 730k context as ~470k, so the thresholds
49
- * never fired and the ctx row lied. Text without CJK scores exactly what
50
- * it scored before. A counting loop, not a regex match: the text can be
51
- * megabytes, and a match array would hold one entry per character.
52
- */
53
- function textTokens(text) {
54
- let cjk = 0;
55
- for (let i = 0; i < text.length; i++) {
56
- const c = text.charCodeAt(i);
57
- // kana, CJK ext A, unified ideographs, Hangul syllables,
58
- // compatibility ideographs, full-width forms
59
- if ((c >= 0x3040 && c <= 0x30ff) || (c >= 0x3400 && c <= 0x4dbf) || (c >= 0x4e00 && c <= 0x9fff) || (c >= 0xac00 && c <= 0xd7af) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xff00 && c <= 0xffef))
60
- cjk++;
61
- }
62
- return cjk + Math.ceil((text.length - cjk) / 4);
63
- }
64
- /**
65
- * Rough token estimate (chars/4 + structural overhead). Calibration-free on
66
- * purpose: context economy only needs a stable MONOTONE proxy, not an exact
67
- * count — the threshold absorbs the error (the predecessor's ADR-0007). The proxy's
68
- * three-word contract, and the pins that hold it, are
69
- * packages/core/tests/sc1b-estimator.test.ts.
70
- */
71
- export function estimateTokens(messages) {
72
- let total = 0;
73
- for (const msg of messages) {
74
- if (msg.role === "user") {
75
- total += contentTokens(msg.content);
76
- }
77
- else if (msg.role === "assistant") {
78
- for (const block of msg.blocks) {
79
- total +=
80
- block.type === "text"
81
- ? textTokens(block.text)
82
- : textTokens(JSON.stringify(block.input)) + 20;
83
- }
84
- }
85
- else {
86
- total += contentTokens(msg.content) + 10;
87
- }
88
- }
89
- return total;
90
- }