takomi 2.1.43 → 2.1.45

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.
Files changed (44) hide show
  1. package/.pi/README.md +4 -3
  2. package/.pi/extensions/oauth-router/README.md +3 -3
  3. package/.pi/extensions/oauth-router/commands.ts +66 -39
  4. package/.pi/extensions/oauth-router/config.ts +34 -34
  5. package/.pi/extensions/oauth-router/index.ts +51 -10
  6. package/.pi/extensions/oauth-router/report-ui.ts +205 -0
  7. package/.pi/extensions/oauth-router/scripts/vibe-verify.py +5 -5
  8. package/.pi/extensions/takomi-context-manager/diagnostics-tools.ts +41 -3
  9. package/.pi/extensions/takomi-context-manager/diagnostics.ts +26 -0
  10. package/.pi/extensions/takomi-context-manager/index.ts +20 -9
  11. package/.pi/extensions/takomi-context-manager/model-policy-gate.ts +36 -15
  12. package/.pi/extensions/takomi-context-manager/policy-tools.ts +93 -42
  13. package/.pi/extensions/takomi-context-manager/skill-categories.d.ts +11 -0
  14. package/.pi/extensions/takomi-context-manager/skill-categories.js +212 -0
  15. package/.pi/extensions/takomi-context-manager/skill-registry.ts +179 -8
  16. package/.pi/extensions/takomi-context-manager/skill-tools.ts +208 -103
  17. package/.pi/extensions/takomi-context-manager/tool-renderers.ts +112 -0
  18. package/.pi/extensions/takomi-context-manager/types.ts +6 -0
  19. package/.pi/extensions/takomi-runtime/commands.ts +45 -12
  20. package/.pi/extensions/takomi-runtime/gate-provenance.ts +24 -0
  21. package/.pi/extensions/takomi-runtime/index.ts +133 -56
  22. package/.pi/extensions/takomi-runtime/routing-policy.ts +14 -2
  23. package/.pi/extensions/takomi-runtime/takomi-stats.js +46 -26
  24. package/.pi/extensions/takomi-runtime/tool-renderers.ts +234 -0
  25. package/.pi/extensions/takomi-runtime/workflow-catalog.ts +54 -0
  26. package/.pi/extensions/takomi-subagents/agents.ts +4 -0
  27. package/.pi/extensions/takomi-subagents/async-lifecycle.ts +401 -0
  28. package/.pi/extensions/takomi-subagents/detached-results.ts +940 -0
  29. package/.pi/extensions/takomi-subagents/index.ts +46 -1
  30. package/.pi/extensions/takomi-subagents/native-render.ts +250 -188
  31. package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +55 -46
  32. package/.pi/extensions/takomi-subagents/pi-subagents-internal.ts +11 -5
  33. package/.pi/extensions/takomi-subagents/result-heartbeat.ts +55 -0
  34. package/.pi/extensions/takomi-subagents/subagent-ux.ts +162 -0
  35. package/.pi/extensions/takomi-subagents/tool-runner.ts +158 -28
  36. package/.pi/takomi/model-routing.md +282 -3
  37. package/assets/.agent/skills/shared-resend-portfolio/SKILL.md +124 -0
  38. package/package.json +4 -3
  39. package/src/pi-harness.js +41 -1
  40. package/src/pi-takomi-core/types.ts +10 -0
  41. package/src/pi-takomi-core/workflows.ts +86 -45
  42. package/src/skills-catalog.js +2 -202
  43. package/src/skills-installer.js +4 -1
  44. package/src/takomi-stats.js +5 -5
