@chantier/core 0.5.0 → 0.6.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 +175 -5
- package/dist/index.mjs +326 -7
- package/package.json +2 -2
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,42 @@
|
|
|
1
1
|
import { ApprovalDetail, ApprovalSink, PermissionEngine, PermissionRules } from "@chantier/permissions";
|
|
2
|
+
//#region src/todo.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Frozen v0.6.0 contract (spec /home/debian/portfolio/chantier/v06-spec.md
|
|
5
|
+
* §Frozen contract): the todo-checklist step shape shared by the core tool,
|
|
6
|
+
* the store, and the TUI. Types only — Worker CoreExt implements the tool;
|
|
7
|
+
* the TUI worker implements the store fields and rendering.
|
|
8
|
+
*/
|
|
9
|
+
/** One checklist row. Whole-list replace per call; at most one in_progress. */
|
|
10
|
+
interface TodoStep {
|
|
11
|
+
readonly content: string;
|
|
12
|
+
readonly status: "pending" | "in_progress" | "completed";
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The todo tool factory. The tool replaces the whole checklist per call and
|
|
16
|
+
* enforces the exactly-one-in_progress invariant (normalizing extras to
|
|
17
|
+
* pending). `onTodo` receives every accepted list; the CLI loop forwards it
|
|
18
|
+
* to the TUI store.
|
|
19
|
+
*/
|
|
20
|
+
type CreateTodoTool = (deps: {
|
|
21
|
+
onTodo: (steps: readonly TodoStep[]) => void;
|
|
22
|
+
}) => ToolDefinition;
|
|
23
|
+
/**
|
|
24
|
+
* The `todo` tool: whole-list checklist replace with the
|
|
25
|
+
* exactly-one-in_progress invariant (extras normalize to pending, first
|
|
26
|
+
* keeps the slot). readOnly: the checklist mutates nothing on disk.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createTodoTool(deps: {
|
|
29
|
+
onTodo: (steps: readonly TodoStep[]) => void;
|
|
30
|
+
}): ToolDefinition;
|
|
31
|
+
/**
|
|
32
|
+
* Lenient row normalization: drops non-object/blank rows, coerces unknown
|
|
33
|
+
* statuses to pending, and enforces exactly one in_progress (the first keeps
|
|
34
|
+
* the slot; extras become pending).
|
|
35
|
+
*/
|
|
36
|
+
export declare function normalizeTodoSteps(raw: readonly unknown[]): readonly TodoStep[];
|
|
37
|
+
/** One-line summary the model sees: counts per status in fixed order. */
|
|
38
|
+
export declare function summarizeTodoSteps(steps: readonly TodoStep[]): string;
|
|
39
|
+
//#endregion
|
|
2
40
|
//#region src/types.d.ts
|
|
3
41
|
interface TextBlock {
|
|
4
42
|
type: "text";
|
|
@@ -78,6 +116,12 @@ interface ToolContext {
|
|
|
78
116
|
session: SessionStore;
|
|
79
117
|
permission: PermissionEngine;
|
|
80
118
|
signal: AbortSignal;
|
|
119
|
+
/**
|
|
120
|
+
* v0.6: live todo-trail hook. The todo tool forwards each accepted
|
|
121
|
+
* checklist to it; the interactive loop binds it to the TUI store.
|
|
122
|
+
* Undefined = the checklist is accepted without a live consumer.
|
|
123
|
+
*/
|
|
124
|
+
onTodo?: (steps: readonly TodoStep[]) => void;
|
|
81
125
|
}
|
|
82
126
|
interface ToolDefinition {
|
|
83
127
|
name: string;
|
|
@@ -203,6 +247,83 @@ interface RunAgentOptions {
|
|
|
203
247
|
*/
|
|
204
248
|
export declare function runAgent(opts: RunAgentOptions): AsyncGenerator<AgentEvent>;
|
|
205
249
|
//#endregion
|
|
250
|
+
//#region src/commands.d.ts
|
|
251
|
+
/**
|
|
252
|
+
* Frozen v0.6.0 contract (spec /home/debian/portfolio/chantier/v06-spec.md
|
|
253
|
+
* §Theme 3): the slash-command registry. Types only — Worker CoreExt
|
|
254
|
+
* implements the registry and dispatch; the TUI consumes `list()` for the
|
|
255
|
+
* palette. `/compact` semantics are preserved exactly (trimmed exact match
|
|
256
|
+
* dispatches; a slash-word that is not a registered command goes to the
|
|
257
|
+
* agent as a normal task).
|
|
258
|
+
*/
|
|
259
|
+
/** A registered slash command. `name` excludes the leading slash. */
|
|
260
|
+
interface CommandSpec {
|
|
261
|
+
/** `[a-z0-9-]+` — the palette matches this string after `/`. */
|
|
262
|
+
readonly name: string;
|
|
263
|
+
/** One-line description shown in the palette and /help. */
|
|
264
|
+
readonly description: string;
|
|
265
|
+
/**
|
|
266
|
+
* "action" performs work itself (e.g. /compact); "expand" rewrites the
|
|
267
|
+
* task text (e.g. a skill fills the editor / injects its body).
|
|
268
|
+
*/
|
|
269
|
+
readonly kind: "action" | "expand";
|
|
270
|
+
}
|
|
271
|
+
/** Registry surface the TUI palette relies on (implementation in core). */
|
|
272
|
+
interface CommandRegistry {
|
|
273
|
+
register(spec: CommandSpec): void;
|
|
274
|
+
/** Stable order: registration order, built-ins first. */
|
|
275
|
+
list(): readonly CommandSpec[];
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Core-agnostic IO surface actions render through. The TUI store satisfies
|
|
279
|
+
* it structurally (its pushItem accepts every TuiItem, including these).
|
|
280
|
+
*/
|
|
281
|
+
interface CommandIo {
|
|
282
|
+
/** Abort signal for the current prompt interaction (e.g. manual compaction). */
|
|
283
|
+
readonly signal: AbortSignal;
|
|
284
|
+
/** Appends one transcript line. */
|
|
285
|
+
pushItem(item: {
|
|
286
|
+
readonly kind: "info" | "divider" | "error";
|
|
287
|
+
readonly text: string;
|
|
288
|
+
}): void;
|
|
289
|
+
}
|
|
290
|
+
/** A registered action command: `run` performs the work itself. */
|
|
291
|
+
interface ActionSpec extends CommandSpec {
|
|
292
|
+
readonly kind: "action";
|
|
293
|
+
/** Only exact `/name` dispatches (the /compact rule); args go to the agent verbatim. */
|
|
294
|
+
readonly run?: (io: CommandIo) => void | Promise<void>;
|
|
295
|
+
}
|
|
296
|
+
/** A registered expand command (e.g. a skill): rewrites the task text. */
|
|
297
|
+
interface ExpandSpec extends CommandSpec {
|
|
298
|
+
readonly kind: "expand";
|
|
299
|
+
/**
|
|
300
|
+
* Receives the text after the command name ("" when none) and returns the
|
|
301
|
+
* replacement task. Async bodies (skill files) are awaited by dispatch.
|
|
302
|
+
*/
|
|
303
|
+
readonly expand: (args: string) => string | Promise<string>;
|
|
304
|
+
}
|
|
305
|
+
type RegistrableCommand = ActionSpec | ExpandSpec;
|
|
306
|
+
type DispatchResult = {
|
|
307
|
+
readonly kind: "handled";
|
|
308
|
+
} | {
|
|
309
|
+
readonly kind: "expanded";
|
|
310
|
+
readonly task: string;
|
|
311
|
+
} | {
|
|
312
|
+
readonly kind: "not-command";
|
|
313
|
+
};
|
|
314
|
+
interface CommandRegistryV6 extends CommandRegistry {
|
|
315
|
+
register(spec: RegistrableCommand): void;
|
|
316
|
+
/**
|
|
317
|
+
* Slash-command seam for the interactive loop. A trimmed task starting
|
|
318
|
+
* with `/` whose first word exactly matches a registered command
|
|
319
|
+
* dispatches; anything else is `not-command` (the task goes to the agent
|
|
320
|
+
* verbatim). Actions dispatch only on the exact `/name` form; expand
|
|
321
|
+
* commands receive the remaining text as args.
|
|
322
|
+
*/
|
|
323
|
+
dispatch(task: string, io: CommandIo): Promise<DispatchResult>;
|
|
324
|
+
}
|
|
325
|
+
export declare function createCommandRegistry(): CommandRegistryV6;
|
|
326
|
+
//#endregion
|
|
206
327
|
//#region src/compaction.d.ts
|
|
207
328
|
/**
|
|
208
329
|
* Rough token estimate: ~4 characters per token for English/code text. Used
|
|
@@ -272,6 +393,54 @@ export declare function compactConversation(opts: CompactConversationOptions): P
|
|
|
272
393
|
/** Builds the regular user message that carries the summary after compaction. */
|
|
273
394
|
export declare function compactedSummaryMessage(summary: string): UserMessage;
|
|
274
395
|
//#endregion
|
|
396
|
+
//#region src/skills.d.ts
|
|
397
|
+
/**
|
|
398
|
+
* Frozen v0.6.0 contract (spec /home/debian/portfolio/chantier/v06-spec.md
|
|
399
|
+
* §Theme 1): agentskills.io SKILL.md types. Types only — Worker CoreExt
|
|
400
|
+
* implements the loader (frontmatter parse, discovery, precedence, trust).
|
|
401
|
+
*/
|
|
402
|
+
/** Validated SKILL.md frontmatter (agentskills.io/specification subset). */
|
|
403
|
+
interface SkillFrontmatter {
|
|
404
|
+
/** 1–64 chars, [a-z0-9-], no lead/trail/consecutive `-`. */
|
|
405
|
+
readonly name: string;
|
|
406
|
+
/** 1–1024 chars, what + when. */
|
|
407
|
+
readonly description: string;
|
|
408
|
+
readonly license?: string;
|
|
409
|
+
/** ≤500 chars. */
|
|
410
|
+
readonly compatibility?: string;
|
|
411
|
+
readonly metadata?: Readonly<Record<string, string>>;
|
|
412
|
+
}
|
|
413
|
+
/** A discovered skill; the body is NOT loaded (progressive disclosure). */
|
|
414
|
+
interface Skill {
|
|
415
|
+
readonly name: string;
|
|
416
|
+
readonly description: string;
|
|
417
|
+
/** Absolute directory holding SKILL.md (body + bundled files resolve here). */
|
|
418
|
+
readonly dir: string;
|
|
419
|
+
readonly frontmatter: SkillFrontmatter;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Scan the given roots (each root = a directory of `<name>/SKILL.md`
|
|
423
|
+
* subdirectories). First occurrence per name wins (caller passes roots in
|
|
424
|
+
* precedence order); invalid skills are skipped with a one-line notice.
|
|
425
|
+
*/
|
|
426
|
+
type LoadSkills = (roots: readonly string[]) => Promise<readonly Skill[]>;
|
|
427
|
+
/** Tier-2 load: the full markdown body below the frontmatter. */
|
|
428
|
+
type LoadSkillBody = (skill: Skill) => Promise<string>;
|
|
429
|
+
interface LoadSkillsOptions {
|
|
430
|
+
/** Called once per skipped-but-diagnosable skill with a one-line reason. */
|
|
431
|
+
onNotice?: (notice: string) => void;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Scans each root one level deep for `<name>/SKILL.md`. Roots are in
|
|
435
|
+
* precedence order (project before user; the caller decides) and the first
|
|
436
|
+
* occurrence of a name wins. Nonexistent or unreadable roots contribute
|
|
437
|
+
* nothing; unreadable directories are skipped silently; a skill whose
|
|
438
|
+
* frontmatter parses but fails validation is skipped with a one-line notice.
|
|
439
|
+
*/
|
|
440
|
+
export declare function loadSkills(roots: readonly string[], options?: LoadSkillsOptions): Promise<readonly Skill[]>;
|
|
441
|
+
/** Tier-2 load: the full markdown body below the frontmatter delimiter. */
|
|
442
|
+
export declare function loadSkillBody(skill: Skill): Promise<string>;
|
|
443
|
+
//#endregion
|
|
275
444
|
//#region src/context.d.ts
|
|
276
445
|
/**
|
|
277
446
|
* Per-model-family prompt profile. Only the identity section is
|
|
@@ -291,14 +460,15 @@ interface ModelProfile {
|
|
|
291
460
|
export declare function resolveModelProfile(model: string): ModelProfile;
|
|
292
461
|
/**
|
|
293
462
|
* Builds the system prompt from fixed, blank-line-joined sections: environment,
|
|
294
|
-
* identity (profile-adjustable), doing-tasks rules,
|
|
295
|
-
*
|
|
296
|
-
*
|
|
463
|
+
* identity (profile-adjustable), doing-tasks rules, todo usage (when the tool
|
|
464
|
+
* is offered), the tier-1 skill catalog (when skills are passed), denial rule,
|
|
465
|
+
* delegation (only when a `task` tool is offered), tool catalog, tool rules,
|
|
466
|
+
* and every AGENTS.md from cwd up to the git root last.
|
|
297
467
|
*
|
|
298
468
|
* `profile` omitted means the default profile: identity and tool rules keep
|
|
299
469
|
* today's semantics, new sections are purely additive.
|
|
300
470
|
*/
|
|
301
|
-
export declare function buildSystemPrompt(cwd: string, tools: ToolDefinition[], profile?: ModelProfile): Promise<string>;
|
|
471
|
+
export declare function buildSystemPrompt(cwd: string, tools: ToolDefinition[], profile?: ModelProfile, skills?: readonly Skill[]): Promise<string>;
|
|
302
472
|
//#endregion
|
|
303
473
|
//#region src/session.d.ts
|
|
304
474
|
export declare const DEFAULT_SESSIONS_ROOT: string;
|
|
@@ -419,4 +589,4 @@ export declare function spawnSubagent(input: {
|
|
|
419
589
|
prompt: string;
|
|
420
590
|
}, deps: SubagentDeps, ctx: ToolContext): Promise<SubagentResult>;
|
|
421
591
|
//#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 };
|
|
592
|
+
export type { ActionSpec, AgentEvent, AssistantMessage, CommandIo, CommandRegistryV6, CompactConversationOptions, CompactSessionOptions, CompactionEntry, CompactionOptions, CompactionOutcome, CompactionResult, CreateSessionOptions, CreateTodoTool, DispatchResult, ExpandSpec, LoadSkillBody, LoadSkills, LoadSkillsOptions, Message, ModelAdapter, ModelEvent, ModelProfile, RegistrableCommand, ResumeSessionOptions, RunAgentOptions, SessionEntry, SessionHeader, SessionStore, Skill, SkillFrontmatter, StopReason, SubagentDeps, SubagentResult, SystemMessage, TextBlock, TodoStep, ToolCallBlock, ToolContext, ToolDefinition, ToolResultMessage, Usage, UserMessage };
|
package/dist/index.mjs
CHANGED
|
@@ -669,6 +669,39 @@ async function executeCall(call, opts, byName) {
|
|
|
669
669
|
}
|
|
670
670
|
}
|
|
671
671
|
//#endregion
|
|
672
|
+
//#region src/commands.ts
|
|
673
|
+
const COMMAND_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
674
|
+
const COMMAND_NAME_MAX = 64;
|
|
675
|
+
function createCommandRegistry() {
|
|
676
|
+
const commands = /* @__PURE__ */ new Map();
|
|
677
|
+
return {
|
|
678
|
+
register(spec) {
|
|
679
|
+
if (spec.name.length === 0 || spec.name.length > COMMAND_NAME_MAX || !COMMAND_NAME_PATTERN.test(spec.name)) throw new Error(`Invalid command name "${spec.name}" (1-${COMMAND_NAME_MAX} chars, [a-z0-9-], no lead/trail/double -).`);
|
|
680
|
+
if (commands.has(spec.name)) throw new Error(`Command "${spec.name}" is already registered.`);
|
|
681
|
+
if (spec.kind === "expand" && typeof spec.expand !== "function") throw new Error(`Expand command "${spec.name}" requires an expand function.`);
|
|
682
|
+
commands.set(spec.name, spec);
|
|
683
|
+
},
|
|
684
|
+
list: () => [...commands.values()],
|
|
685
|
+
async dispatch(task, io) {
|
|
686
|
+
const trimmed = task.trim();
|
|
687
|
+
if (!trimmed.startsWith("/")) return { kind: "not-command" };
|
|
688
|
+
const word = trimmed.slice(1).split(/\s+/, 1)[0] ?? "";
|
|
689
|
+
const spec = commands.get(word);
|
|
690
|
+
if (spec === void 0) return { kind: "not-command" };
|
|
691
|
+
if (spec.kind === "action") {
|
|
692
|
+
if (trimmed !== `/${spec.name}`) return { kind: "not-command" };
|
|
693
|
+
await spec.run?.(io);
|
|
694
|
+
return { kind: "handled" };
|
|
695
|
+
}
|
|
696
|
+
const args = trimmed.slice(1 + spec.name.length).trim();
|
|
697
|
+
return {
|
|
698
|
+
kind: "expanded",
|
|
699
|
+
task: await spec.expand(args)
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
//#endregion
|
|
672
705
|
//#region src/context.ts
|
|
673
706
|
/**
|
|
674
707
|
* Tool-usage rules the model needs to drive the harness correctly.
|
|
@@ -694,6 +727,12 @@ const DOING_TASKS = `# Doing tasks
|
|
|
694
727
|
- Comments explain WHY, not WHAT; skip them where the code already says it.
|
|
695
728
|
- Verify behavioral changes by running the changed path, not by re-reading the edit.
|
|
696
729
|
- State uncertainty plainly rather than guessing.`;
|
|
730
|
+
/** Present in the prompt only when a tool named "todo" is offered. */
|
|
731
|
+
const TODO_USAGE = `# Todo checklist
|
|
732
|
+
|
|
733
|
+
For any task with more than a couple of steps, lay out the plan with the todo tool FIRST
|
|
734
|
+
(one step in_progress, the rest pending), then keep the checklist current as you work:
|
|
735
|
+
each update replaces the whole list, so re-send every row with its new status.`;
|
|
697
736
|
const DENIALS = `# Permission denials
|
|
698
737
|
|
|
699
738
|
A denied tool call is final for that exact invocation: never retry the identical
|
|
@@ -736,27 +775,52 @@ function resolveModelProfile(model) {
|
|
|
736
775
|
}
|
|
737
776
|
/**
|
|
738
777
|
* Builds the system prompt from fixed, blank-line-joined sections: environment,
|
|
739
|
-
* identity (profile-adjustable), doing-tasks rules,
|
|
740
|
-
*
|
|
741
|
-
*
|
|
778
|
+
* identity (profile-adjustable), doing-tasks rules, todo usage (when the tool
|
|
779
|
+
* is offered), the tier-1 skill catalog (when skills are passed), denial rule,
|
|
780
|
+
* delegation (only when a `task` tool is offered), tool catalog, tool rules,
|
|
781
|
+
* and every AGENTS.md from cwd up to the git root last.
|
|
742
782
|
*
|
|
743
783
|
* `profile` omitted means the default profile: identity and tool rules keep
|
|
744
784
|
* today's semantics, new sections are purely additive.
|
|
745
785
|
*/
|
|
746
|
-
async function buildSystemPrompt(cwd, tools, profile = DEFAULT_PROFILE) {
|
|
786
|
+
async function buildSystemPrompt(cwd, tools, profile = DEFAULT_PROFILE, skills = []) {
|
|
747
787
|
const toolCatalog = tools.map((tool) => `- ${tool.name}${tool.readOnly ? " (read-only)" : ""}: ${tool.description.split(".")[0]}.`).join("\n");
|
|
748
788
|
const sections = [
|
|
749
789
|
await environmentSection(cwd),
|
|
750
790
|
profile.identity ?? IDENTITY,
|
|
751
|
-
DOING_TASKS
|
|
752
|
-
DENIALS
|
|
791
|
+
DOING_TASKS
|
|
753
792
|
];
|
|
793
|
+
if (tools.some((tool) => tool.name === "todo")) sections.push(TODO_USAGE);
|
|
794
|
+
if (skills.length > 0) sections.push(skillsSection(skills));
|
|
795
|
+
sections.push(DENIALS);
|
|
754
796
|
if (tools.some((tool) => tool.name === "task")) sections.push(DELEGATION);
|
|
755
797
|
sections.push(`# Available tools\n\n${toolCatalog}`, TOOL_RULES);
|
|
756
798
|
const agentsDocs = await collectAgentsMd(cwd);
|
|
757
799
|
if (agentsDocs.length > 0) sections.push(`# Project instructions (AGENTS.md)\n\n${agentsDocs.join("\n\n")}`);
|
|
758
800
|
return sections.join("\n\n");
|
|
759
801
|
}
|
|
802
|
+
/** Tier-1 skill catalog: `name — description` rows, 200-char descriptions, ~2000-char cap. */
|
|
803
|
+
const SKILL_DESCRIPTION_SHOWN = 200;
|
|
804
|
+
const SKILL_CATALOG_CAP = 2e3;
|
|
805
|
+
const SKILLS_INTRO = "# Skills\n\nSlash commands available (/name [args]) — invoking one injects the skill's full body into the task:";
|
|
806
|
+
function skillsSection(skills) {
|
|
807
|
+
const kept = [];
|
|
808
|
+
let dropped = 0;
|
|
809
|
+
const render = () => {
|
|
810
|
+
const parts = [...kept];
|
|
811
|
+
if (dropped > 0) parts.push(`(+${dropped} more)`);
|
|
812
|
+
return [SKILLS_INTRO, ...parts].join("\n");
|
|
813
|
+
};
|
|
814
|
+
for (const skill of skills) {
|
|
815
|
+
const description = skill.description;
|
|
816
|
+
kept.push(`- ${skill.name} — ${description.length > SKILL_DESCRIPTION_SHOWN ? `${description.slice(0, SKILL_DESCRIPTION_SHOWN)}...` : description}`);
|
|
817
|
+
while (kept.length > 1 && render().length > SKILL_CATALOG_CAP) {
|
|
818
|
+
kept.shift();
|
|
819
|
+
dropped += 1;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return render();
|
|
823
|
+
}
|
|
760
824
|
/** Environment facts first; the branch line is omitted rather than fabricated. */
|
|
761
825
|
async function environmentSection(cwd) {
|
|
762
826
|
const branch = await resolveBranch(await findGitRoot(path.resolve(cwd)));
|
|
@@ -814,6 +878,173 @@ async function findGitRoot(dir) {
|
|
|
814
878
|
}
|
|
815
879
|
}
|
|
816
880
|
//#endregion
|
|
881
|
+
//#region src/skills.ts
|
|
882
|
+
/** agentskills.io name rules: 1–64 chars, [a-z0-9-], no lead/trail/consecutive `-`. */
|
|
883
|
+
const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
884
|
+
const NAME_MAX = 64;
|
|
885
|
+
const DESCRIPTION_MAX = 1024;
|
|
886
|
+
const COMPATIBILITY_MAX = 500;
|
|
887
|
+
/**
|
|
888
|
+
* Scans each root one level deep for `<name>/SKILL.md`. Roots are in
|
|
889
|
+
* precedence order (project before user; the caller decides) and the first
|
|
890
|
+
* occurrence of a name wins. Nonexistent or unreadable roots contribute
|
|
891
|
+
* nothing; unreadable directories are skipped silently; a skill whose
|
|
892
|
+
* frontmatter parses but fails validation is skipped with a one-line notice.
|
|
893
|
+
*/
|
|
894
|
+
async function loadSkills(roots, options = {}) {
|
|
895
|
+
const onNotice = options.onNotice;
|
|
896
|
+
const byName = /* @__PURE__ */ new Map();
|
|
897
|
+
for (const root of roots) {
|
|
898
|
+
let entries;
|
|
899
|
+
try {
|
|
900
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
901
|
+
} catch {
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
for (const entry of entries) {
|
|
905
|
+
if (!entry.isDirectory()) continue;
|
|
906
|
+
const dir = path.join(root, entry.name);
|
|
907
|
+
let read;
|
|
908
|
+
try {
|
|
909
|
+
read = await readSkill(dir, entry.name, onNotice);
|
|
910
|
+
} catch {
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
if ("reason" in read) {
|
|
914
|
+
onNotice?.(`skill '${entry.name}' skipped: ${read.reason}`);
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
if (byName.has(read.skill.name)) {
|
|
918
|
+
onNotice?.(`skill '${read.skill.name}' from ${dir} skipped: duplicate name (first in precedence wins)`);
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
byName.set(read.skill.name, read.skill);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
return [...byName.values()];
|
|
925
|
+
}
|
|
926
|
+
/** Tier-2 load: the full markdown body below the frontmatter delimiter. */
|
|
927
|
+
async function loadSkillBody(skill) {
|
|
928
|
+
const doc = parseFrontmatter((await readFile(path.join(skill.dir, "SKILL.md"), "utf8")).replaceAll("\r\n", "\n"));
|
|
929
|
+
if (doc === void 0) return "";
|
|
930
|
+
return doc.body.trim();
|
|
931
|
+
}
|
|
932
|
+
/** Reads and validates one `<dir>/SKILL.md`; a validation failure skips it. */
|
|
933
|
+
async function readSkill(dir, dirName, onNotice) {
|
|
934
|
+
let text;
|
|
935
|
+
try {
|
|
936
|
+
text = (await readFile(path.join(dir, "SKILL.md"), "utf8")).replaceAll("\r\n", "\n");
|
|
937
|
+
} catch {
|
|
938
|
+
return { reason: "no readable SKILL.md" };
|
|
939
|
+
}
|
|
940
|
+
const doc = parseFrontmatter(text);
|
|
941
|
+
if (doc === void 0) return { reason: "missing or malformed frontmatter (expects --- delimited YAML)" };
|
|
942
|
+
const name = doc.fields.name;
|
|
943
|
+
if (name === void 0 || name.length === 0) return { reason: "missing name" };
|
|
944
|
+
if (name.length > NAME_MAX || !NAME_PATTERN.test(name)) return { reason: `invalid name "${name}" (1-${NAME_MAX} chars, [a-z0-9-], no lead/trail/double -)` };
|
|
945
|
+
const description = doc.fields.description;
|
|
946
|
+
if (description === void 0 || description.length === 0) return { reason: "missing description" };
|
|
947
|
+
if (description.length > DESCRIPTION_MAX) return { reason: `description exceeds ${DESCRIPTION_MAX} chars (${description.length})` };
|
|
948
|
+
const compatibility = doc.fields.compatibility;
|
|
949
|
+
if (compatibility !== void 0 && compatibility.length > COMPATIBILITY_MAX) return { reason: `compatibility exceeds ${COMPATIBILITY_MAX} chars (${compatibility.length})` };
|
|
950
|
+
if (name !== dirName) onNotice?.(`skill '${dirName}': frontmatter name '${name}' ignored (name ≠ dir)`);
|
|
951
|
+
return { skill: {
|
|
952
|
+
name: dirName,
|
|
953
|
+
description,
|
|
954
|
+
dir,
|
|
955
|
+
frontmatter: {
|
|
956
|
+
name,
|
|
957
|
+
description,
|
|
958
|
+
...doc.fields.license === void 0 ? {} : { license: doc.fields.license },
|
|
959
|
+
...compatibility === void 0 ? {} : { compatibility },
|
|
960
|
+
...Object.keys(doc.metadata).length === 0 ? {} : { metadata: doc.metadata }
|
|
961
|
+
}
|
|
962
|
+
} };
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Line-based YAML frontmatter for the SKILL.md subset: `key: value` pairs
|
|
966
|
+
* split on the FIRST colon per line, so unquoted colons inside values parse
|
|
967
|
+
* (the gemini-cli bug); surrounding single/double quotes are stripped. Only
|
|
968
|
+
* one nesting level is supported, under `metadata:`. Returns undefined when
|
|
969
|
+
* the delimiters are missing or a line is not a plain `key: value` pair.
|
|
970
|
+
*/
|
|
971
|
+
function parseFrontmatter(text) {
|
|
972
|
+
const lines = text.split("\n");
|
|
973
|
+
if ((lines[0] ?? "").trimEnd() !== "---") return void 0;
|
|
974
|
+
const fields = {};
|
|
975
|
+
const metadata = {};
|
|
976
|
+
let inMetadata = false;
|
|
977
|
+
let bodyStart = -1;
|
|
978
|
+
let current;
|
|
979
|
+
const flush = () => {
|
|
980
|
+
if (current === void 0) return;
|
|
981
|
+
const folded = current.mode === void 0 ? current.value : current.mode === "fold" ? current.value.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join(" ") : current.value.replace(/^\n+/, "");
|
|
982
|
+
current.map[current.key] = folded.trim();
|
|
983
|
+
current = void 0;
|
|
984
|
+
};
|
|
985
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
986
|
+
const line = lines[i] ?? "";
|
|
987
|
+
if (line.trimEnd() === "---") {
|
|
988
|
+
flush();
|
|
989
|
+
bodyStart = i + 1;
|
|
990
|
+
break;
|
|
991
|
+
}
|
|
992
|
+
if (line.trim().length === 0) {
|
|
993
|
+
if (current !== void 0 && current.mode !== void 0) current.value += "\n";
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
if (/^\s/.test(line)) {
|
|
997
|
+
if (current !== void 0 && current.mode !== void 0) {
|
|
998
|
+
current.value += `\n${line.trim()}`;
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
if (inMetadata) {
|
|
1002
|
+
const entry = splitKeyValue(line.trim());
|
|
1003
|
+
if (entry === void 0) return void 0;
|
|
1004
|
+
metadata[entry[0]] = entry[1];
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
flush();
|
|
1010
|
+
const pair = splitKeyValue(line);
|
|
1011
|
+
if (pair === void 0) return void 0;
|
|
1012
|
+
const indicator = /^[|>][+-]?\d*$/.exec(pair[1]);
|
|
1013
|
+
if (indicator === null) {
|
|
1014
|
+
inMetadata = pair[0] === "metadata" && pair[1].length === 0;
|
|
1015
|
+
if (!inMetadata) fields[pair[0]] = pair[1];
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
inMetadata = false;
|
|
1019
|
+
current = {
|
|
1020
|
+
map: fields,
|
|
1021
|
+
key: pair[0],
|
|
1022
|
+
mode: indicator[0].startsWith("|") ? "keep" : "fold",
|
|
1023
|
+
value: ""
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
if (bodyStart === -1) return void 0;
|
|
1027
|
+
return {
|
|
1028
|
+
fields,
|
|
1029
|
+
metadata,
|
|
1030
|
+
body: lines.slice(bodyStart).join("\n")
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Splits on the FIRST colon (values may contain colons), trims both sides,
|
|
1035
|
+
* and strips one pair of surrounding single/double quotes. Undefined for a
|
|
1036
|
+
* missing colon or an empty key.
|
|
1037
|
+
*/
|
|
1038
|
+
function splitKeyValue(line) {
|
|
1039
|
+
const colon = line.indexOf(":");
|
|
1040
|
+
if (colon <= 0) return void 0;
|
|
1041
|
+
const key = line.slice(0, colon).trim();
|
|
1042
|
+
if (key.length === 0) return void 0;
|
|
1043
|
+
let value = line.slice(colon + 1).trim();
|
|
1044
|
+
if (value.length >= 2 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
|
|
1045
|
+
return [key, value];
|
|
1046
|
+
}
|
|
1047
|
+
//#endregion
|
|
817
1048
|
//#region src/subagent.ts
|
|
818
1049
|
/** The returned summary is capped at 50 KiB; the full transcript stays in the child session. */
|
|
819
1050
|
const SUMMARY_CAP_CHARS = 51200;
|
|
@@ -880,4 +1111,92 @@ async function spawnSubagent(input, deps, ctx) {
|
|
|
880
1111
|
};
|
|
881
1112
|
}
|
|
882
1113
|
//#endregion
|
|
883
|
-
|
|
1114
|
+
//#region src/todo.ts
|
|
1115
|
+
/** Valid TodoStep status values, in checklist order. */
|
|
1116
|
+
const STATUSES = [
|
|
1117
|
+
"pending",
|
|
1118
|
+
"in_progress",
|
|
1119
|
+
"completed"
|
|
1120
|
+
];
|
|
1121
|
+
/**
|
|
1122
|
+
* The `todo` tool: whole-list checklist replace with the
|
|
1123
|
+
* exactly-one-in_progress invariant (extras normalize to pending, first
|
|
1124
|
+
* keeps the slot). readOnly: the checklist mutates nothing on disk.
|
|
1125
|
+
*/
|
|
1126
|
+
function createTodoTool(deps) {
|
|
1127
|
+
return {
|
|
1128
|
+
name: "todo",
|
|
1129
|
+
description: "Track a multi-step plan: replaces the whole checklist with `items` ({content, status: pending|in_progress|completed}). Exactly one step stays in_progress (extras normalize to pending); lay out the plan first and keep it current as you work. An empty list clears the checklist.",
|
|
1130
|
+
inputSchema: {
|
|
1131
|
+
type: "object",
|
|
1132
|
+
properties: { items: {
|
|
1133
|
+
type: "array",
|
|
1134
|
+
description: "The full checklist; replaces the previous list wholesale.",
|
|
1135
|
+
items: {
|
|
1136
|
+
type: "object",
|
|
1137
|
+
properties: {
|
|
1138
|
+
content: {
|
|
1139
|
+
type: "string",
|
|
1140
|
+
description: "One imperative checklist row."
|
|
1141
|
+
},
|
|
1142
|
+
status: {
|
|
1143
|
+
type: "string",
|
|
1144
|
+
enum: [...STATUSES]
|
|
1145
|
+
}
|
|
1146
|
+
},
|
|
1147
|
+
required: ["content", "status"]
|
|
1148
|
+
}
|
|
1149
|
+
} },
|
|
1150
|
+
required: ["items"]
|
|
1151
|
+
},
|
|
1152
|
+
readOnly: true,
|
|
1153
|
+
handler: async (input) => {
|
|
1154
|
+
const raw = input.items;
|
|
1155
|
+
if (!Array.isArray(raw)) return "Error: todo requires an `items` array of {content, status} rows.";
|
|
1156
|
+
const steps = normalizeTodoSteps(raw);
|
|
1157
|
+
if (steps.length === 0) {
|
|
1158
|
+
deps.onTodo([]);
|
|
1159
|
+
return "todo: list cleared";
|
|
1160
|
+
}
|
|
1161
|
+
deps.onTodo(steps);
|
|
1162
|
+
return summarizeTodoSteps(steps);
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Lenient row normalization: drops non-object/blank rows, coerces unknown
|
|
1168
|
+
* statuses to pending, and enforces exactly one in_progress (the first keeps
|
|
1169
|
+
* the slot; extras become pending).
|
|
1170
|
+
*/
|
|
1171
|
+
function normalizeTodoSteps(raw) {
|
|
1172
|
+
const steps = [];
|
|
1173
|
+
let inProgressSeen = false;
|
|
1174
|
+
for (const row of raw) {
|
|
1175
|
+
if (typeof row !== "object" || row === null) continue;
|
|
1176
|
+
const record = row;
|
|
1177
|
+
const content = typeof record.content === "string" ? record.content.trim() : "";
|
|
1178
|
+
if (content.length === 0) continue;
|
|
1179
|
+
const status = STATUSES.find((candidate) => candidate === record.status) ?? "pending";
|
|
1180
|
+
if (status === "in_progress" && inProgressSeen) {
|
|
1181
|
+
steps.push({
|
|
1182
|
+
content,
|
|
1183
|
+
status: "pending"
|
|
1184
|
+
});
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
if (status === "in_progress") inProgressSeen = true;
|
|
1188
|
+
steps.push({
|
|
1189
|
+
content,
|
|
1190
|
+
status
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
return steps;
|
|
1194
|
+
}
|
|
1195
|
+
/** One-line summary the model sees: counts per status in fixed order. */
|
|
1196
|
+
function summarizeTodoSteps(steps) {
|
|
1197
|
+
const done = steps.filter((step) => step.status === "completed").length;
|
|
1198
|
+
const active = steps.filter((step) => step.status === "in_progress").length;
|
|
1199
|
+
return `todo: ${done} done, ${active} in progress, ${steps.length - done - active} pending`;
|
|
1200
|
+
}
|
|
1201
|
+
//#endregion
|
|
1202
|
+
export { COMPACTED_MARKER, COMPACT_PROMPT, DEFAULT_COMPACTION_KEEP_RECENT, DEFAULT_COMPACTION_RESERVE, DEFAULT_SESSIONS_ROOT, alignedMessageOrdinals, buildSystemPrompt, compactConversation, compactSession, compactedSummaryMessage, createCommandRegistry, createSessionStore, createTodoTool, estimateMessageTokens, estimateTokens, loadNewestSessionId, loadSkillBody, loadSkills, looksLikeContextOverflow, normalizeTodoSteps, resolveModelProfile, resumeSessionStore, runAgent, serializeConversation, sessionView, sessionsDirFor, shouldCompact, spawnSubagent, summarizeTodoSteps };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chantier/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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.
|
|
39
|
+
"@chantier/permissions": "^0.6.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"tsdown": "0.23.0"
|