@chantier/core 0.3.0 → 0.5.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/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { ApprovalDetail, ApprovalSink, PermissionEngine } from "@chantier/permissions";
1
+ import { ApprovalDetail, ApprovalSink, PermissionEngine, PermissionRules } from "@chantier/permissions";
2
2
  //#region src/types.d.ts
3
3
  interface TextBlock {
4
4
  type: "text";
@@ -273,8 +273,32 @@ export declare function compactConversation(opts: CompactConversationOptions): P
273
273
  export declare function compactedSummaryMessage(summary: string): UserMessage;
274
274
  //#endregion
275
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>;
276
+ /**
277
+ * Per-model-family prompt profile. Only the identity section is
278
+ * profile-specific; every other section is shared, fixed-order text.
279
+ */
280
+ interface ModelProfile {
281
+ /** Family name, for diagnostics and tests. */
282
+ name: string;
283
+ /** Replaces the default identity paragraph when present. */
284
+ identity?: string;
285
+ }
286
+ /**
287
+ * Profile registry: family prefix match on the model id — `glm-` and `claude-`
288
+ * resolve their profiles, anything else the default. Prefixes match the leading
289
+ * id so suffixed tags (e.g. `glm-5.3-flash:cloud`) still resolve to the family.
290
+ */
291
+ export declare function resolveModelProfile(model: string): ModelProfile;
292
+ /**
293
+ * Builds the system prompt from fixed, blank-line-joined sections: environment,
294
+ * identity (profile-adjustable), doing-tasks rules, denial rule, delegation
295
+ * (only when a `task` tool is offered), tool catalog, tool rules, and every
296
+ * AGENTS.md from cwd up to the git root last.
297
+ *
298
+ * `profile` omitted means the default profile: identity and tool rules keep
299
+ * today's semantics, new sections are purely additive.
300
+ */
301
+ export declare function buildSystemPrompt(cwd: string, tools: ToolDefinition[], profile?: ModelProfile): Promise<string>;
278
302
  //#endregion
279
303
  //#region src/session.d.ts
280
304
  export declare const DEFAULT_SESSIONS_ROOT: string;
@@ -350,4 +374,49 @@ export declare function compactSession(opts: CompactSessionOptions): Promise<Com
350
374
  /** `--continue`: newest session file (by mtime) in this cwd's session dir. */
351
375
  export declare function loadNewestSessionId(cwd: string, sessionsRoot?: string): Promise<string | null>;
352
376
  //#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 };
377
+ //#region src/subagent.d.ts
378
+ interface SubagentDeps {
379
+ /** The parent's model adapter; the child runs on the same provider/model seam. */
380
+ adapter: ModelAdapter;
381
+ /** The parent's settings rules, verbatim: deny rules bind the child, allow rules pre-approve. */
382
+ rules: PermissionRules;
383
+ /** The parent's approval sink: a child ask surfaces in the parent's approval UI. */
384
+ sink: ApprovalSink;
385
+ /** Provider label recorded in the child session header. */
386
+ provider: string;
387
+ model: string;
388
+ /** Child turn cap; defaults to 25. */
389
+ maxTurns?: number;
390
+ /**
391
+ * Declared model context window; opts the child into compaction (same
392
+ * contract as runAgent). Undefined keeps compaction off for the child.
393
+ */
394
+ contextWindow?: number;
395
+ /**
396
+ * The child's toolset. Phase A depth cap: the child toolset is the builtin
397
+ * set, which does not contain `task`, so a child cannot recurse by
398
+ * construction.
399
+ */
400
+ tools: ToolDefinition[];
401
+ }
402
+ interface SubagentResult {
403
+ /** The child's final summary text (50 KiB cap, see spawnSubagent). */
404
+ text: string;
405
+ /** The child's own session id, so the parent can reference/inspect the transcript. */
406
+ sessionId: string;
407
+ truncated: boolean;
408
+ }
409
+ /**
410
+ * Runs one child agent loop for a single `task` call and returns its final
411
+ * summary. Isolation is structural: a fresh permission engine per spawn (the
412
+ * parent's remembered grants never inherit; deny rules still win first), a
413
+ * fresh session store (the child transcript is its own JSONL file), and a
414
+ * task-free toolset. Child asks surface through the parent's sink, each one a
415
+ * fresh decision. `ctx.signal` propagates: on abort the child loop stops and
416
+ * the error surfaces as the parent's tool result.
417
+ */
418
+ export declare function spawnSubagent(input: {
419
+ prompt: string;
420
+ }, deps: SubagentDeps, ctx: ToolContext): Promise<SubagentResult>;
421
+ //#endregion
422
+ export type { AgentEvent, AssistantMessage, CompactConversationOptions, CompactSessionOptions, CompactionEntry, CompactionOptions, CompactionOutcome, CompactionResult, CreateSessionOptions, Message, ModelAdapter, ModelEvent, ModelProfile, ResumeSessionOptions, RunAgentOptions, SessionEntry, SessionHeader, SessionStore, StopReason, SubagentDeps, SubagentResult, SystemMessage, TextBlock, ToolCallBlock, ToolContext, ToolDefinition, ToolResultMessage, Usage, UserMessage };
package/dist/index.mjs CHANGED
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { appendFile, mkdir, readFile, readdir, stat } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import path from "node:path";
5
+ import { createPermissionEngine, createRememberingEngine } from "@chantier/permissions";
5
6
  //#region src/compaction.ts
6
7
  /**
7
8
  * Rough token estimate: ~4 characters per token for English/code text. Used
@@ -195,8 +196,11 @@ function sessionsDirFor(sessionsRoot, cwd) {
195
196
  const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 12);
196
197
  return path.join(sessionsRoot, hash);
197
198
  }
199
+ let sequence = 0;
198
200
  function newSessionId() {
199
- return `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${randomBytes(4).toString("hex")}`;
201
+ sequence = (sequence + 1) % 1679616;
202
+ const seq = sequence.toString(36).padStart(4, "0");
203
+ return `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${seq}${randomBytes(2).toString("hex")}`;
200
204
  }
201
205
  function buildStore(file, dir, id, selfId) {
202
206
  const readEntries = async (loadId) => {
@@ -666,9 +670,10 @@ async function executeCall(call, opts, byName) {
666
670
  }
667
671
  //#endregion
668
672
  //#region src/context.ts
669
- /** Tool-usage rules the model needs to drive the harness correctly. */
670
- const TOOL_RULES = `
671
- # Tool usage rules
673
+ /**
674
+ * Tool-usage rules the model needs to drive the harness correctly.
675
+ */
676
+ const TOOL_RULES = `# Tool usage rules
672
677
 
673
678
  - Paths are relative to the project cwd unless you pass an absolute path deliberately.
674
679
  - Protected paths (.env, .env.*, *.pem, id_rsa*, ~/.ssh) are refused by the harness; do not retry them.
@@ -680,17 +685,100 @@ const TOOL_RULES = `
680
685
  const IDENTITY = `You are chantier, a terminal coding agent. You work inside the user's project directory:
681
686
  read before you write, make surgical edits, and explain what you did in one short paragraph at the end
682
687
  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}