@@ -0,0 +1,205 @@
1
+ import { truncateToWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
2
+
3
+ export const ROUTER_REPORT_WIDGET_KEY = "oauth-router-report";
4
+ export const ROUTER_REPORT_DISMISS_HINT = "Dismiss: /router-clear";
5
+
6
+ type RouterTheme = {
7
+ fg(color: "accent" | "success" | "warning" | "error" | "muted" | "dim", text: string): string;
8
+ bold(text: string): string;
9
+ };
10
+
11
+ const SENSITIVE_VALUE_KEYS = new Set([
12
+ "access_token", "access-token", "accesstoken",
13
+ "refresh_token", "refresh-token", "refreshtoken",
14
+ "id_token", "id-token", "idtoken",
15
+ "auth_token", "auth-token", "authtoken",
16
+ "api_key", "api-key", "apikey",
17
+ "authorization",
18
+ "client_secret", "client-secret", "clientsecret",
19
+ "password", "secret", "code",
20
+ ]);
21
+ const SENSITIVE_QUERY_KEYS = [...SENSITIVE_VALUE_KEYS, "token", "key"];
22
+ const SENSITIVE_QUERY_VALUE = new RegExp(`([?&#](?:${SENSITIVE_QUERY_KEYS.join("|")})=)[^&#\\s"']*`, "gi");
23
+
24
+ function isKeyCharacter(value: string | undefined): boolean {
25
+ return Boolean(value && /[A-Za-z0-9_-]/.test(value));
26
+ }
27
+
28
+ function skipWhitespace(text: string, start: number): number {
29
+ let index = start;
30
+ while (/\s/.test(text[index] ?? "")) index += 1;
31
+ return index;
32
+ }
33
+
34
+ function quotedEnd(text: string, start: number): number | undefined {
35
+ const quote = text[start];
36
+ for (let index = start + 1; index < text.length; index += 1) {
37
+ if (text[index] === "\\") {
38
+ index += 1;
39
+ continue;
40
+ }
41
+ if (text[index] === quote) return index + 1;
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ type SensitiveAssignment = { key: string; valueStart: number };
47
+
48
+ /**
49
+ * Locate a complete assignment key rather than using a word-boundary regex.
50
+ * Underscores and hyphens are key characters, so `zipcode`, `account_code`,
51
+ * and `authorization_status` cannot be mistaken for sensitive keys.
52
+ */
53
+ function sensitiveAssignmentAt(text: string, start: number): SensitiveAssignment | undefined {
54
+ if (isKeyCharacter(text[start - 1])) return undefined;
55
+
56
+ let key: string;
57
+ let afterKey: number;
58
+ if (text[start] === '"' || text[start] === "'") {
59
+ const end = quotedEnd(text, start);
60
+ if (!end) return undefined;
61
+ key = text.slice(start + 1, end - 1);
62
+ afterKey = end;
63
+ } else {
64
+ if (!isKeyCharacter(text[start])) return undefined;
65
+ afterKey = start;
66
+ while (isKeyCharacter(text[afterKey])) afterKey += 1;
67
+ key = text.slice(start, afterKey);
68
+ }
69
+
70
+ if (!SENSITIVE_VALUE_KEYS.has(key.toLowerCase())) return undefined;
71
+ const delimiter = skipWhitespace(text, afterKey);
72
+ if (text[delimiter] !== ":" && text[delimiter] !== "=") return undefined;
73
+ return { key: key.toLowerCase(), valueStart: skipWhitespace(text, delimiter + 1) };
74
+ }
75
+
76
+ function redactSensitiveAssignments(text: string): string {
77
+ let output = "";
78
+ let cursor = 0;
79
+
80
+ for (let start = 0; start < text.length; start += 1) {
81
+ const assignment = sensitiveAssignmentAt(text, start);
82
+ if (!assignment || assignment.valueStart >= text.length) continue;
83
+
84
+ const valueStart = assignment.valueStart;
85
+ const quote = text[valueStart];
86
+ if (quote === '"' || quote === "'") {
87
+ const valueEnd = quotedEnd(text, valueStart);
88
+ if (!valueEnd) continue;
89
+ output += `${text.slice(cursor, valueStart + 1)}[redacted]${quote}`;
90
+ cursor = valueEnd;
91
+ start = valueEnd - 1;
92
+ continue;
93
+ }
94
+
95
+ let valueEnd = valueStart;
96
+ if (assignment.key === "authorization") {
97
+ while (valueEnd < text.length && text[valueEnd] !== "|" && text[valueEnd] !== "\r" && text[valueEnd] !== "\n") valueEnd += 1;
98
+ while (valueEnd > valueStart && /\s/.test(text[valueEnd - 1])) valueEnd -= 1;
99
+ } else {
100
+ while (valueEnd < text.length && !/[\s|,;"'&#]/.test(text[valueEnd])) valueEnd += 1;
101
+ }
102
+ if (valueEnd === valueStart) continue;
103
+
104
+ output += `${text.slice(cursor, valueStart)}[redacted]`;
105
+ cursor = valueEnd;
106
+ start = valueEnd - 1;
107
+ }
108
+
109
+ return output + text.slice(cursor);
110
+ }
111
+
112
+ /**
113
+ * Remove terminal controls and credentials from presentation only. Router data
114
+ * remains untouched; this boundary exists because labels and upstream errors
115
+ * can originate outside the extension.
116
+ */
117
+ export function sanitizeReportText(value: unknown): string {
118
+ let text = String(value ?? "")
119
+ .replace(/\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[PX^_][^\x1b]*(?:\x1b\\))/g, "")
120
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, " ")
121
+ .replace(/\s+/g, " ")
122
+ .trim();
123
+
124
+ // Bearer credentials can occur outside a key/value field. Assignment
125
+ // parsing below handles every quoted/bare sensitive-key combination and
126
+ // keeps Authorization's multi-word field value intact while redacting it.
127
+ text = text
128
+ .replace(/\b(Bearer\s+)(?:"[^"]*"|'[^']*'|[^\s,;|"']+)/gi, "$1[redacted]")
129
+ .replace(SENSITIVE_QUERY_VALUE, "$1[redacted]");
130
+ text = redactSensitiveAssignments(text)
131
+ .replace(/\b(sk-[A-Za-z0-9_-]{8,}|(?:eyJ[A-Za-z0-9_-]+\.){2}[A-Za-z0-9_-]+)\b/gi, "[redacted]");
132
+
133
+ return text;
134
+ }
135
+
136
+ /** Return the RPC-compatible report representation without terminal controls. */
137
+ export function createRouterReportLines(text: string): string[] {
138
+ return text.split(/\r?\n/).map(sanitizeReportText);
139
+ }
140
+
141
+ function fitQuotaBar(line: string, width: number): string {
142
+ const match = /\[([█░]+)\]/.exec(line);
143
+ if (!match) return line;
144
+ const source = match[1];
145
+ const targetWidth = width <= 45 ? 8 : width <= 70 ? 12 : 18;
146
+ if (source.length <= targetWidth) return line;
147
+ const filled = [...source].filter((cell) => cell === "█").length;
148
+ const targetFilled = Math.round((filled / source.length) * targetWidth);
149
+ return `${line.slice(0, match.index)}[${"█".repeat(targetFilled)}${"░".repeat(targetWidth - targetFilled)}]${line.slice(match.index + match[0].length)}`;
150
+ }
151
+
152
+ function styleLine(theme: RouterTheme, line: string): string {
153
+ if (line === ROUTER_REPORT_DISMISS_HINT) return theme.fg("accent", theme.bold(line));
154
+ if (line.startsWith("# ")) return theme.fg("accent", theme.bold(line.slice(2)));
155
+ if (line.startsWith("## ")) return theme.fg("accent", theme.bold(line.slice(3)));
156
+
157
+ // State words must win over incidental success counters or a prior healthy
158
+ // status. In particular, an explicitly disabled account is never success.
159
+ if (/\b(auth=invalid|auth invalid|invalid)\b/i.test(line)) return theme.fg("error", line);
160
+ if (/\b(degraded|cooldown|penalty)\b/i.test(line)) return theme.fg("warning", line);
161
+ if (/\b(enabled\s*=\s*false|disabled|inactive)\b/i.test(line)) return theme.fg("muted", line);
162
+ if (/^Provider:\s*/.test(line)) return theme.fg("accent", line);
163
+ if (/^Local:\s*/.test(line)) return theme.fg("muted", line);
164
+
165
+ const quota = /(\d+)% left/.exec(line);
166
+ if (quota) {
167
+ const remaining = Number(quota[1]);
168
+ return theme.fg(remaining <= 20 ? "error" : remaining <= 50 ? "warning" : "success", line);
169
+ }
170
+ if (/\b(healthy|enabled|ok)\b/i.test(line) && !/\b(unhealthy|not healthy)\b/i.test(line)) return theme.fg("success", line);
171
+ if (/\b(failures?|429s?|error)\b/i.test(line)) return theme.fg("warning", line);
172
+ if (/^(Raw:|Usage:|Aliases:|Valid values:|Compact account list|Provider bars show)/.test(line)) return theme.fg("muted", line);
173
+ if (/^(note:|headers:|claimKeys:|endpoint:|tokenExpires=)/.test(line)) return theme.fg("dim", line);
174
+ return line;
175
+ }
176
+
177
+ class RouterReportComponent implements Component {
178
+ constructor(private readonly lines: string[], private readonly theme: RouterTheme) {}
179
+
180
+ invalidate() {
181
+ // Rendering is derived solely from the report snapshot and current width.
182
+ }
183
+
184
+ render(width: number): string[] {
185
+ const usableWidth = Math.max(1, width);
186
+ const rendered: string[] = [];
187
+ for (const line of this.lines) {
188
+ if (!line) {
189
+ rendered.push("");
190
+ continue;
191
+ }
192
+ const styled = styleLine(this.theme, fitQuotaBar(line, usableWidth));
193
+ const wrapped = wrapTextWithAnsi(styled, usableWidth);
194
+ rendered.push(...(wrapped.length ? wrapped.map((part) => truncateToWidth(part, usableWidth)) : [""]));
195
+ }
196
+ return rendered;
197
+ }
198
+ }
199
+
200
+ export function createRouterReportWidget(text: string) {
201
+ // Snapshot and sanitize now as well as at render time so replacement has no
202
+ // mutable dependency and no stale report can reappear on a later lifecycle event.
203
+ const safeLines = createRouterReportLines(text);
204
+ return (_tui: unknown, theme: RouterTheme): Component => new RouterReportComponent(safeLines, theme);
205
+ }
@@ -21,7 +21,7 @@ REQUIRED = [
21
21
  ROOT / "types.ts",
22
22
  ROOT / "README.md",
23
23
  ]
24
- EXPECTED_MODELS = ["oauth-router", "gpt-5.4-mini", "gpt-5.4", "gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"]
24
+ EXPECTED_MODELS = ["oauth-router", "gpt-5.4-mini", "gpt-5.4", "gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"]
25
25
  PI_CANDIDATES = [
26
26
  os.environ.get("PI_BIN"),
27
27
  shutil.which("pi"),
@@ -77,12 +77,12 @@ def main() -> int:
77
77
  failed = True
78
78
  else:
79
79
  print("[ OK ] `pi --list-models` executed")
80
- for token in EXPECTED_MODELS:
81
- if token not in output:
80
+ for token in EXPECTED_MODELS:
81
+ if token not in output:
82
82
  print(f"[FAIL] model token not found in list output: {token}")
83
83
  failed = True
84
- else:
85
- print(f"[ OK ] model token present: {token}")
84
+ else:
85
+ print(f"[ OK ] model token present: {token}")
86
86
  except Exception as exc:
87
87
  print(f"[FAIL] unable to execute `pi --list-models`: {exc}")
88
88
  failed = True
@@ -2,9 +2,10 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import type { ContextManagerState } from "./state";
5
- import { renderReport, type ContextReportMode } from "./diagnostics";
5
+ import { contextReportPresentation, renderReport, type ContextReportMode } from "./diagnostics";
6
6
  import { discoverSkillsFromFilesystem, mergeSkills } from "./skill-registry";
7
7
  import { persistReportSnapshot, restoreReportFromSession } from "./session-state";
8
+ import { renderCompactCard, renderExpandedMarkdown, renderToolCall, resultText, sanitizePresentation } from "./tool-renderers";
8
9
 
9
10
  export function registerDiagnostics(pi: ExtensionAPI, state: ContextManagerState): void {
10
11
  pi.registerTool({
@@ -29,7 +30,44 @@ export function registerDiagnostics(pi: ExtensionAPI, state: ContextManagerState
29
30
  state.report.toolCalls.contextReport += 1;
30
31
  persistReportSnapshot(pi, state, "context_report");
31
32
  const mode = (params.verbose ? "verbose" : params.mode ?? "summary") as ContextReportMode;
32
- return { content: [{ type: "text", text: renderReport(state, mode) }], details: { ...state.report, mode } };
33
+ const text = renderReport(state, mode);
34
+ // Keep model-facing content exactly as requested. Expanded presentation
35
+ // renders that same mode-specific report rather than silently promoting
36
+ // summary/problems requests to verbose diagnostics.
37
+ const presentation = contextReportPresentation(state);
38
+ return { content: [{ type: "text", text }], details: { ...state.report, mode, presentation } };
39
+ },
40
+ renderCall(args, theme) {
41
+ return renderToolCall("context_report", args.verbose ? "verbose" : args.mode ?? "summary", theme);
42
+ },
43
+ renderResult(result, { expanded }, theme) {
44
+ const details = result.details as {
45
+ mode?: ContextReportMode;
46
+ skillCount?: number;
47
+ loadedByTool?: string[];
48
+ loadedPolicies?: string[];
49
+ presentation?: {
50
+ status?: "success" | "warning" | "error" | "pending";
51
+ summary?: string;
52
+ attentionCount?: number;
53
+ };
54
+ } | undefined;
55
+ const presentation = details?.presentation;
56
+ const text = resultText(result);
57
+ const status = presentation?.status ?? "pending";
58
+ const summary = presentation?.summary ?? "Informational";
59
+ const attentionCount = presentation?.attentionCount ?? 0;
60
+ const metadata = `${details?.skillCount ?? 0} skills · ${details?.loadedPolicies?.length ?? 0} policies loaded · ${attentionCount} attention items`;
61
+ if (!expanded) {
62
+ return renderCompactCard({ status, title: "Context health", summary, metadata }, theme);
63
+ }
64
+ return renderExpandedMarkdown({
65
+ status,
66
+ title: "Context report",
67
+ summary,
68
+ metadata: [metadata, `Requested mode: ${details?.mode ?? "summary"}`],
69
+ markdown: text,
70
+ }, theme);
33
71
  },
34
72
  });
35
73
 
@@ -55,7 +93,7 @@ export function registerDiagnostics(pi: ExtensionAPI, state: ContextManagerState
55
93
  persistReportSnapshot(pi, state, "context-report-command");
56
94
  const requested = args.trim();
57
95
  const mode: ContextReportMode = requested === "verbose" || requested === "problems" || requested === "summary" ? requested : "summary";
58
- ctx.ui.notify(renderReport(state, mode), "info");
96
+ ctx.ui.notify(sanitizePresentation(renderReport(state, mode)), "info");
59
97
  },
60
98
  });
61
99
  }
@@ -139,6 +139,32 @@ function overallStatus(issues: ReportIssue[]): { label: string; next: string; wa
139
139
  return { label: "⚠ Attention Required", next: actionable[0].fix, warningCount: actionable.length };
140
140
  }
141
141
 
142
+ export type ContextReportPresentation = {
143
+ status: "success" | "warning" | "error" | "pending";
144
+ summary: string;
145
+ attentionCount: number;
146
+ };
147
+
148
+ /** Structured execution metadata shared by context_report renderers. */
149
+ export function contextReportPresentation(state: ContextManagerState): ContextReportPresentation {
150
+ syncReportLedger(state);
151
+ const issues = collectIssues(state);
152
+ const overall = overallStatus(issues);
153
+ const actionable = issues.filter((issue) => issue.severity !== "info");
154
+ const status = issues.length === 0
155
+ ? "success"
156
+ : actionable.some((issue) => issue.severity === "failed" || issue.severity === "blocked")
157
+ ? "error"
158
+ : actionable.length > 0
159
+ ? "warning"
160
+ : "pending";
161
+ return {
162
+ status,
163
+ summary: overall.label.replace(/^[^\w]+\s*/, ""),
164
+ attentionCount: overall.warningCount,
165
+ };
166
+ }
167
+
142
168
  function promptRewriteState(state: ContextManagerState): string {
143
169
  const rewrite = state.report.promptRewrite;
144
170
  if (!rewrite.attempted) return "Not run yet";
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { loadConfig, DEFAULT_CONFIG } from "./config";
3
3
  import { createState } from "./state";
4
- import { collectSkillsFromOptions, collectSkillsFromXml, discoverSkillsFromFilesystem, mergeSkills } from "./skill-registry";
4
+ import { collectSkillsFromOptions, collectSkillsFromXml, discoverSkillsFromFilesystem, enrichSkillsWithInstallerTaxonomy, mergeSkills } from "./skill-registry";
5
5
  import { discoverPolicies } from "./policy-registry";
6
6
  import { findCandidates } from "./context-router";
7
7
  import { rewritePrompt } from "./prompt-rewriter";
@@ -18,6 +18,7 @@ import type { ContextManagerConfig } from "./types";
18
18
  export default function takomiContextManager(pi: ExtensionAPI) {
19
19
  const state = createState();
20
20
  let config: ContextManagerConfig = DEFAULT_CONFIG;
21
+ let duplicateExtensionWarnings: Array<{ toolName: string; paths: string[] }> = [];
21
22
 
22
23
  registerSkillTools(pi, state);
23
24
  registerPolicyTools(pi, state);
@@ -27,21 +28,31 @@ export default function takomiContextManager(pi: ExtensionAPI) {
27
28
 
28
29
  pi.on("session_start", async (_event, ctx) => {
29
30
  config = await loadConfig(ctx.cwd);
30
- state.policies = await discoverPolicies(ctx.cwd, config);
31
- state.skills = mergeSkills(await discoverSkillsFromFilesystem(ctx.cwd));
31
+ [state.policies, duplicateExtensionWarnings] = await Promise.all([
32
+ discoverPolicies(ctx.cwd, config),
33
+ detectDuplicateTakomiExtensions(ctx.cwd),
34
+ ]);
35
+
36
+ // Pi already discovers skills while building systemPromptOptions. Rewalking
37
+ // every global/project skill tree here made startup block on thousands of
38
+ // filesystem calls, then repeated the same work before the first request.
39
+ // Keep filesystem discovery lazy for direct skill-tool calls and as a
40
+ // compatibility fallback when Pi supplies no skill metadata.
41
+ state.skills = new Map();
32
42
  state.report.cwd = ctx.cwd;
33
- state.report.skillCount = state.skills.size;
43
+ state.report.skillCount = 0;
34
44
  restoreReportFromSession(state, ctx);
35
45
  });
36
46
 
37
47
  pi.on("before_agent_start", async (event, ctx) => {
38
- config = await loadConfig(ctx.cwd);
39
- state.policies = await discoverPolicies(ctx.cwd, config);
40
- const duplicateExtensionWarnings = await detectDuplicateTakomiExtensions(ctx.cwd);
41
48
  const optionSkills = collectSkillsFromOptions(event.systemPromptOptions);
42
49
  const xmlSkills = collectSkillsFromXml(event.systemPrompt);
43
- const filesystemSkills = await discoverSkillsFromFilesystem(ctx.cwd);
44
- state.skills = mergeSkills([...filesystemSkills, ...optionSkills, ...xmlSkills]);
50
+ const suppliedSkills = [...optionSkills, ...xmlSkills];
51
+ const enrichedSuppliedSkills = await enrichSkillsWithInstallerTaxonomy(suppliedSkills);
52
+ const filesystemSkills = suppliedSkills.length === 0
53
+ ? await discoverSkillsFromFilesystem(ctx.cwd)
54
+ : [];
55
+ state.skills = mergeSkills([...filesystemSkills, ...enrichedSuppliedSkills]);
45
56
 
46
57
  const candidates = findCandidates(event.prompt, state.skills, config);
47
58
  const rewrite = rewritePrompt(event.systemPrompt, state.skills, candidates, config);
@@ -7,6 +7,7 @@ import { recordBlocked } from "./state";
7
7
  import { resolveTakomiRoutingPolicy } from "../takomi-runtime/routing-policy";
8
8
  import { approvedModelEquivalent, isTakomiModelApproved } from "../takomi-runtime/model-routing-defaults";
9
9
  import { persistReportSnapshot } from "./session-state";
10
+ import { sanitizePresentation } from "./tool-renderers";
10
11
 
11
12
  type Settings = {
12
13
  takomi?: { modelRoutingPolicyFile?: string };
@@ -162,18 +163,40 @@ type RecoveryChoice =
162
163
  | { action: "retry"; model: string }
163
164
  | { action: "stop" };
164
165
 
166
+ type RecoveryOptions = {
167
+ options: string[];
168
+ retryModels: Map<string, string>;
169
+ };
170
+
171
+ /**
172
+ * Keep policy model IDs raw for selection and mutation, while passing only
173
+ * presentation-safe labels to the terminal UI. Disambiguate labels that become
174
+ * identical after sanitization so a selected label still maps to one raw ID.
175
+ */
176
+ function recoveryOptions(approved: string[]): RecoveryOptions {
177
+ const retryModels = new Map<string, string>();
178
+ const options: string[] = [];
179
+ for (const model of approved) {
180
+ const baseLabel = `Retry with ${sanitizePresentation(model)}`;
181
+ let label = baseLabel;
182
+ let duplicate = 2;
183
+ while (retryModels.has(label)) label = `${baseLabel} (${duplicate++})`;
184
+ retryModels.set(label, model);
185
+ options.push(label);
186
+ }
187
+ options.push("Stop and let me send a new prompt");
188
+ return { options, retryModels };
189
+ }
190
+
165
191
  async function askForInvalidModelRecovery(ctx: { ui: { select(title: string, options: string[]): Promise<string | undefined>; notify(message: string, level?: string): void }; abort?: () => void }, requested: string, approved: string[]): Promise<RecoveryChoice> {
166
192
  if (approved.length === 0) return { action: "stop" };
167
- const options = [
168
- ...approved.map((model) => `Retry with ${model}`),
169
- "Stop and let me send a new prompt",
170
- ];
193
+ const recovery = recoveryOptions(approved);
171
194
  const choice = await ctx.ui.select(
172
- `takomi_subagent requested a model outside your routing policy: ${requested}`,
173
- options,
195
+ sanitizePresentation(`takomi_subagent requested a model outside your routing policy: ${requested}`),
196
+ recovery.options,
174
197
  );
175
- if (choice?.startsWith("Retry with ")) return { action: "retry", model: choice.replace("Retry with ", "") };
176
- return { action: "stop" };
198
+ const model = choice ? recovery.retryModels.get(choice) : undefined;
199
+ return model ? { action: "retry", model } : { action: "stop" };
177
200
  }
178
201
 
179
202
  export function installModelPolicyGate(pi: ExtensionAPI, state: ContextManagerState): void {
@@ -221,7 +244,8 @@ export function installModelPolicyGate(pi: ExtensionAPI, state: ContextManagerSt
221
244
  timestamp,
222
245
  })));
223
246
  persistReportSnapshot(pi, state, "model-policy-correction");
224
- ctx.ui.notify(`Takomi context manager corrected subagent model routing:\n- ${corrections.map((correction) => `${correction.from} -> ${correction.to}${correction.recovery ? ` (${correction.recovery})` : ""}`).join("\n- ")}\n\nBe careful to follow /takomi routing policy next time.`, "warning");
247
+ const notification = `Takomi context manager corrected subagent model routing:\n- ${corrections.map((correction) => `${sanitizePresentation(correction.from)} -> ${sanitizePresentation(correction.to)}${correction.recovery ? ` (${sanitizePresentation(correction.recovery)})` : ""}`).join("\n- ")}\n\nBe careful to follow /takomi routing policy next time.`;
248
+ ctx.ui.notify(notification, "warning");
225
249
  }
226
250
  });
227
251
 
@@ -231,12 +255,9 @@ export function installModelPolicyGate(pi: ExtensionAPI, state: ContextManagerSt
231
255
  if (!isModelFailure(content)) return;
232
256
 
233
257
  const snapshot = await loadSnapshot(ctx.cwd);
234
- const options = [
235
- ...snapshot.approvedModels.map((model) => `Retry with ${model}`),
236
- "Stop and let me send a new prompt",
237
- ];
238
- const choice = await ctx.ui.select("Takomi subagent model/provider failure. How do you want to continue?", options);
239
- const retryModel = choice?.startsWith("Retry with ") ? choice.replace("Retry with ", "") : undefined;
258
+ const recovery = recoveryOptions(snapshot.approvedModels);
259
+ const choice = await ctx.ui.select("Takomi subagent model/provider failure. How do you want to continue?", recovery.options);
260
+ const retryModel = choice ? recovery.retryModels.get(choice) : undefined;
240
261
  const stopped = !retryModel;
241
262
  const guidance = [
242
263
  "Takomi subagent failed with a model/provider-related error.",
@@ -1,42 +1,93 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { Type } from "typebox";
3
- import type { ContextManagerState } from "./state";
4
- import { syncReportLedger } from "./state";
5
- import { renderPolicies, renderPolicyManifest } from "./policy-registry";
6
- import { persistReportSnapshot, restoreReportFromSession } from "./session-state";
7
-
8
- export function registerPolicyTools(pi: ExtensionAPI, state: ContextManagerState): void {
9
- pi.registerTool({
10
- name: "policy_manifest",
11
- label: "Policy Manifest",
12
- description: "Return descriptions for available context policy packs without loading full policy content.",
13
- promptSnippet: "Show available context policy pack descriptions",
14
- parameters: Type.Object({ policies: Type.Optional(Type.Array(Type.String({ description: "Policy name to inspect" }))) }),
15
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
16
- restoreReportFromSession(state, ctx);
17
- state.report.timestamp = new Date().toISOString();
18
- state.report.cwd = ctx.cwd;
19
- state.report.toolCalls.policyManifest += 1;
20
- persistReportSnapshot(pi, state, "policy_manifest");
21
- return { content: [{ type: "text", text: renderPolicyManifest(state.policies, params.policies ?? []) }], details: { requested: params.policies ?? [...state.policies.keys()] } };
22
- },
23
- });
24
-
25
- pi.registerTool({
26
- name: "policy_load",
27
- label: "Policy Load",
28
- description: "Load one or more context policy packs required before sensitive tools such as takomi_subagent.",
29
- promptSnippet: "Load policy packs required before sensitive tool calls",
30
- parameters: Type.Object({ policies: Type.Array(Type.String({ description: "Policy pack name to load" })) }),
31
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
32
- restoreReportFromSession(state, ctx);
33
- state.report.timestamp = new Date().toISOString();
34
- state.report.cwd = ctx.cwd;
35
- state.report.toolCalls.policyLoad += 1;
36
- const text = renderPolicies(state.policies, state.loadedPolicies, params.policies);
37
- syncReportLedger(state);
38
- persistReportSnapshot(pi, state, "policy_load");
39
- return { content: [{ type: "text", text }], details: { requested: params.policies, loadedPolicies: [...state.loadedPolicies].sort() } };
40
- },
41
- });
42
- }
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import type { ContextManagerState } from "./state";
4
+ import { syncReportLedger } from "./state";
5
+ import { renderPolicies, renderPolicyManifest } from "./policy-registry";
6
+ import { persistReportSnapshot, restoreReportFromSession } from "./session-state";
7
+ import { renderCompactCard, renderExpandedMarkdown, renderToolCall, resultText } from "./tool-renderers";
8
+ import { normalizeName } from "./skill-registry";
9
+
10
+ function requestedPolicies(state: ContextManagerState, names: string[] | undefined): string[] {
11
+ return names?.length ? names : [...state.policies.values()].map((policy) => policy.name).sort((a, b) => a.localeCompare(b, "en"));
12
+ }
13
+
14
+ function missingPolicies(state: ContextManagerState, names: string[]): string[] {
15
+ return names.filter((name) => !state.policies.has(normalizeName(name)));
16
+ }
17
+
18
+ export function registerPolicyTools(pi: ExtensionAPI, state: ContextManagerState): void {
19
+ pi.registerTool({
20
+ name: "policy_manifest",
21
+ label: "Policy Manifest",
22
+ description: "Return descriptions for available context policy packs without loading full policy content.",
23
+ promptSnippet: "Show available context policy pack descriptions",
24
+ parameters: Type.Object({ policies: Type.Optional(Type.Array(Type.String({ description: "Policy name to inspect" }))) }),
25
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
26
+ restoreReportFromSession(state, ctx);
27
+ state.report.timestamp = new Date().toISOString();
28
+ state.report.cwd = ctx.cwd;
29
+ state.report.toolCalls.policyManifest += 1;
30
+ persistReportSnapshot(pi, state, "policy_manifest");
31
+ const requested = requestedPolicies(state, params.policies);
32
+ const missing = missingPolicies(state, requested);
33
+ return { content: [{ type: "text", text: renderPolicyManifest(state.policies, params.policies ?? []) }], details: { requested, found: requested.length - missing.length, missing } };
34
+ },
35
+ renderCall(args, theme) {
36
+ return renderToolCall("policy_manifest", args.policies?.length ? `${args.policies.length} requested` : "all policies", theme);
37
+ },
38
+ renderResult(result, { expanded }, theme) {
39
+ const details = result.details as { requested?: string[]; found?: number; missing?: string[] } | undefined;
40
+ const requested = details?.requested ?? [];
41
+ const found = details?.found ?? 0;
42
+ const missing = details?.missing ?? [];
43
+ const status = missing.length ? "warning" : requested.length ? "success" : "pending";
44
+ const summary = missing.length ? `${found} available · ${missing.length} unavailable` : requested.length ? `${found} policies available` : "no policies discovered";
45
+ const metadata = missing.length ? `Missing: ${missing.join(", ")}` : `${requested.length} requested`;
46
+ if (!expanded) return renderCompactCard({ status, title: "Policy manifest", summary, metadata }, theme);
47
+ return renderExpandedMarkdown({ status, title: "Policy manifest", summary, metadata: [metadata], markdown: resultText(result) }, theme);
48
+ },
49
+ });
50
+
51
+ pi.registerTool({
52
+ name: "policy_load",
53
+ label: "Policy Load",
54
+ description: "Load one or more context policy packs required before sensitive tools such as takomi_subagent.",
55
+ promptSnippet: "Load policy packs required before sensitive tool calls",
56
+ parameters: Type.Object({ policies: Type.Array(Type.String({ description: "Policy pack name to load" })) }),
57
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
58
+ restoreReportFromSession(state, ctx);
59
+ state.report.timestamp = new Date().toISOString();
60
+ state.report.cwd = ctx.cwd;
61
+ state.report.toolCalls.policyLoad += 1;
62
+ const text = renderPolicies(state.policies, state.loadedPolicies, params.policies);
63
+ syncReportLedger(state);
64
+ persistReportSnapshot(pi, state, "policy_load");
65
+ const missing = missingPolicies(state, params.policies);
66
+ return {
67
+ content: [{ type: "text", text }],
68
+ details: {
69
+ requested: params.policies,
70
+ loadedPolicies: [...state.loadedPolicies].sort(),
71
+ loadedCount: params.policies.length - missing.length,
72
+ missing,
73
+ },
74
+ };
75
+ },
76
+ renderCall(args, theme) {
77
+ return renderToolCall("policy_load", `${args.policies.length} requested`, theme);
78
+ },
79
+ renderResult(result, { expanded }, theme) {
80
+ const details = result.details as { requested?: string[]; loadedCount?: number; missing?: string[] } | undefined;
81
+ const requested = details?.requested ?? [];
82
+ const loadedCount = details?.loadedCount ?? 0;
83
+ const missing = details?.missing ?? [];
84
+ const status = missing.length || requested.length === 0 ? "warning" : "success";
85
+ const summary = requested.length === 0
86
+ ? "no policies requested"
87
+ : missing.length ? `${loadedCount} loaded · ${missing.length} unavailable` : `${loadedCount} policies loaded`;
88
+ const metadata = missing.length ? `Missing: ${missing.join(", ")}` : `${requested.length} requested`;
89
+ if (!expanded) return renderCompactCard({ status, title: "Policy load", summary, metadata }, theme);
90
+ return renderExpandedMarkdown({ status, title: "Policy load", summary, metadata: [metadata], markdown: resultText(result) }, theme);
91
+ },
92
+ });
93
+ }
@@ -0,0 +1,11 @@
1
+ export type SkillCategory = {
2
+ id: string;
3
+ title: string;
4
+ color: string;
5
+ description: string;
6
+ skills: string[];
7
+ };
8
+
9
+ export const CORE_SKILLS: string[];
10
+ export const SKILL_CATEGORIES: SkillCategory[];
11
+ export function getSkillCategory(skillName: string): string | undefined;