@chantier/core 0.3.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/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ All notable changes are documented in the root
4
+ [CHANGELOG.md](../../CHANGELOG.md).
5
+
6
+ ## 0.3.0 — 2026-09-16
7
+
8
+ - Compaction is a derived view over the append-only session log; callers drive
9
+ `compactSession`, with `/compact`, an automatic trigger near the context
10
+ window, and a reactive fallback.
11
+ - `includeUsage` is requested from providers so token accounting stays exact.
12
+
13
+ See the root changelog for the full list.
package/LICENSE ADDED
@@ -0,0 +1,190 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ 4. (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ Copyright 2026 DASHLEA × MLC
179
+
180
+ Licensed under the Apache License, Version 2.0 (the "License");
181
+ you may not use this file except in compliance with the License.
182
+ You may obtain a copy of the License at
183
+
184
+ http://www.apache.org/licenses/LICENSE-2.0
185
+
186
+ Unless required by applicable law or agreed to in writing, software
187
+ distributed under the License is distributed on an "AS IS" BASIS,
188
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
189
+ See the License for the specific language governing permissions and
190
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,12 @@
1
+ # @chantier/core
2
+
3
+ Agent loop, session store, and the `ModelAdapter` seam for the
4
+ [chantier](https://github.com/mlcassociation/chantier) coding agent. Sessions
5
+ are plain JSONL under `~/.chantier/sessions/`; compaction is a derived view
6
+ over that append-only log, never a rewrite of it.
7
+
8
+ ```ts
9
+ import { runAgent, createSessionStore } from "@chantier/core";
10
+ ```
11
+
12
+ License: Apache-2.0.
@@ -0,0 +1,353 @@
1
+ import { ApprovalDetail, ApprovalSink, PermissionEngine } from "@chantier/permissions";
2
+ //#region src/types.d.ts
3
+ interface TextBlock {
4
+ type: "text";
5
+ text: string;
6
+ }
7
+ interface ToolCallBlock {
8
+ type: "tool-call";
9
+ id: string;
10
+ name: string;
11
+ args: Record<string, unknown>;
12
+ }
13
+ interface Usage {
14
+ inputTokens: number;
15
+ outputTokens: number;
16
+ }
17
+ interface SystemMessage {
18
+ role: "system";
19
+ content: string;
20
+ }
21
+ interface UserMessage {
22
+ role: "user";
23
+ content: Array<TextBlock>;
24
+ }
25
+ interface AssistantMessage {
26
+ role: "assistant";
27
+ content: Array<TextBlock | ToolCallBlock>;
28
+ usage?: Usage;
29
+ }
30
+ interface ToolResultMessage {
31
+ role: "tool-result";
32
+ toolCallId: string;
33
+ toolName: string;
34
+ content: string;
35
+ }
36
+ type Message = SystemMessage | UserMessage | AssistantMessage | ToolResultMessage;
37
+ /**
38
+ * Marker appended to the JSONL when a compaction replaces the older part of the
39
+ * conversation with a summary. The log stays append-only: nothing is rewritten
40
+ * or deleted, `load()` always returns everything (TUI/rewind keep working), and
41
+ * `view()` folds compaction entries into the model-facing context.
42
+ *
43
+ * `firstKeptMessageIndex` is the 0-based ordinal of the first `message` entry
44
+ * that is kept verbatim after this compaction (the session header and
45
+ * compaction entries themselves are not counted). Ordinals are stable because
46
+ * the log is append-only — entries are never reordered, rewritten, or removed —
47
+ * so no entry ids are needed, and `--continue`/fork of an old file stays safe.
48
+ *
49
+ * Convention: the compaction entry is immediately followed by a regular `user`
50
+ * message entry carrying the summary text prefixed with COMPACTED_MARKER. The
51
+ * summary message is a real log entry (its ordinal is always >=
52
+ * firstKeptMessageIndex), so `view()` returns it naturally and resume flows
53
+ * stay plain `Message[]` with no synthetic messages.
54
+ */
55
+ interface CompactionEntry {
56
+ type: "compaction";
57
+ summary: string;
58
+ firstKeptMessageIndex: number;
59
+ tokensBefore: number;
60
+ createdAt: string;
61
+ }
62
+ type StopReason = "end_turn" | "max_turns" | "error";
63
+ type ModelEvent = {
64
+ type: "text-delta";
65
+ text: string;
66
+ } | {
67
+ type: "tool-call";
68
+ id: string;
69
+ name: string;
70
+ args: Record<string, unknown>;
71
+ } | {
72
+ type: "finish";
73
+ stopReason: StopReason;
74
+ usage?: Usage;
75
+ };
76
+ interface ToolContext {
77
+ cwd: string;
78
+ session: SessionStore;
79
+ permission: PermissionEngine;
80
+ signal: AbortSignal;
81
+ }
82
+ interface ToolDefinition {
83
+ name: string;
84
+ description: string;
85
+ inputSchema: {
86
+ type: "object";
87
+ properties: Record<string, unknown>;
88
+ required?: string[];
89
+ };
90
+ readOnly: boolean;
91
+ handler: (input: Record<string, unknown>, ctx: ToolContext) => Promise<string>;
92
+ /** How to extract the permission specifier (e.g. path, command) from input. */
93
+ specifier?: (input: Record<string, unknown>) => string | undefined;
94
+ /**
95
+ * Optional approval-UI metadata (e.g. a unified diff preview of the pending
96
+ * mutation), passed to the sink via ApprovalRequest.detail. Shape: ApprovalDetail
97
+ * from @chantier/permissions. Called with the same input the handler will
98
+ * receive; a returned undefined simply omits the metadata. May be async
99
+ * (e.g. reading the file to build a diff). Sinks that ignore detail are unaffected.
100
+ */
101
+ askDetail?: (input: Record<string, unknown>, ctx: ToolContext) => ApprovalDetail | undefined | Promise<ApprovalDetail | undefined>;
102
+ }
103
+ type SessionHeader = {
104
+ type: "session";
105
+ id: string;
106
+ cwd: string;
107
+ provider: string;
108
+ model: string;
109
+ createdAt: string;
110
+ };
111
+ type SessionEntry = SessionHeader | {
112
+ type: "message";
113
+ message: Message;
114
+ } | CompactionEntry;
115
+ /**
116
+ * `view` folds the append-only log into the model-facing context: the summary
117
+ * message convention replaces everything before the last compaction's
118
+ * `firstKeptMessageIndex`. Optional so lightweight fakes (tests) remain valid;
119
+ * real stores from this module always implement it, and
120
+ * `sessionView(store.load(id))` is the equivalent free-function form.
121
+ */
122
+ interface SessionStore {
123
+ id: string;
124
+ dir: string;
125
+ append(entry: SessionEntry): Promise<void>;
126
+ load(id: string): Promise<SessionEntry[]>;
127
+ view?(id: string): Promise<Message[]>;
128
+ }
129
+ //#endregion
130
+ //#region src/model-adapter.d.ts
131
+ /**
132
+ * The provider seam. Core never imports a provider SDK; implementations live in
133
+ * `@chantier/providers` and translate into these events.
134
+ */
135
+ interface ModelAdapter {
136
+ /** `messages` always starts with a SystemMessage (see core context assembly). */
137
+ stream(messages: Message[], tools: ToolDefinition[], signal: AbortSignal): AsyncIterable<ModelEvent>;
138
+ }
139
+ //#endregion
140
+ //#region src/agent.d.ts
141
+ type AgentEvent = {
142
+ type: "text-delta";
143
+ text: string;
144
+ } | {
145
+ type: "tool-result";
146
+ toolCallId: string;
147
+ toolName: string;
148
+ args: Record<string, unknown>;
149
+ content: string;
150
+ } | {
151
+ type: "compaction";
152
+ tokensBefore: number;
153
+ tokensAfter: number;
154
+ summaryChars: number;
155
+ } | {
156
+ type: "result";
157
+ text: string;
158
+ usage?: Usage;
159
+ turns: number;
160
+ stopReason: "end_turn" | "max_turns";
161
+ };
162
+ interface CompactionOptions {
163
+ /** Compaction on/off; defaults to true when `contextWindow` is set. */
164
+ enabled?: boolean;
165
+ /** Headroom kept for the model's answer; see `shouldCompact`. */
166
+ reserve?: number;
167
+ /** Verbatim tail kept after compaction, in estimated tokens. */
168
+ keepRecent?: number;
169
+ }
170
+ interface RunAgentOptions {
171
+ adapter: ModelAdapter;
172
+ tools: ToolDefinition[];
173
+ permission: PermissionEngine;
174
+ sink: ApprovalSink;
175
+ session: SessionStore;
176
+ /** Project cwd; every tool handler resolves paths against it. */
177
+ cwd: string;
178
+ system: string;
179
+ /** Prior conversation (e.g. replayed on --continue); the system prompt is prepended once. */
180
+ messages?: Message[];
181
+ maxTurns?: number;
182
+ signal: AbortSignal;
183
+ /**
184
+ * Model context window in tokens. Undefined = compaction disabled; callers
185
+ * opt in by declaring the window they are targeting.
186
+ */
187
+ contextWindow?: number;
188
+ compaction?: CompactionOptions;
189
+ }
190
+ /**
191
+ * The boring loop: stream → accumulate → execute tools (denials feed back as
192
+ * results, never as error branching) → repeat until a turn has zero tool calls.
193
+ * Consecutive readOnly tool calls run concurrently, in the order the model
194
+ * emitted them; mutating calls run sequentially.
195
+ *
196
+ * Context management: when `contextWindow` is set, compaction is checked
197
+ * between turns (after tool results are appended, before the next stream) and
198
+ * once as a reactive fallback when a stream fails with a context-overflow
199
+ * error. Compaction replaces the in-memory context with summary + kept tail,
200
+ * appends one `compaction` entry plus the summary as a regular user message to
201
+ * the session log (the log stays append-only; `load()` is untouched), and
202
+ * preserves assistant tool-call / tool-result pairing.
203
+ */
204
+ export declare function runAgent(opts: RunAgentOptions): AsyncGenerator<AgentEvent>;
205
+ //#endregion
206
+ //#region src/compaction.d.ts
207
+ /**
208
+ * Rough token estimate: ~4 characters per token for English/code text. Used
209
+ * when a provider does not report usage; deliberately cheap and deterministic
210
+ * rather than accurate — the reserve/keepRecent margins absorb the error.
211
+ */
212
+ export declare function estimateTokens(text: string): number;
213
+ /** Compaction trigger margins, per the design dossier (§9.1). */
214
+ export declare const DEFAULT_COMPACTION_RESERVE = 16000;
215
+ export declare const DEFAULT_COMPACTION_KEEP_RECENT = 20000;
216
+ /**
217
+ * Marker prefixing the summary message injected after a compaction. The same
218
+ * text is logged as a regular `user` message and re-built in memory, so the
219
+ * agent's live context and `view()` agree byte-for-byte.
220
+ */
221
+ export declare const COMPACTED_MARKER = "[context compacted — earlier conversation summarized]\n\n";
222
+ /** Fires when the next call would leave less than reserve + keepRecent of headroom. */
223
+ export declare function shouldCompact(opts: {
224
+ tokensUsed: number;
225
+ window: number;
226
+ reserve?: number;
227
+ keepRecent?: number;
228
+ }): boolean;
229
+ /** Best-effort context-overflow detection: provider error texts vary widely. */
230
+ export declare function looksLikeContextOverflow(error: unknown): boolean;
231
+ /** Sums `estimateTokens` over messages; the chars/4 fallback for missing usage. */
232
+ export declare function estimateMessageTokens(messages: readonly Message[]): number;
233
+ /**
234
+ * Compact textual rendering of a conversation for the summarizer model.
235
+ * Deterministic and role-tagged; tool-call args are inlined as JSON.
236
+ */
237
+ export declare function serializeConversation(messages: readonly Message[]): string;
238
+ export declare const COMPACT_PROMPT = "You are summarizing a coding-agent conversation so the work can continue in a fresh context window. Write the summary as plain text with exactly these sections:\n\nACTIVE TASK\n- The user's current request and intent, in the user's own terms. Quote the latest instruction's original wording when it is precise.\n\nDECISIONS AND CONSTRAINTS\n- Key decisions made (with the why), invariants, preferences, and constraints that must not be violated.\n\nFILES AND CODE\n- Files read or modified, one line each: path, what was done or learned, and why it matters.\n\nUNRESOLVED THREADS\n- Open questions, pending next steps, and anything attempted that did not work (with the error).\n\nTO REMEMBER\n- Anything the user explicitly asked to remember, verbatim.\n\nRules: preserve exact identifiers, paths, commands, and error messages; do not invent facts; output nothing before or after the sections; be concise but complete — this summary replaces the conversation it summarizes.";
239
+ interface CompactionResult {
240
+ /** Summarizer output for everything before the kept span. Empty = nothing to summarize. */
241
+ summary: string;
242
+ /** Tail of the input conversation that stays verbatim; never contains a SystemMessage. */
243
+ keptMessages: Message[];
244
+ /**
245
+ * Index into the `messages` input of the first kept message, so callers can
246
+ * align their own per-message metadata (keptMessages === messages.slice(keptStart)).
247
+ */
248
+ keptStart: number;
249
+ tokensBefore: number;
250
+ /** Estimated context size after re-injection (summary message + kept messages). */
251
+ estimatedAfter: number;
252
+ }
253
+ interface CompactConversationOptions {
254
+ adapter: ModelAdapter;
255
+ /**
256
+ * Full conversation, starting with the SystemMessage (the caller rebuilds it
257
+ * from the system prompt). The system message is never summarized and never
258
+ * returned in keptMessages.
259
+ */
260
+ messages: Message[];
261
+ keepRecent: number;
262
+ window: number;
263
+ reserve: number;
264
+ signal?: AbortSignal;
265
+ }
266
+ /**
267
+ * Summarizes everything except the most recent `keepRecent` tokens-worth of
268
+ * messages and returns the pieces for re-injection. Pure with respect to the
269
+ * session: it never mutates any store; callers decide what to append.
270
+ */
271
+ export declare function compactConversation(opts: CompactConversationOptions): Promise<CompactionResult>;
272
+ /** Builds the regular user message that carries the summary after compaction. */
273
+ export declare function compactedSummaryMessage(summary: string): UserMessage;
274
+ //#endregion
275
+ //#region src/context.d.ts
276
+ /** Builds the system prompt: identity + tool rules + every AGENTS.md from cwd up to the git root. */
277
+ export declare function buildSystemPrompt(cwd: string, tools: ToolDefinition[]): Promise<string>;
278
+ //#endregion
279
+ //#region src/session.d.ts
280
+ export declare const DEFAULT_SESSIONS_ROOT: string;
281
+ export declare function sessionsDirFor(sessionsRoot: string, cwd: string): string;
282
+ interface CreateSessionOptions {
283
+ cwd: string;
284
+ provider: string;
285
+ model: string;
286
+ sessionsRoot?: string;
287
+ id?: string;
288
+ }
289
+ interface ResumeSessionOptions {
290
+ cwd: string;
291
+ sessionsRoot?: string;
292
+ id: string;
293
+ }
294
+ /** Creates a new session file and writes the header line immediately. */
295
+ export declare function createSessionStore(opts: CreateSessionOptions): Promise<SessionStore>;
296
+ /** Reattaches to an existing session file (--continue); no new header line. */
297
+ export declare function resumeSessionStore(opts: ResumeSessionOptions): Promise<SessionStore>;
298
+ /**
299
+ * Folds the append-only log into the model-facing context: everything with a
300
+ * message ordinal below the last compaction's `firstKeptMessageIndex` is
301
+ * replaced by that compaction's summary message (a regular logged `user`
302
+ * message — see `CompactionEntry`). Logs without compaction entries view as
303
+ * the plain message sequence, so old sessions and `--continue` stay safe.
304
+ *
305
+ * Two subtleties make this more than a filter:
306
+ * - The boundary is applied to the WHOLE log, not just entries after the
307
+ * compaction marker: in the append order the kept tail is logged before the
308
+ * compaction entry, so a forward pass alone would leak pre-boundary
309
+ * messages into the view.
310
+ * - The summary message is hoisted to the front of the kept span. It is logged
311
+ * after the boundary messages (append-only), but the model-facing context is
312
+ * system + summary + kept tail — the live agent list and `view()` must
313
+ * agree, and hoisting restores that order without touching the log.
314
+ */
315
+ export declare function sessionView(entries: readonly SessionEntry[]): Message[];
316
+ /**
317
+ * True log ordinals for `given` messages when they form a suffix of the log's
318
+ * view — the shape every flow this harness builds produces (`sessionView()`
319
+ * output plus messages appended after the last load, which land at the end).
320
+ * Returns null when the suffix match fails (e.g. a full replay of a compacted
321
+ * log, whose pre-boundary messages are absent from the view); callers fall
322
+ * back to the naive suffix-offset heuristic in that case. System messages are
323
+ * never logged; their slots stay null.
324
+ */
325
+ export declare function alignedMessageOrdinals(entries: readonly SessionEntry[], given: readonly Message[]): Array<number | null> | null;
326
+ interface CompactionOutcome {
327
+ tokensBefore: number;
328
+ tokensAfter: number;
329
+ summaryChars: number;
330
+ }
331
+ interface CompactSessionOptions {
332
+ store: SessionStore;
333
+ adapter: ModelAdapter;
334
+ /** The system prompt; never summarized, stays first in the next context. */
335
+ system: string;
336
+ contextWindow: number;
337
+ reserve?: number;
338
+ keepRecent?: number;
339
+ signal?: AbortSignal;
340
+ }
341
+ /**
342
+ * Caller-driven compaction between agent runs: estimates the current view and,
343
+ * when `shouldCompact` fires, summarizes everything but the recent tail and
344
+ * appends one compaction entry plus the summary message through the same
345
+ * append-only `SessionStore.append` seam the agent uses. Returns null when the
346
+ * estimate is under the threshold or the summarizer produced nothing (nothing
347
+ * to do); the caller re-derives the context with `sessionView(store.load())`.
348
+ */
349
+ export declare function compactSession(opts: CompactSessionOptions): Promise<CompactionOutcome | null>;
350
+ /** `--continue`: newest session file (by mtime) in this cwd's session dir. */
351
+ export declare function loadNewestSessionId(cwd: string, sessionsRoot?: string): Promise<string | null>;
352
+ //#endregion
353
+ export type { AgentEvent, AssistantMessage, CompactConversationOptions, CompactSessionOptions, CompactionEntry, CompactionOptions, CompactionOutcome, CompactionResult, CreateSessionOptions, Message, ModelAdapter, ModelEvent, ResumeSessionOptions, RunAgentOptions, SessionEntry, SessionHeader, SessionStore, StopReason, SystemMessage, TextBlock, ToolCallBlock, ToolContext, ToolDefinition, ToolResultMessage, Usage, UserMessage };
package/dist/index.mjs ADDED
@@ -0,0 +1,729 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { appendFile, mkdir, readFile, readdir, stat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ //#region src/compaction.ts
6
+ /**
7
+ * Rough token estimate: ~4 characters per token for English/code text. Used
8
+ * when a provider does not report usage; deliberately cheap and deterministic
9
+ * rather than accurate — the reserve/keepRecent margins absorb the error.
10
+ */
11
+ function estimateTokens(text) {
12
+ return Math.ceil(text.length / 4);
13
+ }
14
+ /** Compaction trigger margins, per the design dossier (§9.1). */
15
+ const DEFAULT_COMPACTION_RESERVE = 16e3;
16
+ const DEFAULT_COMPACTION_KEEP_RECENT = 2e4;
17
+ /**
18
+ * Marker prefixing the summary message injected after a compaction. The same
19
+ * text is logged as a regular `user` message and re-built in memory, so the
20
+ * agent's live context and `view()` agree byte-for-byte.
21
+ */
22
+ const COMPACTED_MARKER = "[context compacted — earlier conversation summarized]\n\n";
23
+ /** Fires when the next call would leave less than reserve + keepRecent of headroom. */
24
+ function shouldCompact(opts) {
25
+ const reserve = opts.reserve ?? 16e3;
26
+ const keepRecent = opts.keepRecent ?? 2e4;
27
+ return opts.tokensUsed >= opts.window - reserve - keepRecent;
28
+ }
29
+ /** Best-effort context-overflow detection: provider error texts vary widely. */
30
+ function looksLikeContextOverflow(error) {
31
+ const text = error instanceof Error ? error.message : String(error);
32
+ return /context (?:length|window)|maximum context length|prompt is too long|input length exceeds|too many tokens|token limit|reduce the (?:length|number of tokens)/i.test(text);
33
+ }
34
+ /** Flat text rendering of a message for the estimator fallback. */
35
+ function messageText(message) {
36
+ if (message.role === "system") return message.content;
37
+ if (message.role === "user") return message.content.map((block) => block.text).join("");
38
+ if (message.role === "assistant") return message.content.map((block) => {
39
+ if (block.type === "text") return block.text;
40
+ return `${block.name}${block.id}${JSON.stringify(block.args)}`;
41
+ }).join("");
42
+ return message.content;
43
+ }
44
+ /** Sums `estimateTokens` over messages; the chars/4 fallback for missing usage. */
45
+ function estimateMessageTokens(messages) {
46
+ let tokens = 0;
47
+ for (const message of messages) tokens += estimateTokens(messageText(message));
48
+ return tokens;
49
+ }
50
+ /**
51
+ * Compact textual rendering of a conversation for the summarizer model.
52
+ * Deterministic and role-tagged; tool-call args are inlined as JSON.
53
+ */
54
+ function serializeConversation(messages) {
55
+ const parts = [];
56
+ for (const message of messages) if (message.role === "system") parts.push(`[system]\n${message.content}`);
57
+ else if (message.role === "user") parts.push(`[user]\n${message.content.map((block) => block.text).join("\n")}`);
58
+ else if (message.role === "assistant") {
59
+ const blocks = message.content.map((block) => block.type === "text" ? block.text : `tool-call ${block.name} id=${block.id}: ${JSON.stringify(block.args)}`);
60
+ parts.push(`[assistant]\n${blocks.join("\n")}`);
61
+ } else parts.push(`[tool-result ${message.toolCallId} (${message.toolName})]\n${message.content}`);
62
+ return parts.join("\n\n");
63
+ }
64
+ const COMPACT_PROMPT = `You are summarizing a coding-agent conversation so the work can continue in a fresh context window. Write the summary as plain text with exactly these sections:
65
+
66
+ ACTIVE TASK
67
+ - The user's current request and intent, in the user's own terms. Quote the latest instruction's original wording when it is precise.
68
+
69
+ DECISIONS AND CONSTRAINTS
70
+ - Key decisions made (with the why), invariants, preferences, and constraints that must not be violated.
71
+
72
+ FILES AND CODE
73
+ - Files read or modified, one line each: path, what was done or learned, and why it matters.
74
+
75
+ UNRESOLVED THREADS
76
+ - Open questions, pending next steps, and anything attempted that did not work (with the error).
77
+
78
+ TO REMEMBER
79
+ - Anything the user explicitly asked to remember, verbatim.
80
+
81
+ Rules: preserve exact identifiers, paths, commands, and error messages; do not invent facts; output nothing before or after the sections; be concise but complete — this summary replaces the conversation it summarizes.`;
82
+ /** Old tool results are the biggest context sinks; prune them for summarization. */
83
+ const TOOL_RESULT_PRUNE_CHARS = 800;
84
+ function pruneToolResult(result) {
85
+ if (result.content.length <= TOOL_RESULT_PRUNE_CHARS) return result;
86
+ return {
87
+ ...result,
88
+ content: `${result.content.slice(0, TOOL_RESULT_PRUNE_CHARS)}\n[...tool result truncated, ${result.content.length} chars total]`
89
+ };
90
+ }
91
+ /** Prunes oversized tool-result payloads from the span that gets summarized. */
92
+ function pruneOldToolResults(messages) {
93
+ return messages.map((message) => message.role === "tool-result" ? pruneToolResult(message) : message);
94
+ }
95
+ function findAssistantWithCall(messages, from, toolCallId) {
96
+ for (let i = from; i >= 1; i -= 1) {
97
+ const message = messages[i];
98
+ if (message?.role === "assistant" && message.content.some((block) => block.type === "tool-call" && block.id === toolCallId)) return i;
99
+ }
100
+ return -1;
101
+ }
102
+ /**
103
+ * Walks the boundary back until every kept tool-result has its assistant
104
+ * tool-call inside the kept span (and therefore every kept call has its
105
+ * result). Splitting a pair would 400 on strict APIs.
106
+ */
107
+ function safeBoundary(messages, start) {
108
+ let boundary = start;
109
+ for (;;) {
110
+ const seen = /* @__PURE__ */ new Set();
111
+ let moved = false;
112
+ for (let i = boundary; i < messages.length; i += 1) {
113
+ const message = messages[i];
114
+ if (message === void 0) break;
115
+ if (message.role === "assistant") {
116
+ for (const block of message.content) if (block.type === "tool-call") seen.add(block.id);
117
+ } else if (message.role === "tool-result" && !seen.has(message.toolCallId)) {
118
+ const assistantIndex = findAssistantWithCall(messages, i - 1, message.toolCallId);
119
+ if (assistantIndex < 1) return 1;
120
+ boundary = assistantIndex;
121
+ moved = true;
122
+ break;
123
+ }
124
+ }
125
+ if (!moved) return boundary;
126
+ }
127
+ }
128
+ /**
129
+ * Summarizes everything except the most recent `keepRecent` tokens-worth of
130
+ * messages and returns the pieces for re-injection. Pure with respect to the
131
+ * session: it never mutates any store; callers decide what to append.
132
+ */
133
+ async function compactConversation(opts) {
134
+ const { adapter, messages, keepRecent, window, reserve } = opts;
135
+ if (messages[0]?.role !== "system") throw new Error("compactConversation: messages must start with the SystemMessage");
136
+ if (window - reserve - keepRecent <= 0) throw new RangeError(`compaction cannot fit: window ${window} must exceed reserve ${reserve} + keepRecent ${keepRecent}`);
137
+ let acc = 0;
138
+ let boundary = messages.length;
139
+ while (boundary > 1 && acc < keepRecent) {
140
+ boundary -= 1;
141
+ const message = messages[boundary];
142
+ if (message !== void 0) acc += estimateTokens(messageText(message));
143
+ }
144
+ const keptStart = safeBoundary(messages, boundary);
145
+ const summarizedSpan = messages.slice(1, keptStart);
146
+ const keptMessages = messages.slice(keptStart);
147
+ const tokensBefore = estimateMessageTokens(messages);
148
+ const keptTokens = estimateMessageTokens(keptMessages);
149
+ if (summarizedSpan.length === 0) return {
150
+ summary: "",
151
+ keptMessages,
152
+ keptStart,
153
+ tokensBefore,
154
+ estimatedAfter: keptTokens
155
+ };
156
+ const summary = await summarizeWithAdapter(adapter, serializeConversation(pruneOldToolResults(summarizedSpan)), opts.signal);
157
+ return {
158
+ summary,
159
+ keptMessages,
160
+ keptStart,
161
+ tokensBefore,
162
+ estimatedAfter: estimateTokens(COMPACTED_MARKER + summary) + keptTokens
163
+ };
164
+ }
165
+ const EMPTY_SIGNAL = new AbortController().signal;
166
+ async function summarizeWithAdapter(adapter, conversationText, signal) {
167
+ const messages = [{
168
+ role: "system",
169
+ content: "You summarize coding-agent conversations. Output only the requested summary sections as plain text."
170
+ }, {
171
+ role: "user",
172
+ content: [{
173
+ type: "text",
174
+ text: `${COMPACT_PROMPT}\n\n<conversation>\n${conversationText}\n</conversation>`
175
+ }]
176
+ }];
177
+ let text = "";
178
+ for await (const event of adapter.stream(messages, [], signal ?? EMPTY_SIGNAL)) if (event.type === "text-delta") text += event.text;
179
+ return text.trim();
180
+ }
181
+ /** Builds the regular user message that carries the summary after compaction. */
182
+ function compactedSummaryMessage(summary) {
183
+ return {
184
+ role: "user",
185
+ content: [{
186
+ type: "text",
187
+ text: `${COMPACTED_MARKER}${summary}`
188
+ }]
189
+ };
190
+ }
191
+ //#endregion
192
+ //#region src/session.ts
193
+ const DEFAULT_SESSIONS_ROOT = path.join(homedir(), ".chantier", "sessions");
194
+ function sessionsDirFor(sessionsRoot, cwd) {
195
+ const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 12);
196
+ return path.join(sessionsRoot, hash);
197
+ }
198
+ function newSessionId() {
199
+ return `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${randomBytes(4).toString("hex")}`;
200
+ }
201
+ function buildStore(file, dir, id, selfId) {
202
+ const readEntries = async (loadId) => {
203
+ const target = loadId === selfId ? file : path.join(dir, `${loadId}.jsonl`);
204
+ return (await readFile(target, "utf8")).split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line));
205
+ };
206
+ return {
207
+ id,
208
+ dir,
209
+ async append(entry) {
210
+ await appendFile(file, `${JSON.stringify(entry)}\n`);
211
+ },
212
+ load: readEntries,
213
+ async view(viewId) {
214
+ return sessionView(await readEntries(viewId));
215
+ }
216
+ };
217
+ }
218
+ /** Creates a new session file and writes the header line immediately. */
219
+ async function createSessionStore(opts) {
220
+ const dir = sessionsDirFor(opts.sessionsRoot ?? DEFAULT_SESSIONS_ROOT, opts.cwd);
221
+ await mkdir(dir, { recursive: true });
222
+ const id = opts.id ?? newSessionId();
223
+ const file = path.join(dir, `${id}.jsonl`);
224
+ const header = {
225
+ type: "session",
226
+ id,
227
+ cwd: opts.cwd,
228
+ provider: opts.provider,
229
+ model: opts.model,
230
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
231
+ };
232
+ await appendFile(file, `${JSON.stringify(header)}\n`);
233
+ return buildStore(file, dir, id, id);
234
+ }
235
+ /** Reattaches to an existing session file (--continue); no new header line. */
236
+ async function resumeSessionStore(opts) {
237
+ const dir = sessionsDirFor(opts.sessionsRoot ?? DEFAULT_SESSIONS_ROOT, opts.cwd);
238
+ return buildStore(path.join(dir, `${opts.id}.jsonl`), dir, opts.id, opts.id);
239
+ }
240
+ /**
241
+ * Folds the append-only log into the model-facing context: everything with a
242
+ * message ordinal below the last compaction's `firstKeptMessageIndex` is
243
+ * replaced by that compaction's summary message (a regular logged `user`
244
+ * message — see `CompactionEntry`). Logs without compaction entries view as
245
+ * the plain message sequence, so old sessions and `--continue` stay safe.
246
+ *
247
+ * Two subtleties make this more than a filter:
248
+ * - The boundary is applied to the WHOLE log, not just entries after the
249
+ * compaction marker: in the append order the kept tail is logged before the
250
+ * compaction entry, so a forward pass alone would leak pre-boundary
251
+ * messages into the view.
252
+ * - The summary message is hoisted to the front of the kept span. It is logged
253
+ * after the boundary messages (append-only), but the model-facing context is
254
+ * system + summary + kept tail — the live agent list and `view()` must
255
+ * agree, and hoisting restores that order without touching the log.
256
+ */
257
+ function sessionView(entries) {
258
+ return viewWithOrdinals(entries).map((viewed) => viewed.message);
259
+ }
260
+ /** `sessionView` with ordinals, shared by the alignment and compaction helpers. */
261
+ function viewWithOrdinals(entries) {
262
+ let keptFrom = 0;
263
+ for (const entry of entries) if (entry.type === "compaction") keptFrom = Math.max(keptFrom, entry.firstKeptMessageIndex);
264
+ let hoisted;
265
+ let hoistedOrdinal = -1;
266
+ let lastCompaction = -1;
267
+ for (let i = 0; i < entries.length; i += 1) if (entries[i]?.type === "compaction") lastCompaction = i;
268
+ if (lastCompaction >= 0) {
269
+ const next = entries[lastCompaction + 1];
270
+ if (next?.type === "message" && next.message.role === "user" && next.message.content[0]?.type === "text" && next.message.content[0].text.startsWith("[context compacted — earlier conversation summarized]\n\n")) hoisted = next.message;
271
+ }
272
+ const view = [];
273
+ let ordinal = 0;
274
+ for (const entry of entries) {
275
+ if (entry.type !== "message") continue;
276
+ if (entry.message === hoisted) hoistedOrdinal = ordinal;
277
+ else if (ordinal >= keptFrom) view.push({
278
+ message: entry.message,
279
+ ordinal
280
+ });
281
+ ordinal += 1;
282
+ }
283
+ if (hoisted !== void 0) view.unshift({
284
+ message: hoisted,
285
+ ordinal: hoistedOrdinal
286
+ });
287
+ return view;
288
+ }
289
+ /**
290
+ * True log ordinals for `given` messages when they form a suffix of the log's
291
+ * view — the shape every flow this harness builds produces (`sessionView()`
292
+ * output plus messages appended after the last load, which land at the end).
293
+ * Returns null when the suffix match fails (e.g. a full replay of a compacted
294
+ * log, whose pre-boundary messages are absent from the view); callers fall
295
+ * back to the naive suffix-offset heuristic in that case. System messages are
296
+ * never logged; their slots stay null.
297
+ */
298
+ function alignedMessageOrdinals(entries, given) {
299
+ const view = viewWithOrdinals(entries);
300
+ const result = new Array(given.length).fill(null);
301
+ let cursor = view.length - 1;
302
+ for (let i = given.length - 1; i >= 0; i -= 1) {
303
+ const message = given[i];
304
+ if (message === void 0) return null;
305
+ if (message.role === "system") continue;
306
+ let viewed = view[cursor];
307
+ while (cursor >= 0 && viewed !== void 0 && JSON.stringify(viewed.message) !== JSON.stringify(message)) {
308
+ cursor -= 1;
309
+ viewed = view[cursor];
310
+ }
311
+ if (viewed === void 0) return null;
312
+ result[i] = viewed.ordinal;
313
+ cursor -= 1;
314
+ }
315
+ return result;
316
+ }
317
+ /**
318
+ * Caller-driven compaction between agent runs: estimates the current view and,
319
+ * when `shouldCompact` fires, summarizes everything but the recent tail and
320
+ * appends one compaction entry plus the summary message through the same
321
+ * append-only `SessionStore.append` seam the agent uses. Returns null when the
322
+ * estimate is under the threshold or the summarizer produced nothing (nothing
323
+ * to do); the caller re-derives the context with `sessionView(store.load())`.
324
+ */
325
+ async function compactSession(opts) {
326
+ const reserve = opts.reserve ?? 16e3;
327
+ const keepRecent = opts.keepRecent ?? 2e4;
328
+ const entries = await opts.store.load(opts.store.id);
329
+ const view = viewWithOrdinals(entries);
330
+ const messages = [{
331
+ role: "system",
332
+ content: opts.system
333
+ }, ...view.map((viewed) => viewed.message)];
334
+ if (!shouldCompact({
335
+ tokensUsed: estimateMessageTokens(messages),
336
+ window: opts.contextWindow,
337
+ reserve,
338
+ keepRecent
339
+ })) return null;
340
+ const kept = await compactConversation({
341
+ adapter: opts.adapter,
342
+ messages,
343
+ keepRecent,
344
+ window: opts.contextWindow,
345
+ reserve,
346
+ signal: opts.signal
347
+ });
348
+ if (kept.summary.length === 0) return null;
349
+ const loggedCount = entries.reduce((count, entry) => entry.type === "message" ? count + 1 : count, 0);
350
+ const firstKept = view[kept.keptStart - 1];
351
+ const entry = {
352
+ type: "compaction",
353
+ summary: kept.summary,
354
+ firstKeptMessageIndex: firstKept?.ordinal ?? loggedCount,
355
+ tokensBefore: kept.tokensBefore,
356
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
357
+ };
358
+ await opts.store.append(entry);
359
+ await opts.store.append({
360
+ type: "message",
361
+ message: compactedSummaryMessage(kept.summary)
362
+ });
363
+ return {
364
+ tokensBefore: kept.tokensBefore,
365
+ tokensAfter: kept.estimatedAfter,
366
+ summaryChars: kept.summary.length
367
+ };
368
+ }
369
+ /** `--continue`: newest session file (by mtime) in this cwd's session dir. */
370
+ async function loadNewestSessionId(cwd, sessionsRoot = DEFAULT_SESSIONS_ROOT) {
371
+ const dir = sessionsDirFor(sessionsRoot, cwd);
372
+ let files;
373
+ try {
374
+ files = await readdir(dir);
375
+ } catch {
376
+ return null;
377
+ }
378
+ const jsonl = files.filter((name) => name.endsWith(".jsonl"));
379
+ if (jsonl.length === 0) return null;
380
+ let newest = null;
381
+ for (const name of jsonl) {
382
+ const info = await stat(path.join(dir, name));
383
+ if (newest === null || info.mtimeMs > newest.mtimeMs || info.mtimeMs === newest.mtimeMs && name > newest.name) newest = {
384
+ name,
385
+ mtimeMs: info.mtimeMs
386
+ };
387
+ }
388
+ return newest ? newest.name.replace(/\.jsonl$/, "") : null;
389
+ }
390
+ //#endregion
391
+ //#region src/agent.ts
392
+ const HEADLESS_HINT = "rerun with --yolo or add an allow rule to .chantier/settings.json";
393
+ /**
394
+ * The boring loop: stream → accumulate → execute tools (denials feed back as
395
+ * results, never as error branching) → repeat until a turn has zero tool calls.
396
+ * Consecutive readOnly tool calls run concurrently, in the order the model
397
+ * emitted them; mutating calls run sequentially.
398
+ *
399
+ * Context management: when `contextWindow` is set, compaction is checked
400
+ * between turns (after tool results are appended, before the next stream) and
401
+ * once as a reactive fallback when a stream fails with a context-overflow
402
+ * error. Compaction replaces the in-memory context with summary + kept tail,
403
+ * appends one `compaction` entry plus the summary as a regular user message to
404
+ * the session log (the log stays append-only; `load()` is untouched), and
405
+ * preserves assistant tool-call / tool-result pairing.
406
+ */
407
+ async function* runAgent(opts) {
408
+ const maxTurns = opts.maxTurns ?? 50;
409
+ const byName = new Map(opts.tools.map((tool) => [tool.name, tool]));
410
+ const system = {
411
+ role: "system",
412
+ content: opts.system
413
+ };
414
+ const messages = [system, ...opts.messages ?? []];
415
+ const available = opts.tools.filter((tool) => !opts.permission.isRemoved(tool.name));
416
+ const contextWindow = opts.contextWindow;
417
+ const compactionEnabled = contextWindow !== void 0 && (opts.compaction?.enabled ?? true);
418
+ const reserve = opts.compaction?.reserve ?? 16e3;
419
+ const keepRecent = opts.compaction?.keepRecent ?? 2e4;
420
+ /**
421
+ * Ordinal of each message within the session log's message-entry sequence
422
+ * (the header and compaction entries are not counted; null = never logged,
423
+ * i.e. the system message). Maintained even when compaction is off — it is
424
+ * then garbage but never read.
425
+ */
426
+ const ordinals = [null];
427
+ let nextOrdinal = 0;
428
+ if (compactionEnabled) {
429
+ const priorEntries = await opts.session.load(opts.session.id);
430
+ const logged = priorEntries.reduce((count, entry) => entry.type === "message" ? count + 1 : count, 0);
431
+ const given = opts.messages ?? [];
432
+ const aligned = alignedMessageOrdinals(priorEntries, given);
433
+ if (aligned !== null) ordinals.push(...aligned);
434
+ else {
435
+ const offset = Math.max(0, logged - given.filter((message) => message.role !== "system").length);
436
+ let seen = 0;
437
+ for (const message of given) {
438
+ if (message.role === "system") {
439
+ ordinals.push(null);
440
+ continue;
441
+ }
442
+ ordinals.push(offset + seen);
443
+ seen += 1;
444
+ }
445
+ }
446
+ nextOrdinal = logged;
447
+ } else ordinals.push(...(opts.messages ?? []).map(() => null));
448
+ let usage;
449
+ let turns = 0;
450
+ let lastText = "";
451
+ const appendMessage = async (message) => {
452
+ messages.push(message);
453
+ ordinals.push(nextOrdinal);
454
+ await opts.session.append({
455
+ type: "message",
456
+ message
457
+ });
458
+ };
459
+ /**
460
+ * Runs one compaction against the current context and rewires the live
461
+ * message list (system + summary message + kept tail). Never throws: a
462
+ * failed compaction keeps the run going uncompacted. Appends the compaction
463
+ * entry (which carries the summary) and the summary message to the log.
464
+ */
465
+ const runCompaction = async () => {
466
+ if (!compactionEnabled || contextWindow === void 0) return null;
467
+ let kept;
468
+ try {
469
+ kept = await compactConversation({
470
+ adapter: opts.adapter,
471
+ messages,
472
+ keepRecent,
473
+ window: contextWindow,
474
+ reserve,
475
+ signal: opts.signal
476
+ });
477
+ } catch {
478
+ return null;
479
+ }
480
+ if (kept.summary.length === 0) return null;
481
+ const keptOrdinals = ordinals.slice(kept.keptStart);
482
+ const entry = {
483
+ type: "compaction",
484
+ summary: kept.summary,
485
+ firstKeptMessageIndex: keptOrdinals[0] ?? nextOrdinal,
486
+ tokensBefore: kept.tokensBefore,
487
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
488
+ };
489
+ await opts.session.append(entry);
490
+ const summaryMessage = compactedSummaryMessage(kept.summary);
491
+ await opts.session.append({
492
+ type: "message",
493
+ message: summaryMessage
494
+ });
495
+ messages.splice(0, messages.length, system, summaryMessage, ...kept.keptMessages);
496
+ ordinals.splice(0, ordinals.length, null, nextOrdinal, ...keptOrdinals);
497
+ nextOrdinal += 1;
498
+ return {
499
+ tokensBefore: kept.tokensBefore,
500
+ tokensAfter: kept.estimatedAfter,
501
+ summaryChars: kept.summary.length
502
+ };
503
+ };
504
+ while (turns < maxTurns) {
505
+ turns += 1;
506
+ let turnText = "";
507
+ const toolCalls = [];
508
+ let turnStart = messages.length;
509
+ let overflowError;
510
+ for (;;) {
511
+ turnText = "";
512
+ toolCalls.length = 0;
513
+ try {
514
+ for await (const event of opts.adapter.stream(messages, available, opts.signal)) if (event.type === "text-delta") {
515
+ turnText += event.text;
516
+ yield event;
517
+ } else if (event.type === "tool-call") toolCalls.push({
518
+ type: "tool-call",
519
+ id: event.id,
520
+ name: event.name,
521
+ args: event.args
522
+ });
523
+ else usage = event.usage;
524
+ break;
525
+ } catch (error) {
526
+ if (!looksLikeContextOverflow(error)) throw error;
527
+ if (overflowError !== void 0) throw overflowError;
528
+ overflowError = error;
529
+ const outcome = await runCompaction();
530
+ if (outcome === null) throw error;
531
+ turnStart = messages.length;
532
+ yield {
533
+ type: "compaction",
534
+ ...outcome
535
+ };
536
+ }
537
+ }
538
+ await appendMessage({
539
+ role: "assistant",
540
+ content: [...turnText.length > 0 ? [{
541
+ type: "text",
542
+ text: turnText
543
+ }] : [], ...toolCalls]
544
+ });
545
+ if (toolCalls.length === 0) {
546
+ yield {
547
+ type: "result",
548
+ text: turnText,
549
+ usage,
550
+ turns,
551
+ stopReason: "end_turn"
552
+ };
553
+ return;
554
+ }
555
+ lastText = turnText;
556
+ for (const group of consecutiveReadOnlyGroups(toolCalls, byName)) {
557
+ const results = await Promise.all(group.map((call) => executeCall(call, opts, byName)));
558
+ for (const [index, result] of results.entries()) {
559
+ await appendMessage(result);
560
+ yield {
561
+ type: "tool-result",
562
+ toolCallId: result.toolCallId,
563
+ toolName: result.toolName,
564
+ args: group[index]?.args ?? {},
565
+ content: result.content
566
+ };
567
+ }
568
+ }
569
+ if (compactionEnabled && contextWindow !== void 0) {
570
+ const turnDelta = messages.slice(turnStart);
571
+ if (shouldCompact({
572
+ tokensUsed: usage ? usage.inputTokens + usage.outputTokens + estimateMessageTokens(turnDelta) : estimateMessageTokens(messages),
573
+ window: contextWindow,
574
+ reserve,
575
+ keepRecent
576
+ })) {
577
+ const outcome = await runCompaction();
578
+ if (outcome !== null) yield {
579
+ type: "compaction",
580
+ ...outcome
581
+ };
582
+ }
583
+ }
584
+ }
585
+ yield {
586
+ type: "result",
587
+ text: lastText,
588
+ usage,
589
+ turns: maxTurns,
590
+ stopReason: "max_turns"
591
+ };
592
+ }
593
+ /** Groups consecutive readOnly calls; the first mutating call breaks the run. */
594
+ function* consecutiveReadOnlyGroups(calls, byName) {
595
+ let group = [];
596
+ for (const call of calls) {
597
+ if (byName.get(call.name)?.readOnly === true) {
598
+ group.push(call);
599
+ continue;
600
+ }
601
+ if (group.length > 0) {
602
+ yield group;
603
+ group = [];
604
+ }
605
+ yield [call];
606
+ }
607
+ if (group.length > 0) yield group;
608
+ }
609
+ async function executeCall(call, opts, byName) {
610
+ const base = {
611
+ role: "tool-result",
612
+ toolCallId: call.id,
613
+ toolName: call.name
614
+ };
615
+ const tool = byName.get(call.name);
616
+ if (tool === void 0) return {
617
+ ...base,
618
+ content: `Permission denied: tool "${call.name}" is not available. It was removed by a deny rule or does not exist. Use one of the listed tools.`
619
+ };
620
+ const ctx = {
621
+ cwd: opts.cwd,
622
+ session: opts.session,
623
+ permission: opts.permission,
624
+ signal: opts.signal
625
+ };
626
+ const specifier = tool.specifier?.(call.args);
627
+ const decision = opts.permission.evaluate(call.name, specifier, tool.readOnly);
628
+ if (decision === "deny") return {
629
+ ...base,
630
+ content: `Permission denied: ${call.name}(${specifier ?? ""}) is denied by permission rules. This decision is final; do not retry this call.`
631
+ };
632
+ if (decision === "ask") {
633
+ let detail;
634
+ try {
635
+ detail = await tool.askDetail?.(call.args, ctx);
636
+ } catch {
637
+ detail = void 0;
638
+ }
639
+ const approval = await opts.sink.ask({
640
+ tool: call.name,
641
+ input: call.args,
642
+ reason: specifier === void 0 ? call.name : `${call.name} ${specifier}`,
643
+ detail
644
+ });
645
+ if (!approval.approved) {
646
+ const reason = approval.reason ?? `the request was not approved (${HEADLESS_HINT})`;
647
+ return {
648
+ ...base,
649
+ content: `Permission denied: ${call.name}(${specifier ?? ""}) was not approved. ${reason}`
650
+ };
651
+ }
652
+ }
653
+ try {
654
+ const content = await tool.handler(call.args, ctx);
655
+ return {
656
+ ...base,
657
+ content
658
+ };
659
+ } catch (error) {
660
+ const description = error instanceof Error ? error.message : String(error);
661
+ return {
662
+ ...base,
663
+ content: `Error: tool ${call.name} failed: ${description}. Adjust the arguments and retry if useful.`
664
+ };
665
+ }
666
+ }
667
+ //#endregion
668
+ //#region src/context.ts
669
+ /** Tool-usage rules the model needs to drive the harness correctly. */
670
+ const TOOL_RULES = `
671
+ # Tool usage rules
672
+
673
+ - Paths are relative to the project cwd unless you pass an absolute path deliberately.
674
+ - Protected paths (.env, .env.*, *.pem, id_rsa*, ~/.ssh) are refused by the harness; do not retry them.
675
+ - read returns numbered lines; use offset/limit to window big files instead of re-reading everything.
676
+ - edit requires an exact unique oldString; include surrounding context when text is not unique.
677
+ - write refuses to overwrite a multi-line file without overwrite: true; prefer edit for targeted changes.
678
+ - Tool output is truncated at a character cap; note the [truncated] marker and read narrower ranges.
679
+ `;
680
+ const IDENTITY = `You are chantier, a terminal coding agent. You work inside the user's project directory:
681
+ read before you write, make surgical edits, and explain what you did in one short paragraph at the end
682
+ of a task. When a mutation is denied, state it plainly and continue with what is allowed.`;
683
+ /** Builds the system prompt: identity + tool rules + every AGENTS.md from cwd up to the git root. */
684
+ async function buildSystemPrompt(cwd, tools) {
685
+ const toolCatalog = tools.map((tool) => `- ${tool.name}${tool.readOnly ? " (read-only)" : ""}: ${tool.description.split(".")[0]}.`).join("\n");
686
+ const agentsDocs = await collectAgentsMd(cwd);
687
+ const agentsSection = agentsDocs.length > 0 ? `\n# Project instructions (AGENTS.md)\n\n${agentsDocs.join("\n\n")}` : "";
688
+ return `${IDENTITY}
689
+
690
+ # Available tools
691
+
692
+ ${toolCatalog}
693
+ ${TOOL_RULES}${agentsSection}`;
694
+ }
695
+ /**
696
+ * AGENTS.md discovery: walk from cwd to the git root. Files from ancestor
697
+ * directories come first, the nearest file last (nearer = later in context).
698
+ * No @import expansion in v0.1 (planned for v0.2).
699
+ */
700
+ async function collectAgentsMd(cwd) {
701
+ const docs = [];
702
+ const dir = path.resolve(cwd);
703
+ const stop = await findGitRoot(dir);
704
+ stop ?? path.parse(dir).root;
705
+ const chain = [];
706
+ for (let current = dir;; current = path.dirname(current)) {
707
+ chain.push(current);
708
+ if (current === stop || current === path.parse(current).root) break;
709
+ }
710
+ for (const candidate of chain.reverse()) {
711
+ const file = path.join(candidate, "AGENTS.md");
712
+ if ((await stat(file).catch(() => null))?.isFile() === true) {
713
+ const content = await readFile(file, "utf8");
714
+ if (content.trim().length > 0) docs.push(`--- ${file} ---\n${content.trim()}`);
715
+ }
716
+ }
717
+ return docs;
718
+ }
719
+ async function findGitRoot(dir) {
720
+ let current = path.resolve(dir);
721
+ for (;;) {
722
+ if (await stat(path.join(current, ".git")).catch(() => null) !== null) return current;
723
+ const parent = path.dirname(current);
724
+ if (parent === current) return null;
725
+ current = parent;
726
+ }
727
+ }
728
+ //#endregion
729
+ export { COMPACTED_MARKER, COMPACT_PROMPT, DEFAULT_COMPACTION_KEEP_RECENT, DEFAULT_COMPACTION_RESERVE, DEFAULT_SESSIONS_ROOT, alignedMessageOrdinals, buildSystemPrompt, compactConversation, compactSession, compactedSummaryMessage, createSessionStore, estimateMessageTokens, estimateTokens, loadNewestSessionId, looksLikeContextOverflow, resumeSessionStore, runAgent, serializeConversation, sessionView, sessionsDirFor, shouldCompact };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@chantier/core",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "license": "Apache-2.0",
6
+ "description": "Agent loop, session store, and model-adapter seam for the chantier coding agent",
7
+ "exports": {
8
+ ".": {
9
+ "development": "./src/index.ts",
10
+ "types": "./dist/index.d.mts",
11
+ "import": "./dist/index.mjs"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE",
18
+ "CHANGELOG.md"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/mlcassociation/chantier.git"
26
+ },
27
+ "homepage": "https://github.com/mlcassociation/chantier#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/mlcassociation/chantier/issues"
30
+ },
31
+ "engines": {
32
+ "node": ">=22"
33
+ },
34
+ "scripts": {
35
+ "build": "tsdown src/index.ts --format esm --dts --clean --out-dir dist",
36
+ "prepublishOnly": "npm run build"
37
+ },
38
+ "dependencies": {
39
+ "@chantier/permissions": "^0.3.0"
40
+ },
41
+ "devDependencies": {
42
+ "tsdown": "0.23.0"
43
+ }
44
+ }