688
+ /** Doing-tasks discipline shared by every model family. */
689
+ const DOING_TASKS = `# Doing tasks
690
+
691
+ - Prefer editing existing files over creating new ones.
692
+ - Do exactly what was asked: no scope creep no extra retries, telemetry, or
693
+ abstraction "while you're at it"; the real ask only.
694
+ - Comments explain WHY, not WHAT; skip them where the code already says it.
695
+ - Verify behavioral changes by running the changed path, not by re-reading the edit.
696
+ - State uncertainty plainly rather than guessing.`;
697
+ const DENIALS = `# Permission denials
689
698
 
690
- # Available tools
699
+ A denied tool call is final for that exact invocation: never retry the identical
700
+ denied call. Adjust the arguments or switch the approach, or continue with what
701
+ is allowed, and state plainly that the action was not permitted.`;
702
+ /** Present in the prompt only when a tool named "task" is offered. */
703
+ const DELEGATION = `# Delegating subtasks
691
704
 
692
- ${toolCatalog}
693
- ${TOOL_RULES}${agentsSection}`;
705
+ - Delegate self-contained subtasks with the full context the child needs in the
706
+ prompt (paths, constraints, acceptance); the child returns a final summary.
707
+ - Scale the prompt effort to the subtask: brief for mechanical work, detailed
708
+ for design work.
709
+ - Do not delegate single sequential edits you can do directly.`;
710
+ /** Default semantics: today's identity, unchanged. */
711
+ const DEFAULT_PROFILE = { name: "default" };
712
+ const GLM_PROFILE = {
713
+ name: "glm",
714
+ identity: `You are chantier, a terminal coding agent working directly in the user's project directory.
715
+ Style for this model family: keep prose terse; tool arguments are strict JSON
716
+ objects with no trailing commentary inside tool calls; finish each task with one
717
+ short final paragraph and nothing more. When a tool call is denied or fails,
718
+ adjust the arguments or change the approach — never loop identical retries.`
719
+ };
720
+ const CLAUDE_PROFILE = {
721
+ name: "claude",
722
+ identity: `You are chantier, a terminal coding agent working directly in the user's project directory.
723
+ Read before you write; strongly prefer editing existing files over creating new
724
+ ones; do exactly the task asked with no scope creep; when something is unclear,
725
+ say so plainly instead of guessing.`
726
+ };
727
+ /**
728
+ * Profile registry: family prefix match on the model id — `glm-` and `claude-`
729
+ * resolve their profiles, anything else the default. Prefixes match the leading
730
+ * id so suffixed tags (e.g. `glm-5.3-flash:cloud`) still resolve to the family.
731
+ */
732
+ function resolveModelProfile(model) {
733
+ if (model.startsWith("glm-")) return GLM_PROFILE;
734
+ if (model.startsWith("claude-")) return CLAUDE_PROFILE;
735
+ return DEFAULT_PROFILE;
736
+ }
737
+ /**
738
+ * Builds the system prompt from fixed, blank-line-joined sections: environment,
739
+ * identity (profile-adjustable), doing-tasks rules, denial rule, delegation
740
+ * (only when a `task` tool is offered), tool catalog, tool rules, and every
741
+ * AGENTS.md from cwd up to the git root last.
742
+ *
743
+ * `profile` omitted means the default profile: identity and tool rules keep
744
+ * today's semantics, new sections are purely additive.
745
+ */
746
+ async function buildSystemPrompt(cwd, tools, profile = DEFAULT_PROFILE) {
747
+ const toolCatalog = tools.map((tool) => `- ${tool.name}${tool.readOnly ? " (read-only)" : ""}: ${tool.description.split(".")[0]}.`).join("\n");
748
+ const sections = [
749
+ await environmentSection(cwd),
750
+ profile.identity ?? IDENTITY,
751
+ DOING_TASKS,
752
+ DENIALS
753
+ ];
754
+ if (tools.some((tool) => tool.name === "task")) sections.push(DELEGATION);
755
+ sections.push(`# Available tools\n\n${toolCatalog}`, TOOL_RULES);
756
+ const agentsDocs = await collectAgentsMd(cwd);
757
+ if (agentsDocs.length > 0) sections.push(`# Project instructions (AGENTS.md)\n\n${agentsDocs.join("\n\n")}`);
758
+ return sections.join("\n\n");
759
+ }
760
+ /** Environment facts first; the branch line is omitted rather than fabricated. */
761
+ async function environmentSection(cwd) {
762
+ const branch = await resolveBranch(await findGitRoot(path.resolve(cwd)));
763
+ const lines = [
764
+ "# Environment",
765
+ "",
766
+ `- cwd: ${cwd}`,
767
+ `- date: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)} (UTC)`
768
+ ];
769
+ if (branch !== null) lines.push(`- branch: ${branch}`);
770
+ return lines.join("\n");
771
+ }
772
+ /**
773
+ * Branch read from the filesystem, no git process spawned: `.git/HEAD` with
774
+ * `ref: refs/heads/<name>` yields the name; a raw sha means detached. Missing
775
+ * or unreadable HEAD → null → the caller omits the branch line.
776
+ */
777
+ async function resolveBranch(gitRoot) {
778
+ if (gitRoot === null) return null;
779
+ const head = await readFile(path.join(gitRoot, ".git", "HEAD"), "utf8").catch(() => null);
780
+ if (head === null) return null;
781
+ return /^ref: refs\/heads\/(.+)$/.exec(head.trim())?.[1] ?? "(detached)";
694
782
  }
