@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 +13 -0
- package/LICENSE +190 -0
- package/README.md +12 -0
- package/dist/index.d.mts +353 -0
- package/dist/index.mjs +729 -0
- package/package.json +44 -0
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.
|
package/dist/index.d.mts
ADDED
|
@@ -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
|
+
}
|