@zerwiz/ymir 0.1.16 → 0.1.18
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/.agents/config/app-repos.yaml +38 -0
- package/.claude/settings.json +31 -0
- package/.codex/hooks.json +43 -0
- package/.cursor/hooks.json +33 -0
- package/.pi/extensions/README.md +39 -0
- package/.pi/extensions/gna-pi-watch.ts +15 -0
- package/.pi/extensions/lib/ro-assistant-layout.ts +96 -0
- package/.pi/extensions/lib/ro-operational-user-layout.ts +145 -0
- package/.pi/extensions/lib/ro-visibility.ts +99 -0
- package/.pi/extensions/lib/ro-working-ship.ts +243 -0
- package/.pi/extensions/lib/rodd-operational-input.ts +62 -0
- package/.pi/extensions/lib/skuld-branch-dispatch.ts +252 -0
- package/.pi/extensions/lib/skuld-branch-model-picker.ts +77 -0
- package/.pi/extensions/lib/vordr-sessionstart-supervisor.mjs +48 -0
- package/.pi/extensions/ro.ts +11 -0
- package/.pi/extensions/skuld-branch-supervision.ts +11 -0
- package/.pi/extensions/syn-turnend-guard.ts +11 -0
- package/.pi/mcp.json +16 -0
- package/.pi/mcp.json.example +11 -0
- package/.pi/npm/.gitignore +2 -0
- package/.pi/settings.json +8 -0
- package/.pi/shared/extensions/gna-pi-watch.ts +750 -0
- package/.pi/shared/extensions/herdr-agent-state.ts +257 -0
- package/.pi/shared/extensions/open-editor.ts +509 -0
- package/.pi/shared/extensions/ro.ts +504 -0
- package/.pi/shared/extensions/skuld-branch-supervision.ts +1535 -0
- package/.pi/shared/extensions/syn-turnend-guard.ts +683 -0
- package/.pi/shared/extensions/todo.ts +133 -0
- package/.pi/shared/extensions/ymir-subagents.ts +287 -0
- package/.pi/shared/extensions/ymir-well.ts +130 -0
- package/CHANGELOG.md +23 -0
- package/assets/Ymir.md +4654 -0
- package/assets/healer.html +717 -0
- package/assets/icon-family/family-sheet.png +0 -0
- package/assets/icon-family/hlidskjalf.svg +1 -0
- package/assets/icon-family/sessrumnir.svg +1 -0
- package/assets/icon-family/smidja.svg +1 -0
- package/assets/icon-family/ymir.svg +1 -0
- package/assets/mimirswell.html +880 -0
- package/assets/mock/README.md +16 -0
- package/assets/mock/feeds.ts +175 -0
- package/assets/mock/mock.ts +408 -0
- package/assets/reference/docs/agent-control.md +122 -0
- package/assets/reference/docs/apodex/Appdex_as_Orchestrator_Planner_Reviewer_Assessment.md +176 -0
- package/assets/reference/docs/apodex/Final Report(1).md +198 -0
- package/assets/reference/docs/apodex/Final Report(2).md +54 -0
- package/assets/reference/docs/apodex/Final Report(3).md +40 -0
- package/assets/reference/docs/apodex/Final Report(4).md +234 -0
- package/assets/reference/docs/apodex/Final Report(5).md +13 -0
- package/assets/reference/docs/apodex/Final Report.md +82 -0
- package/assets/reference/docs/architecture.md +392 -0
- package/assets/reference/docs/arm-pretool-check.md +247 -0
- package/assets/reference/docs/calm-mode-feasibility.md +541 -0
- package/assets/reference/docs/calm.md +59 -0
- package/assets/reference/docs/captain-hold-lifecycle.md +96 -0
- package/assets/reference/docs/cd-guard.md +165 -0
- package/assets/reference/docs/cmux-backend.md +133 -0
- package/assets/reference/docs/codex-app-backend.md +57 -0
- package/assets/reference/docs/configuration.md +924 -0
- package/assets/reference/docs/documentation-audiences.json +468 -0
- package/assets/reference/docs/documentation-audiences.md +28 -0
- package/assets/reference/docs/examples/crew-dispatch.json +26 -0
- package/assets/reference/docs/examples/process-event-extension/file-signal.mjs +96 -0
- package/assets/reference/docs/examples/process-event-extension/firstmate-extension.json +15 -0
- package/assets/reference/docs/examples/watched-tools.json +24 -0
- package/assets/reference/docs/examples/wedge-alarm +22 -0
- package/assets/reference/docs/extension-bindings.md +237 -0
- package/assets/reference/docs/fm-test-isolation-proof.json +37 -0
- package/assets/reference/docs/fm-test-isolation-proof.md +149 -0
- package/assets/reference/docs/fm-test-portable-shards.md +118 -0
- package/assets/reference/docs/gitlab-merge-watch.md +275 -0
- package/assets/reference/docs/herdr-backend.md +346 -0
- package/assets/reference/docs/orca-backend.md +84 -0
- package/assets/reference/docs/pi-supervision-branch-poster.svg +125 -0
- package/assets/reference/docs/pi-supervision-branch.md +96 -0
- package/assets/reference/docs/plans/agentic-engineering-workflow.md +395 -0
- package/assets/reference/docs/plans/agentic-factory-implementation-plan.md +615 -0
- package/assets/reference/docs/plans/command-factory-enhancement-plan.md +420 -0
- package/assets/reference/docs/plans/phase-1-layer3-program-design.md +134 -0
- package/assets/reference/docs/plans/phase-2-vertical-slices.md +100 -0
- package/assets/reference/docs/plans/phase-3-measurable-goals.md +106 -0
- package/assets/reference/docs/plans/phase-4-context-budget.md +106 -0
- package/assets/reference/docs/plans/phase-5-pre-mortem-confidence.md +100 -0
- package/assets/reference/docs/plans/phase-6-incident-to-agent.md +109 -0
- package/assets/reference/docs/remote-secondmates.md +268 -0
- package/assets/reference/docs/scripts.md +146 -0
- package/assets/reference/docs/sessionstart-nudge.md +115 -0
- package/assets/reference/docs/subagent-guard.md +383 -0
- package/assets/reference/docs/supervision-protocols/claude.md +27 -0
- package/assets/reference/docs/supervision-protocols/codex.md +16 -0
- package/assets/reference/docs/supervision-protocols/cursor.md +31 -0
- package/assets/reference/docs/supervision-protocols/grok.md +39 -0
- package/assets/reference/docs/supervision-protocols/opencode.md +17 -0
- package/assets/reference/docs/supervision-protocols/pi.md +33 -0
- package/assets/reference/docs/supervision-protocols/unknown.md +13 -0
- package/assets/reference/docs/tmux-backend.md +115 -0
- package/assets/reference/docs/trace-context.md +117 -0
- package/assets/reference/docs/turnend-guard.md +170 -0
- package/assets/reference/docs/verification/dispatch-auth.md +204 -0
- package/assets/reference/docs/verification/muse.md +221 -0
- package/assets/reference/docs/verification/process-event-sources.md +208 -0
- package/assets/reference/docs/verification/public-followup.md +119 -0
- package/assets/reference/docs/verification/runtime-backends.md +1022 -0
- package/assets/reference/docs/verification/stow-memory.md +53 -0
- package/assets/reference/docs/verification/supervision.md +534 -0
- package/assets/reference/docs/verification/trace-context.md +31 -0
- package/assets/reference/docs/voice-relay.md +295 -0
- package/assets/reference/docs/watcher-continuity.md +119 -0
- package/assets/reference/docs/wedge-alarm.md +39 -0
- package/assets/reference/docs/zellij-backend.md +114 -0
- package/assets/reference/index.html +1172 -0
- package/assets/reference/skills/afk/SKILL.md +241 -0
- package/assets/reference/skills/ahoy/SKILL.md +56 -0
- package/assets/reference/skills/ask-user-authority/SKILL.md +56 -0
- package/assets/reference/skills/bearings/SKILL.md +161 -0
- package/assets/reference/skills/bearings/assets/board-template.html +731 -0
- package/assets/reference/skills/bootstrap-diagnostics/SKILL.md +68 -0
- package/assets/reference/skills/captain-hold-lifecycle/SKILL.md +54 -0
- package/assets/reference/skills/decision-hold-lifecycle/SKILL.md +15 -0
- package/assets/reference/skills/diagnostic-reasoning/SKILL.md +53 -0
- package/assets/reference/skills/firstmate-codexapp/SKILL.md +110 -0
- package/assets/reference/skills/firstmate-codexapp/agents/openai.yaml +4 -0
- package/assets/reference/skills/firstmate-coding-guidelines/SKILL.md +127 -0
- package/assets/reference/skills/firstmate-orca/SKILL.md +92 -0
- package/assets/reference/skills/fmx-respond/SKILL.md +283 -0
- package/assets/reference/skills/harness-adapters/SKILL.md +95 -0
- package/assets/reference/skills/harness-adapters/references/common/control-and-recovery.md +37 -0
- package/assets/reference/skills/harness-adapters/references/common/dispatch.md +32 -0
- package/assets/reference/skills/harness-adapters/references/common/model-and-effort.md +42 -0
- package/assets/reference/skills/harness-adapters/references/common/primary-hooks.md +40 -0
- package/assets/reference/skills/harness-adapters/references/harness/claude.md +55 -0
- package/assets/reference/skills/harness-adapters/references/harness/codex.md +43 -0
- package/assets/reference/skills/harness-adapters/references/harness/cursor.md +75 -0
- package/assets/reference/skills/harness-adapters/references/harness/grok.md +69 -0
- package/assets/reference/skills/harness-adapters/references/harness/kimi.md +51 -0
- package/assets/reference/skills/harness-adapters/references/harness/muse.md +70 -0
- package/assets/reference/skills/harness-adapters/references/harness/opencode.md +42 -0
- package/assets/reference/skills/harness-adapters/references/harness/pi.md +56 -0
- package/assets/reference/skills/process-event-sources/SKILL.md +137 -0
- package/assets/reference/skills/project-management/SKILL.md +92 -0
- package/assets/reference/skills/quota-array-dispatch/SKILL.md +131 -0
- package/assets/reference/skills/secondmate-provisioning/SKILL.md +254 -0
- package/assets/reference/skills/stow/SKILL.md +309 -0
- package/assets/reference/skills/stuck-crewmate-recovery/SKILL.md +54 -0
- package/assets/reference/skills/updatefirstmate/SKILL.md +64 -0
- package/assets/skills/README.md +5 -0
- package/assets/yggdrasil.html +649 -0
- package/assets/ymir-banner-01.png +0 -0
- package/assets/ymir-banner-02.png +0 -0
- package/assets/ymir-banner-03.png +0 -0
- package/assets/ymir-banner-04.png +0 -0
- package/assets/ymir-banner-05.png +0 -0
- package/assets/ymir-banner-06.png +0 -0
- package/assets/ymir-emblem-darksteel.svg +63 -0
- package/assets/ymir-emblem-forged.svg +55 -0
- package/assets/ymir-emblem-slate.svg +55 -0
- package/assets/ymir-mark-algiz-anvil.svg +9 -0
- package/assets/ymir-stave.svg +53 -0
- package/assets/ymir.html +563 -0
- package/bin/ymir-install.sh +6 -1
- package/data/learnings.md +33 -0
- package/data/local-models.md +34 -0
- package/data/operator.md +15 -0
- package/data/projects.md +28 -0
- package/data/realm.md +1 -0
- package/deploy/Containerfile +27 -0
- package/deploy/README.md +104 -0
- package/deploy/compose/compose.yaml +45 -0
- package/deploy/env.example +30 -0
- package/deploy/quadlet/ymir.container +57 -0
- package/package.json +10 -8
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todo — opencode-style task tracker for pi.
|
|
3
|
+
*
|
|
4
|
+
* Session-scoped todo list with 4 statuses and 3 priorities.
|
|
5
|
+
* State stored in session entries (survives branching).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
|
|
12
|
+
interface Todo {
|
|
13
|
+
content: string;
|
|
14
|
+
status: "pending" | "in_progress" | "completed" | "cancelled";
|
|
15
|
+
priority: "high" | "medium" | "low";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface TodoDetails {
|
|
19
|
+
action: string;
|
|
20
|
+
todos: Todo[];
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const StatusEnum = Type.Unsafe({ type: "string", enum: ["pending", "in_progress", "completed", "cancelled"] });
|
|
25
|
+
const PriorityEnum = Type.Unsafe({ type: "string", enum: ["high", "medium", "low"] });
|
|
26
|
+
|
|
27
|
+
const TodoParams = Type.Object({
|
|
28
|
+
action: Type.Unsafe({ type: "string", enum: ["list", "add", "update", "remove", "clear", "replace"], description: "Action: list, add, update, remove, clear, replace" }),
|
|
29
|
+
content: Type.Optional(Type.String({ description: "Task description (for add/update)" })),
|
|
30
|
+
priority: Type.Optional(PriorityEnum),
|
|
31
|
+
index: Type.Optional(Type.Number({ description: "Todo index 0-based (for update/remove)" })),
|
|
32
|
+
status: Type.Optional(StatusEnum),
|
|
33
|
+
todos: Type.Optional(Type.Array(Type.Object({ content: Type.String(), status: StatusEnum, priority: PriorityEnum }), { description: "Full todo list (for replace)" })),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
let todos: Todo[] = [];
|
|
37
|
+
|
|
38
|
+
function reconstructState(ctx: ExtensionContext) {
|
|
39
|
+
todos = [];
|
|
40
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
41
|
+
if (entry.type !== "message") continue;
|
|
42
|
+
const msg = entry.message;
|
|
43
|
+
if (msg.role !== "toolResult" || msg.toolName !== "todo") continue;
|
|
44
|
+
const details = msg.details as TodoDetails | undefined;
|
|
45
|
+
if (details?.todos) todos = [...details.todos];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const STATUS_ICONS: Record<string, string> = { pending: "○", in_progress: "►", completed: "✓", cancelled: "✗" };
|
|
50
|
+
const PRIORITY_LABELS: Record<string, string> = { high: "HIGH", medium: "med", low: "low" };
|
|
51
|
+
|
|
52
|
+
function formatTodoList(t: Todo[], theme: Theme, expanded: boolean): string {
|
|
53
|
+
if (t.length === 0) return theme.fg("dim", "No todos.");
|
|
54
|
+
const active = t.filter(x => x.status !== "completed" && x.status !== "cancelled");
|
|
55
|
+
const done = t.filter(x => x.status === "completed");
|
|
56
|
+
let out = theme.fg("muted", `${active.length} active, ${done.length} done`);
|
|
57
|
+
const display = expanded ? t : t.slice(0, 10);
|
|
58
|
+
for (let i = 0; i < display.length; i++) {
|
|
59
|
+
const todo = display[i];
|
|
60
|
+
const icon = STATUS_ICONS[todo.status] ?? "?";
|
|
61
|
+
const iconColor = todo.status === "completed" ? "success" : todo.status === "cancelled" ? "dim" : todo.status === "in_progress" ? "accent" : "muted";
|
|
62
|
+
const pri = PRIORITY_LABELS[todo.priority] ?? "";
|
|
63
|
+
const priColor = todo.priority === "high" ? "error" : todo.priority === "medium" ? "warning" : "dim";
|
|
64
|
+
let line = ` ${theme.fg(iconColor, icon)} ${theme.fg("accent", String(i))}`;
|
|
65
|
+
if (pri) line += ` ${theme.fg(priColor, pri.padEnd(4))}`;
|
|
66
|
+
line += ` ${todo.status === "completed" || todo.status === "cancelled" ? theme.fg("dim", todo.content) : theme.fg("text", todo.content)}`;
|
|
67
|
+
out += "\n" + truncateToWidth(line, 80);
|
|
68
|
+
}
|
|
69
|
+
if (!expanded && t.length > 10) out += `\n ${theme.fg("dim", `... ${t.length - 10} more`)}`;
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
class TodoUI {
|
|
74
|
+
private todoList: Todo[];
|
|
75
|
+
private theme: Theme;
|
|
76
|
+
private onClose: () => void;
|
|
77
|
+
constructor(todoList: Todo[], theme: Theme, onClose: () => void) { this.todoList = todoList; this.theme = theme; this.onClose = onClose; }
|
|
78
|
+
handleInput(data: string): void { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) this.onClose(); }
|
|
79
|
+
render(width: number): string[] {
|
|
80
|
+
const lines: string[] = []; const th = this.theme;
|
|
81
|
+
lines.push(""); const title = th.fg("accent", " Todos ");
|
|
82
|
+
lines.push(truncateToWidth(th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10))), width));
|
|
83
|
+
lines.push(""); lines.push(formatTodoList(this.todoList, th, true));
|
|
84
|
+
lines.push(""); lines.push(truncateToWidth(` ${th.fg("dim", "Escape to close")}`, width)); lines.push("");
|
|
85
|
+
return lines;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default function todoExtension(pi: ExtensionAPI) {
|
|
90
|
+
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
|
|
91
|
+
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
|
|
92
|
+
|
|
93
|
+
pi.registerTool({
|
|
94
|
+
name: "todo", label: "Todo",
|
|
95
|
+
description: "Manage a task list. Actions: list, add, update, remove, clear, replace",
|
|
96
|
+
promptSnippet: "Track multi-step work with todo list",
|
|
97
|
+
promptGuidelines: ["Use todo when work has 3+ steps or user provides multiple tasks.", "Mark exactly ONE todo as in_progress at a time.", "Mark tasks completed only after verification."],
|
|
98
|
+
parameters: TodoParams,
|
|
99
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
100
|
+
const action = params.action as string;
|
|
101
|
+
switch (action) {
|
|
102
|
+
case "list": return { content: [{ type: "text", text: todos.length ? todos.map((t, i) => `${i}: [${t.status}] [${t.priority}] ${t.content}`).join("\n") : "No todos." }], details: { action: "list", todos: [...todos] } as TodoDetails };
|
|
103
|
+
case "add": { if (!params.content) throw new Error("content required for add"); const priority = (params.priority as any) ?? "medium"; const newTodo: Todo = { content: params.content, status: "pending", priority }; todos.push(newTodo); return { content: [{ type: "text", text: `Added #${todos.length - 1}: ${newTodo.content}` }], details: { action: "add", todos: [...todos] } as TodoDetails }; }
|
|
104
|
+
case "update": { if (params.index === undefined) throw new Error("index required for update"); const idx = params.index; if (idx < 0 || idx >= todos.length) throw new Error(`Index ${idx} out of range`); const todo = todos[idx]; if (params.status) { if (params.status === "in_progress") { const existing = todos.find(t => t.status === "in_progress"); if (existing && existing !== todo) existing.status = "pending"; } todo.status = params.status as any; } if (params.priority) todo.priority = params.priority as any; if (params.content) todo.content = params.content; return { content: [{ type: "text", text: `Updated #${idx}: [${todo.status}] [${todo.priority}] ${todo.content}` }], details: { action: "update", todos: [...todos] } as TodoDetails }; }
|
|
105
|
+
case "remove": { if (params.index === undefined) throw new Error("index required for remove"); const idx = params.index; if (idx < 0 || idx >= todos.length) throw new Error(`Index ${idx} out of range`); const removed = todos.splice(idx, 1)[0]; return { content: [{ type: "text", text: `Removed #${idx}: ${removed.content}` }], details: { action: "remove", todos: [...todos] } as TodoDetails }; }
|
|
106
|
+
case "clear": { const count = todos.length; todos = []; return { content: [{ type: "text", text: `Cleared ${count} todos.` }], details: { action: "clear", todos: [] } as TodoDetails }; }
|
|
107
|
+
case "replace": { if (!params.todos) throw new Error("todos array required for replace"); todos = params.todos.map((t: any) => ({ content: t.content, status: t.status, priority: t.priority })); return { content: [{ type: "text", text: `Replaced with ${todos.length} todos.` }], details: { action: "replace", todos: [...todos] } as TodoDetails }; }
|
|
108
|
+
default: throw new Error(`Unknown action: ${action}`);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
renderCall(args, theme) {
|
|
112
|
+
let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
|
|
113
|
+
if (args.content) text += ` ${theme.fg("dim", `"${args.content}"`)}`;
|
|
114
|
+
if (args.index !== undefined) text += ` ${theme.fg("accent", `#${args.index}`)}`;
|
|
115
|
+
if (args.todos) text += ` ${theme.fg("muted", `(${args.todos.length} items)`)}`;
|
|
116
|
+
return new Text(text, 0, 0);
|
|
117
|
+
},
|
|
118
|
+
renderResult(result, { expanded }, theme) {
|
|
119
|
+
const details = result.details as TodoDetails | undefined;
|
|
120
|
+
if (!details) { const c = result.content[0]; return new Text(c?.type === "text" ? c.text : "", 0, 0); }
|
|
121
|
+
if (details.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
|
122
|
+
return new Text(formatTodoList(details.todos, theme, expanded), 0, 0);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
pi.registerCommand("todos", {
|
|
127
|
+
description: "Show the current todo list",
|
|
128
|
+
handler: async (_args, ctx) => {
|
|
129
|
+
if (ctx.mode !== "tui") { ctx.ui.notify("/todos requires interactive mode", "error"); return; }
|
|
130
|
+
await ctx.ui.custom<void>((_tui, theme, _kb, done) => { return new TodoUI(todos, theme, () => done()); });
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ymir-subagents — the Eindri roster as a Pi tool.
|
|
3
|
+
*
|
|
4
|
+
* Pi core has no agent loader: agents come from a package, and Ymir installs
|
|
5
|
+
* none. That left `.pi/agents/` holding twenty correct profiles that nothing in
|
|
6
|
+
* Pi ever read. This extension closes that gap inside the repo, so Ymir needs no
|
|
7
|
+
* root-pi package: it reads the canonical `.agents/agents/*.md` tree directly and
|
|
8
|
+
* exposes every figure as a callable subagent.
|
|
9
|
+
*
|
|
10
|
+
* The canonical tree is the single source of truth (Rule 02). This file reads it;
|
|
11
|
+
* it never copies from it.
|
|
12
|
+
*
|
|
13
|
+
* subagent({ agent: "kvasir", task: "find every AGENTS.md" })
|
|
14
|
+
*
|
|
15
|
+
* The chosen figure's markdown body becomes the system prompt, and its
|
|
16
|
+
* frontmatter decides the model. The run is a nested model call in this session,
|
|
17
|
+
* so no pane and no subprocess are needed.
|
|
18
|
+
*
|
|
19
|
+
* Placement: this file belongs to the SHARED source, which the loader deploys to
|
|
20
|
+
* the global extension home — pi auto-discovers BOTH the global home and a
|
|
21
|
+
* project-local .pi/extensions, and the same extension in both registers its
|
|
22
|
+
* tools twice, which pi refuses. ONE home. Never copy this into a project.
|
|
23
|
+
*
|
|
24
|
+
* Contract (pi.dev/docs/latest/extensions):
|
|
25
|
+
* - export a default factory receiving ExtensionAPI (sync or async)
|
|
26
|
+
* - registerTool works during load and at runtime
|
|
27
|
+
* - do not start background resources in the factory
|
|
28
|
+
*
|
|
29
|
+
* No imports: `@earendil-works/pi-coding-agent` is not installed as a package,
|
|
30
|
+
* so an extension that imports its types cannot load at all. The working
|
|
31
|
+
* extensions in this tree take `pi` as `any` and declare parameters as plain
|
|
32
|
+
* JSON schema. This one follows them.
|
|
33
|
+
*/
|
|
34
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
35
|
+
import { join, resolve } from "node:path";
|
|
36
|
+
|
|
37
|
+
/** A figure as the canonical tree declares it. */
|
|
38
|
+
type Figure = {
|
|
39
|
+
name: string;
|
|
40
|
+
description: string;
|
|
41
|
+
model?: string;
|
|
42
|
+
body: string;
|
|
43
|
+
file: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse a `---` YAML frontmatter block without a YAML dependency.
|
|
48
|
+
*
|
|
49
|
+
* The profiles use only flat scalars for the fields this extension needs
|
|
50
|
+
* (`name`, `description`, `model`), so a strict line scan is enough and avoids
|
|
51
|
+
* pulling a parser into an extension that must load in every session. Anything
|
|
52
|
+
* it cannot read is simply absent.
|
|
53
|
+
*/
|
|
54
|
+
function frontmatter(text: string): Record<string, string> {
|
|
55
|
+
const out: Record<string, string> = {};
|
|
56
|
+
if (!text.startsWith("---")) return out;
|
|
57
|
+
const end = text.indexOf("\n---", 3);
|
|
58
|
+
if (end === -1) return out;
|
|
59
|
+
for (const line of text.slice(3, end).split("\n")) {
|
|
60
|
+
const m = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/.exec(line);
|
|
61
|
+
if (!m) continue;
|
|
62
|
+
let value = m[2].trim();
|
|
63
|
+
// Strip a matching pair of surrounding quotes; keep the inner text verbatim.
|
|
64
|
+
if (
|
|
65
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
66
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
67
|
+
) {
|
|
68
|
+
value = value.slice(1, -1);
|
|
69
|
+
}
|
|
70
|
+
out[m[1]] = value;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The body after the frontmatter block — the figure's system prompt. */
|
|
76
|
+
function bodyOf(text: string): string {
|
|
77
|
+
if (!text.startsWith("---")) return text;
|
|
78
|
+
const end = text.indexOf("\n---", 3);
|
|
79
|
+
if (end === -1) return text;
|
|
80
|
+
return text.slice(end + 4).replace(/^\s*\n/, "");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Find the canonical agents tree.
|
|
85
|
+
*
|
|
86
|
+
* Pi's project root is the nearest ancestor holding `.pi`; Ymir keeps its agents
|
|
87
|
+
* at `.agents/agents` beside it. Walk up so a session started in a worktree or a
|
|
88
|
+
* subdirectory still finds the one tree, and fall back to the global home.
|
|
89
|
+
*/
|
|
90
|
+
function findAgentsDir(cwd: string): string | undefined {
|
|
91
|
+
let dir = resolve(cwd);
|
|
92
|
+
while (true) {
|
|
93
|
+
const candidate = join(dir, ".agents", "agents");
|
|
94
|
+
if (existsSync(candidate)) return candidate;
|
|
95
|
+
const parent = resolve(dir, "..");
|
|
96
|
+
if (parent === dir) break;
|
|
97
|
+
dir = parent;
|
|
98
|
+
}
|
|
99
|
+
const home = process.env.HOME ?? "";
|
|
100
|
+
const global = join(home, ".agents", "agents");
|
|
101
|
+
return existsSync(global) ? global : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Read every profile in the tree, keyed by its frontmatter `name:`. */
|
|
105
|
+
function loadFigures(dir: string): Map<string, Figure> {
|
|
106
|
+
const figures = new Map<string, Figure>();
|
|
107
|
+
let entries: string[];
|
|
108
|
+
try {
|
|
109
|
+
entries = readdirSync(dir);
|
|
110
|
+
} catch {
|
|
111
|
+
return figures;
|
|
112
|
+
}
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
if (!entry.endsWith(".md")) continue;
|
|
115
|
+
const file = join(dir, entry);
|
|
116
|
+
let text: string;
|
|
117
|
+
try {
|
|
118
|
+
text = readFileSync(file, "utf-8");
|
|
119
|
+
} catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const fm = frontmatter(text);
|
|
123
|
+
const name = fm.name || entry.replace(/\.md$/, "");
|
|
124
|
+
figures.set(name, {
|
|
125
|
+
name,
|
|
126
|
+
description: fm.description ?? "",
|
|
127
|
+
model: fm.model,
|
|
128
|
+
body: bodyOf(text),
|
|
129
|
+
file,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return figures;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The roster as TOON — the house format for an agent-facing answer. */
|
|
136
|
+
function rosterText(figures: Map<string, Figure>): string {
|
|
137
|
+
if (figures.size === 0) {
|
|
138
|
+
return "subagents: 0 figures found — is .agents/agents present?";
|
|
139
|
+
}
|
|
140
|
+
const rows = [...figures.values()]
|
|
141
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
142
|
+
.map((f) => ` "${f.name}","${f.description.replace(/"/g, "'")}"`);
|
|
143
|
+
return `subagents[${figures.size}]{name,description}:\n${rows.join("\n")}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export default function subagents(pi: any) {
|
|
147
|
+
// The tree is read once per session, not per tool call: a figure's profile
|
|
148
|
+
// does not change mid-session, and re-reading twenty files on every dispatch
|
|
149
|
+
// would cost more than the dispatch.
|
|
150
|
+
let figures = new Map<string, Figure>();
|
|
151
|
+
|
|
152
|
+
pi.on("session_start", async (_event: any, ctx: any) => {
|
|
153
|
+
const dir = findAgentsDir(ctx?.cwd ?? process.cwd());
|
|
154
|
+
figures = dir ? loadFigures(dir) : new Map();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
pi.registerTool({
|
|
158
|
+
name: "subagent",
|
|
159
|
+
description:
|
|
160
|
+
"Dispatch one of Ymir's Eindri figures as a subagent, or list them. Reads " +
|
|
161
|
+
"the canonical .agents/agents/*.md tree. Use for a task that fits a " +
|
|
162
|
+
"figure's craft — kvasir (recon), sindri (code), bragi (marketing), " +
|
|
163
|
+
"huginn (research), forseti (review), mimir (planning), and the rest.",
|
|
164
|
+
parameters: {
|
|
165
|
+
type: "object",
|
|
166
|
+
properties: {
|
|
167
|
+
agent: {
|
|
168
|
+
type: "string",
|
|
169
|
+
description:
|
|
170
|
+
"The figure to dispatch, by name (e.g. kvasir, sindri, bragi). Omit to list.",
|
|
171
|
+
},
|
|
172
|
+
task: {
|
|
173
|
+
type: "string",
|
|
174
|
+
description: "The task to hand the figure.",
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
required: [],
|
|
178
|
+
},
|
|
179
|
+
async execute(_id: string, params: { agent?: string; task?: string }, _signal?: any, _onUpdate?: any, ctx?: any) {
|
|
180
|
+
// A list request is answered without a model call — it is a lookup, and
|
|
181
|
+
// the roster is already in hand.
|
|
182
|
+
if (!params?.agent || !params?.task) {
|
|
183
|
+
return { content: [{ type: "text", text: rosterText(figures) }] };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const figure = figures.get(params.agent);
|
|
187
|
+
if (!figure) {
|
|
188
|
+
const known = [...figures.keys()].sort().join(", ") || "none";
|
|
189
|
+
return {
|
|
190
|
+
content: [
|
|
191
|
+
{
|
|
192
|
+
type: "text",
|
|
193
|
+
text: `error: no figure named "${params.agent}"\nhelp: known figures: ${known}`,
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
isError: true,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Resolve the figure's model. A profile that names one gets it when the
|
|
201
|
+
// machine serves it; otherwise the session's own model carries the run, so
|
|
202
|
+
// a dispatch never fails for want of a model that is not available.
|
|
203
|
+
let model = ctx?.model;
|
|
204
|
+
if (figure.model && ctx?.modelRegistry) {
|
|
205
|
+
try {
|
|
206
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
207
|
+
const match = available.find(
|
|
208
|
+
(m: any) => `${m.provider}/${m.id}` === figure.model || m.id === figure.model,
|
|
209
|
+
);
|
|
210
|
+
if (match) model = match;
|
|
211
|
+
} catch {
|
|
212
|
+
// keep the session model
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (!model || !ctx?.modelRegistry?.streamSimple) {
|
|
216
|
+
return {
|
|
217
|
+
content: [
|
|
218
|
+
{
|
|
219
|
+
type: "text",
|
|
220
|
+
text: `error: no model available for subagent "${figure.name}"`,
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
isError: true,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// The figure's body is its system prompt; the task is the user turn. This
|
|
228
|
+
// is the whole contract — the profile IS the agent.
|
|
229
|
+
const stream = ctx.modelRegistry.streamSimple(
|
|
230
|
+
model,
|
|
231
|
+
{
|
|
232
|
+
systemPrompt: figure.body,
|
|
233
|
+
messages: [{ role: "user", content: [{ type: "text", text: params.task }] }],
|
|
234
|
+
},
|
|
235
|
+
{ signal: _signal },
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
let text = "";
|
|
239
|
+
try {
|
|
240
|
+
for await (const event of stream) {
|
|
241
|
+
if (event?.type === "text_delta" && event.delta) text += event.delta;
|
|
242
|
+
}
|
|
243
|
+
} catch (err: any) {
|
|
244
|
+
return {
|
|
245
|
+
content: [
|
|
246
|
+
{
|
|
247
|
+
type: "text",
|
|
248
|
+
text: `error: subagent "${figure.name}" failed mid-run: ${err?.message ?? err}`,
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
isError: true,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (!text) {
|
|
255
|
+
try {
|
|
256
|
+
const result = await stream.result();
|
|
257
|
+
for (const part of result?.content ?? []) {
|
|
258
|
+
if (part?.type === "text") text += part.text;
|
|
259
|
+
}
|
|
260
|
+
} catch {
|
|
261
|
+
// leave text empty; the header below still reports 0 chars
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const header = `subagent[1]{figure,model,chars}:\n "${figure.name}","${model.provider}/${model.id}",${text.length}`;
|
|
266
|
+
return {
|
|
267
|
+
content: [{ type: "text", text: `${header}\n\n${text}` }],
|
|
268
|
+
details: { figure: figure.name, model: `${model.provider}/${model.id}` },
|
|
269
|
+
};
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
pi.registerCommand("subagents", {
|
|
274
|
+
description: "List the Eindri figures available as subagents",
|
|
275
|
+
handler: async (_args: string, ctx: any) => {
|
|
276
|
+
const dir = findAgentsDir(ctx?.cwd ?? process.cwd());
|
|
277
|
+
const found = dir ? loadFigures(dir) : new Map<string, Figure>();
|
|
278
|
+
const names = [...found.keys()].sort();
|
|
279
|
+
ctx?.ui?.notify(
|
|
280
|
+
names.length
|
|
281
|
+
? `${names.length} figures: ${names.join(", ")}`
|
|
282
|
+
: "no figures found — is .agents/agents present?",
|
|
283
|
+
"info",
|
|
284
|
+
);
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ymir-well — Kaia's memory, as tools inside every pi session.
|
|
3
|
+
*
|
|
4
|
+
* The well is Mimirsbrunn: the engram store at $YMIR_HOME/memory/kaia.engram,
|
|
5
|
+
* reached over the bridge on :4602. Every worker Ymir seats should be able to
|
|
6
|
+
* recall what was learned and observe what it learned — that is what makes the
|
|
7
|
+
* fleet learn instead of re-learning. Before this, only the orchestrator could,
|
|
8
|
+
* and only through smidja_orchestrate.py.
|
|
9
|
+
*
|
|
10
|
+
* Placement: this file belongs to the SHARED source, which the loader deploys to
|
|
11
|
+
* the global extension home — pi auto-discovers BOTH the global home and a
|
|
12
|
+
* project-local .pi/extensions, and the same extension in both registers its
|
|
13
|
+
* tools twice, which pi refuses. ONE home. Never copy this into a project.
|
|
14
|
+
*
|
|
15
|
+
* Contract (pi.dev/docs/latest/extensions):
|
|
16
|
+
* - export a default factory receiving ExtensionAPI (sync or async)
|
|
17
|
+
* - registerTool works during load and at runtime
|
|
18
|
+
* - do not start background resources in the factory
|
|
19
|
+
*/
|
|
20
|
+
export default function well(pi: any) {
|
|
21
|
+
const BRIDGE = process.env.YMIR_MEMORY_URL ?? "http://127.0.0.1:4602";
|
|
22
|
+
|
|
23
|
+
/** One shape for every answer: a tool that throws teaches the model nothing. */
|
|
24
|
+
async function call(path: string, init?: RequestInit) {
|
|
25
|
+
const res = await fetch(new URL(path, BRIDGE).toString(), {
|
|
26
|
+
...init,
|
|
27
|
+
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
|
|
28
|
+
signal: AbortSignal.timeout(15000),
|
|
29
|
+
});
|
|
30
|
+
const body = await res.text();
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
return { ok: false as const, status: res.status, body: body.slice(0, 400) };
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
return { ok: true as const, body: JSON.parse(body) };
|
|
36
|
+
} catch {
|
|
37
|
+
return { ok: true as const, body };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pi.registerTool({
|
|
42
|
+
name: "well_recall",
|
|
43
|
+
description:
|
|
44
|
+
"Recall from Kaia's memory (the well) what Ymir already learned. Use before " +
|
|
45
|
+
"starting work: a lesson already paid for costs nothing the second time. " +
|
|
46
|
+
"Returns episodes with their score, content and tags.",
|
|
47
|
+
parameters: {
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: {
|
|
50
|
+
query: { type: "string", description: "what you want to remember, in words" },
|
|
51
|
+
k: { type: "number", description: "how many episodes (default 5)" },
|
|
52
|
+
},
|
|
53
|
+
required: ["query"],
|
|
54
|
+
},
|
|
55
|
+
async execute(_id: string, params: { query: string; k?: number }) {
|
|
56
|
+
const k = Math.max(1, Math.min(20, Number(params.k ?? 5)));
|
|
57
|
+
const r = await call(`/recall?q=${encodeURIComponent(params.query)}&k=${k}`);
|
|
58
|
+
if (!r.ok) {
|
|
59
|
+
return { content: [{ type: "text", text: `the well did not answer (${r.status}): ${r.body}` }], isError: true };
|
|
60
|
+
}
|
|
61
|
+
const hits = (r.body as any)?.results ?? [];
|
|
62
|
+
if (!hits.length) {
|
|
63
|
+
return { content: [{ type: "text", text: "the well holds nothing for that yet." }] };
|
|
64
|
+
}
|
|
65
|
+
const text = hits
|
|
66
|
+
.map((h: any, i: number) => {
|
|
67
|
+
const e = h.episode ?? {};
|
|
68
|
+
const tags = Array.isArray(e.tags) ? e.tags.join(", ") : String(e.tags ?? "");
|
|
69
|
+
return `${i + 1}. [${Number(h.score ?? 0).toFixed(2)}] ${String(e.content ?? "").slice(0, 600)}${tags ? `\n tags: ${tags}` : ""}`;
|
|
70
|
+
})
|
|
71
|
+
.join("\n");
|
|
72
|
+
return { content: [{ type: "text", text }] };
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
pi.registerTool({
|
|
77
|
+
name: "well_observe",
|
|
78
|
+
description:
|
|
79
|
+
"Write a lesson into Kaia's memory (the well). Use when a run teaches " +
|
|
80
|
+
"something durable — what worked, what failed, what the next hand must know. " +
|
|
81
|
+
"The orchestrator reads this before dispatch, so write it plain and true.",
|
|
82
|
+
parameters: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
content: { type: "string", description: "the lesson, in full sentences" },
|
|
86
|
+
tags: { type: "string", description: "comma-separated labels, e.g. 'run,lesson'" },
|
|
87
|
+
actors: { type: "string", description: "comma-separated names involved" },
|
|
88
|
+
salience: { type: "number", description: "importance 0..1 (default 0.7)" },
|
|
89
|
+
},
|
|
90
|
+
required: ["content"],
|
|
91
|
+
},
|
|
92
|
+
async execute(
|
|
93
|
+
_id: string,
|
|
94
|
+
params: { content: string; tags?: string; actors?: string; salience?: number },
|
|
95
|
+
) {
|
|
96
|
+
// LISTS, never comma-strings: the store keeps a string as an array of
|
|
97
|
+
// CHARACTERS, and a later recall then reads tags as "r,u,n".
|
|
98
|
+
const list = (v?: string) =>
|
|
99
|
+
String(v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
100
|
+
const r = await call("/observe", {
|
|
101
|
+
method: "POST",
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
content: params.content,
|
|
104
|
+
tags: list(params.tags ?? "run,lesson"),
|
|
105
|
+
actors: list(params.actors),
|
|
106
|
+
salience: Math.max(0, Math.min(1, Number(params.salience ?? 0.7))),
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
if (!r.ok) {
|
|
110
|
+
return { content: [{ type: "text", text: `the lesson was NOT written (${r.status}): ${r.body}` }], isError: true };
|
|
111
|
+
}
|
|
112
|
+
const id = (r.body as any)?.id ?? "?";
|
|
113
|
+
return { content: [{ type: "text", text: `written to the well: ${id}` }] };
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// A session that cannot reach the well should say so once, plainly, rather than
|
|
118
|
+
// let every later recall fail in silence.
|
|
119
|
+
pi.on("session_start", async () => {
|
|
120
|
+
const r = await call("/health");
|
|
121
|
+
if (!r.ok) {
|
|
122
|
+
pi.registerMessageRenderer?.("ymir-well-offline", (m: any) => m);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const e = (r.body as any)?.episodes;
|
|
126
|
+
if (typeof e === "number" && e === 0) {
|
|
127
|
+
console.error("[ymir-well] the well is reachable but holds 0 episodes");
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
|
+
## 2026-09-17 — the fork stands the machine
|
|
4
|
+
|
|
5
|
+
- **`config/app-repos.yaml` (public)** — the five app repos, the fork's road:
|
|
6
|
+
a fresh clone with no hoard registry falls back to this map in the installer's
|
|
7
|
+
`apps` step, so `git clone` + `bin/ymir-install.sh` pull the four surfaces as
|
|
8
|
+
they would on the author's seat. Verified on a clean clone (`ymir-plan.sh`:
|
|
9
|
+
hlidskjalf/odrerir/sessrumnir/smidja all `DO`). The hoard registry stays
|
|
10
|
+
authoritative when present.
|
|
11
|
+
|
|
12
|
+
## 2026-09-17 — the phantom optional pins, cut
|
|
13
|
+
|
|
14
|
+
- The platform's stale \`optionalDependencies\` (sessrumnir pinned at the broken
|
|
15
|
+
0.1.9) shadowed the required \`^0.1.10\` — npm never fetched the good seat.
|
|
16
|
+
Removed; the required deps now resolve to latest (0.1.18).
|
|
17
|
+
|
|
18
|
+
## 2026-09-17 — the packaged seat carries the watch
|
|
19
|
+
|
|
20
|
+
- **The @zerwiz/ymir tarball now ships every surface the runtime reads**: the
|
|
21
|
+
harness surfaces (`.pi/`, `.opencode/`, `.claude/`, `.codex/`, `.cursor/`),
|
|
22
|
+
`assets/`, `data/`, `deploy/` — so an npm-installed seat has the watchers,
|
|
23
|
+
the session-start hooks and the extensions, not only the repo clone (0.1.16
|
|
24
|
+
→ 0.1.17).
|
|
25
|
+
|
|
3
26
|
## 2026-09-17 — the npm door stands the surfaces
|
|
4
27
|
|
|
5
28
|
- **`@zerwiz/ymir` now depends on its surfaces**: `@zerwiz/{hlidskjalf,
|