695
783
  /**
696
784
  * AGENTS.md discovery: walk from cwd to the git root. Files from ancestor
@@ -726,4 +814,70 @@ async function findGitRoot(dir) {
726
814
  }
727
815
  }
728
816
  //#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 };
817
+ //#region src/subagent.ts
818
+ /** The returned summary is capped at 50 KiB; the full transcript stays in the child session. */
819
+ const SUMMARY_CAP_CHARS = 51200;
820
+ /** Children get their own (smaller) turn budget; the parent's cap says nothing about subtasks. */
821
+ const DEFAULT_SUBAGENT_TURNS = 25;
822
+ /**
823
+ * Plain-string appendix for the child system prompt. Kept out of the composer
824
+ * on purpose: the subagent role is a property of delegation, not of the
825
+ * project's base prompt.
826
+ */
827
+ const SUBAGENT_ROLE_APPENDIX = "\n\n# Subagent role\n\nYou are a subagent spawned by the parent agent to complete one self-contained task. Work within the prompt you were given, use the allowed tools, and end with a single short summary paragraph of what you did and found. Do not ask the user questions; decide and act.";
828
+ /**
829
+ * Runs one child agent loop for a single `task` call and returns its final
830
+ * summary. Isolation is structural: a fresh permission engine per spawn (the
831
+ * parent's remembered grants never inherit; deny rules still win first), a
832
+ * fresh session store (the child transcript is its own JSONL file), and a
833
+ * task-free toolset. Child asks surface through the parent's sink, each one a
834
+ * fresh decision. `ctx.signal` propagates: on abort the child loop stops and
835
+ * the error surfaces as the parent's tool result.
836
+ */
837
+ async function spawnSubagent(input, deps, ctx) {
838
+ const childPermission = createRememberingEngine(createPermissionEngine(deps.rules));
839
+ const childSession = await createSessionStore({
840
+ cwd: ctx.cwd,
841
+ provider: deps.provider,
842
+ model: deps.model
843
+ });
844
+ const system = `${await buildSystemPrompt(ctx.cwd, deps.tools)}${SUBAGENT_ROLE_APPENDIX}`;
845
+ const userMessage = {
846
+ role: "user",
847
+ content: [{
848
+ type: "text",
849
+ text: input.prompt
850
+ }]
851
+ };
852
+ await childSession.append({
853
+ type: "message",
854
+ message: userMessage
855
+ });
856
+ let text = "";
857
+ for await (const event of runAgent({
858
+ adapter: deps.adapter,
859
+ tools: deps.tools,
860
+ permission: childPermission,
861
+ sink: deps.sink,
862
+ session: childSession,
863
+ cwd: ctx.cwd,
864
+ system,
865
+ messages: [userMessage],
866
+ maxTurns: deps.maxTurns ?? DEFAULT_SUBAGENT_TURNS,
867
+ contextWindow: deps.contextWindow,
868
+ compaction: deps.contextWindow === void 0 ? void 0 : { enabled: true },
869
+ signal: ctx.signal
870
+ })) if (event.type === "result") text = event.text;
871
+ if (text.length <= SUMMARY_CAP_CHARS) return {
872
+ text,
873
+ sessionId: childSession.id,
874
+ truncated: false
875
+ };
876
+ return {
877
+ text: `${text.slice(0, SUMMARY_CAP_CHARS)}\n[truncated: subagent summary exceeded 50 KiB cap; full transcript session id: ${childSession.id}]`,
878
+ sessionId: childSession.id,
879
+ truncated: true
880
+ };
881
+ }
882
+ //#endregion
883
+ 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, resolveModelProfile, resumeSessionStore, runAgent, serializeConversation, sessionView, sessionsDirFor, shouldCompact, spawnSubagent };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chantier/core",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Agent loop, session store, and model-adapter seam for the chantier coding agent",
@@ -36,7 +36,7 @@
36
36
  "prepublishOnly": "npm run build"
37
37
  },
38
38
  "dependencies": {
39
- "@chantier/permissions": "^0.3.0"
39
+ "@chantier/permissions": "^0.5.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "tsdown": "0.23.0"