takomi 2.1.44 → 2.5.1
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/.pi/README.md +9 -8
- package/.pi/agents/architect.md +2 -2
- package/.pi/agents/designer.md +2 -2
- package/.pi/agents/worker.md +32 -0
- package/.pi/extensions/oauth-router/commands.ts +66 -35
- package/.pi/extensions/oauth-router/index.ts +51 -7
- package/.pi/extensions/oauth-router/report-ui.ts +205 -0
- package/.pi/extensions/takomi-context-manager/diagnostics-tools.ts +41 -3
- package/.pi/extensions/takomi-context-manager/diagnostics.ts +26 -0
- package/.pi/extensions/takomi-context-manager/index.ts +3 -2
- package/.pi/extensions/takomi-context-manager/model-policy-gate.ts +50 -117
- package/.pi/extensions/takomi-context-manager/policy-tools.ts +93 -42
- package/.pi/extensions/takomi-context-manager/skill-categories.d.ts +11 -0
- package/.pi/extensions/takomi-context-manager/skill-categories.js +212 -0
- package/.pi/extensions/takomi-context-manager/skill-registry.ts +179 -8
- package/.pi/extensions/takomi-context-manager/skill-tools.ts +208 -103
- package/.pi/extensions/takomi-context-manager/tool-renderers.ts +112 -0
- package/.pi/extensions/takomi-context-manager/types.ts +6 -0
- package/.pi/extensions/takomi-runtime/command-text.ts +5 -4
- package/.pi/extensions/takomi-runtime/commands.ts +58 -25
- package/.pi/extensions/takomi-runtime/gate-provenance.ts +24 -0
- package/.pi/extensions/takomi-runtime/index.ts +385 -124
- package/.pi/extensions/takomi-runtime/model-routing-defaults.ts +262 -326
- package/.pi/extensions/takomi-runtime/profile.ts +9 -8
- package/.pi/extensions/takomi-runtime/routing-policy.ts +97 -66
- package/.pi/extensions/takomi-runtime/shared.ts +7 -30
- package/.pi/extensions/takomi-runtime/takomi-stats.js +46 -26
- package/.pi/extensions/takomi-runtime/tool-renderers.ts +234 -0
- package/.pi/extensions/takomi-runtime/workflow-catalog.ts +54 -0
- package/.pi/extensions/takomi-subagents/agents.ts +9 -0
- package/.pi/extensions/takomi-subagents/async-lifecycle.ts +401 -0
- package/.pi/extensions/takomi-subagents/detached-results.ts +940 -0
- package/.pi/extensions/takomi-subagents/index.ts +50 -1
- package/.pi/extensions/takomi-subagents/native-render.ts +250 -188
- package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +62 -47
- package/.pi/extensions/takomi-subagents/pi-subagents-internal.ts +11 -5
- package/.pi/extensions/takomi-subagents/result-heartbeat.ts +55 -0
- package/.pi/extensions/takomi-subagents/subagent-ux.ts +162 -0
- package/.pi/extensions/takomi-subagents/tool-runner.ts +198 -29
- package/.pi/settings.json +39 -36
- package/.pi/takomi/model-routing.md +288 -3
- package/.pi/takomi-profile.json +54 -50
- package/assets/.agent/skills/shared-resend-portfolio/SKILL.md +124 -0
- package/package.json +4 -3
- package/src/pi-takomi-core/orchestration.ts +39 -21
- package/src/pi-takomi-core/routing.ts +8 -8
- package/src/pi-takomi-core/types.ts +35 -5
- package/src/pi-takomi-core/workflows.ts +86 -45
- package/src/skills-catalog.js +2 -202
- package/src/skills-installer.js +4 -1
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
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";
|
|
@@ -48,10 +48,11 @@ export default function takomiContextManager(pi: ExtensionAPI) {
|
|
|
48
48
|
const optionSkills = collectSkillsFromOptions(event.systemPromptOptions);
|
|
49
49
|
const xmlSkills = collectSkillsFromXml(event.systemPrompt);
|
|
50
50
|
const suppliedSkills = [...optionSkills, ...xmlSkills];
|
|
51
|
+
const enrichedSuppliedSkills = await enrichSkillsWithInstallerTaxonomy(suppliedSkills);
|
|
51
52
|
const filesystemSkills = suppliedSkills.length === 0
|
|
52
53
|
? await discoverSkillsFromFilesystem(ctx.cwd)
|
|
53
54
|
: [];
|
|
54
|
-
state.skills = mergeSkills([...filesystemSkills, ...
|
|
55
|
+
state.skills = mergeSkills([...filesystemSkills, ...enrichedSuppliedSkills]);
|
|
55
56
|
|
|
56
57
|
const candidates = findCandidates(event.prompt, state.skills, config);
|
|
57
58
|
const rewrite = rewritePrompt(event.systemPrompt, state.skills, candidates, config);
|
|
@@ -1,114 +1,27 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
2
|
import type { ContextManagerState } from "./state";
|
|
6
3
|
import { recordBlocked } from "./state";
|
|
7
|
-
import {
|
|
8
|
-
import { approvedModelEquivalent, isTakomiModelApproved } from "../takomi-runtime/model-routing-defaults";
|
|
4
|
+
import { isTakomiModelApproved, loadTakomiModelRoutingSnapshot } from "../takomi-runtime/model-routing-defaults";
|
|
9
5
|
import { persistReportSnapshot } from "./session-state";
|
|
10
|
-
|
|
11
|
-
type Settings = {
|
|
12
|
-
takomi?: { modelRoutingPolicyFile?: string };
|
|
13
|
-
subagents?: { agentOverrides?: Record<string, unknown> };
|
|
14
|
-
};
|
|
6
|
+
import { sanitizePresentation } from "./tool-renderers";
|
|
15
7
|
|
|
16
8
|
type ModelPolicySnapshot = {
|
|
17
9
|
approvedModels: string[];
|
|
18
10
|
preferredModels: string[];
|
|
19
11
|
sourceFiles: string[];
|
|
12
|
+
policyConflicts?: string[];
|
|
20
13
|
};
|
|
21
14
|
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async function readSettingsFile(filePath: string): Promise<Settings> {
|
|
27
|
-
try {
|
|
28
|
-
return JSON.parse(await readFile(filePath, "utf8")) as Settings;
|
|
29
|
-
} catch {
|
|
30
|
-
return {};
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function mergeSettings(globalSettings: Settings, projectSettings: Settings): Settings {
|
|
35
|
-
const globalOverrides = asRecord(globalSettings.subagents?.agentOverrides);
|
|
36
|
-
const projectOverrides = asRecord(projectSettings.subagents?.agentOverrides);
|
|
15
|
+
async function loadSnapshot(cwd: string): Promise<ModelPolicySnapshot> {
|
|
16
|
+
const snapshot = await loadTakomiModelRoutingSnapshot(cwd);
|
|
37
17
|
return {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
...(globalSettings.subagents ?? {}),
|
|
43
|
-
...(projectSettings.subagents ?? {}),
|
|
44
|
-
agentOverrides: { ...globalOverrides, ...projectOverrides },
|
|
45
|
-
},
|
|
18
|
+
approvedModels: snapshot.approvedModels,
|
|
19
|
+
preferredModels: snapshot.preferredModels,
|
|
20
|
+
sourceFiles: snapshot.sourceFiles,
|
|
21
|
+
policyConflicts: snapshot.policyConflicts,
|
|
46
22
|
};
|
|
47
23
|
}
|
|
48
24
|
|
|
49
|
-
async function readSettings(cwd: string): Promise<Settings> {
|
|
50
|
-
const globalSettings = await readSettingsFile(path.join(os.homedir(), ".pi", "agent", "settings.json"));
|
|
51
|
-
const projectSettings = await readSettingsFile(path.resolve(cwd, ".pi/settings.json"));
|
|
52
|
-
return mergeSettings(globalSettings, projectSettings);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function modelFamily(model: string): string {
|
|
56
|
-
return model.split("/").at(-1)?.toLowerCase() ?? model.toLowerCase();
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function unique(values: string[]): string[] {
|
|
60
|
-
return [...new Set(values.filter(Boolean))];
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function collectModelsFromSettings(settings: Settings): string[] {
|
|
64
|
-
const overrides = asRecord(settings.subagents?.agentOverrides);
|
|
65
|
-
const models: string[] = [];
|
|
66
|
-
for (const value of Object.values(overrides)) {
|
|
67
|
-
const record = asRecord(value);
|
|
68
|
-
if (typeof record.model === "string") models.push(record.model);
|
|
69
|
-
if (Array.isArray(record.fallbackModels)) {
|
|
70
|
-
for (const fallback of record.fallbackModels) if (typeof fallback === "string") models.push(fallback.split(":")[0]);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return models;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function isModelLike(value: string): boolean {
|
|
77
|
-
const lower = value.toLowerCase();
|
|
78
|
-
return /(^|\/)(gpt|claude|gemini|o[0-9]|qwen|deepseek|llama|mistral|kimi|grok|sonnet|haiku|opus|codex|mini|max)/i.test(lower)
|
|
79
|
-
|| lower.includes("oauth-router/")
|
|
80
|
-
|| lower.includes("openai-codex/")
|
|
81
|
-
|| lower.includes("lmstudio/");
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function extractPreferredProvider(text: string): string | undefined {
|
|
85
|
-
const match = text.match(/(?:preferred|default)\s+(?:provider|router)(?:\s*\/\s*(?:provider|router))?\s*:\s*([a-z0-9-]+)/i)
|
|
86
|
-
?? text.match(/use\s+([a-z0-9-]+)\s+as\s+(?:the\s+)?(?:provider|router)/i);
|
|
87
|
-
return match?.[1];
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function collectModelsFromPolicy(text: string): string[] {
|
|
91
|
-
// Providerless names such as "GPT-5.5" are intent labels unless the policy
|
|
92
|
-
// declares a preferred provider/router header.
|
|
93
|
-
const explicit = (text.match(/[a-z0-9-]+\/[a-z0-9._-]+/gi) ?? []).filter(isModelLike);
|
|
94
|
-
const preferredProvider = extractPreferredProvider(text);
|
|
95
|
-
const inferred: string[] = [];
|
|
96
|
-
if (preferredProvider && /gpt[- ]?5\.5/i.test(text)) inferred.push(`${preferredProvider}/gpt-5.5`);
|
|
97
|
-
if (preferredProvider && /gpt[- ]?5\.4(?!\s*mini)/i.test(text)) inferred.push(`${preferredProvider}/gpt-5.4`);
|
|
98
|
-
if (preferredProvider && /gpt[- ]?5\.4\s*mini/i.test(text)) inferred.push(`${preferredProvider}/gpt-5.4-mini`);
|
|
99
|
-
return unique([...explicit, ...inferred]);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async function loadSnapshot(cwd: string): Promise<ModelPolicySnapshot> {
|
|
103
|
-
const settings = await readSettings(cwd);
|
|
104
|
-
const settingsModels = collectModelsFromSettings(settings);
|
|
105
|
-
const resolvedPolicy = await resolveTakomiRoutingPolicy(cwd);
|
|
106
|
-
const sourceFiles = resolvedPolicy.policyPath ? [resolvedPolicy.policyPath] : [];
|
|
107
|
-
const policyModels = resolvedPolicy.text ? collectModelsFromPolicy(resolvedPolicy.text) : [];
|
|
108
|
-
const approvedModels = unique([...settingsModels, ...policyModels]);
|
|
109
|
-
return { approvedModels, preferredModels: settingsModels.length ? unique(settingsModels) : approvedModels, sourceFiles };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
25
|
function collectRequestedModelRefs(input: unknown): Array<{ holder: Record<string, unknown>; key: string; value: string; index?: number }> {
|
|
113
26
|
const refs: Array<{ holder: Record<string, unknown>; key: string; value: string; index?: number }> = [];
|
|
114
27
|
function visit(value: unknown): void {
|
|
@@ -162,18 +75,40 @@ type RecoveryChoice =
|
|
|
162
75
|
| { action: "retry"; model: string }
|
|
163
76
|
| { action: "stop" };
|
|
164
77
|
|
|
78
|
+
type RecoveryOptions = {
|
|
79
|
+
options: string[];
|
|
80
|
+
retryModels: Map<string, string>;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Keep policy model IDs raw for selection and mutation, while passing only
|
|
85
|
+
* presentation-safe labels to the terminal UI. Disambiguate labels that become
|
|
86
|
+
* identical after sanitization so a selected label still maps to one raw ID.
|
|
87
|
+
*/
|
|
88
|
+
function recoveryOptions(approved: string[]): RecoveryOptions {
|
|
89
|
+
const retryModels = new Map<string, string>();
|
|
90
|
+
const options: string[] = [];
|
|
91
|
+
for (const model of approved) {
|
|
92
|
+
const baseLabel = `Retry with ${sanitizePresentation(model)}`;
|
|
93
|
+
let label = baseLabel;
|
|
94
|
+
let duplicate = 2;
|
|
95
|
+
while (retryModels.has(label)) label = `${baseLabel} (${duplicate++})`;
|
|
96
|
+
retryModels.set(label, model);
|
|
97
|
+
options.push(label);
|
|
98
|
+
}
|
|
99
|
+
options.push("Stop and let me send a new prompt");
|
|
100
|
+
return { options, retryModels };
|
|
101
|
+
}
|
|
102
|
+
|
|
165
103
|
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
104
|
if (approved.length === 0) return { action: "stop" };
|
|
167
|
-
const
|
|
168
|
-
...approved.map((model) => `Retry with ${model}`),
|
|
169
|
-
"Stop and let me send a new prompt",
|
|
170
|
-
];
|
|
105
|
+
const recovery = recoveryOptions(approved);
|
|
171
106
|
const choice = await ctx.ui.select(
|
|
172
|
-
`takomi_subagent requested a model outside your routing policy: ${requested}
|
|
173
|
-
options,
|
|
107
|
+
sanitizePresentation(`takomi_subagent requested a model outside your routing policy: ${requested}`),
|
|
108
|
+
recovery.options,
|
|
174
109
|
);
|
|
175
|
-
|
|
176
|
-
return { action: "stop" };
|
|
110
|
+
const model = choice ? recovery.retryModels.get(choice) : undefined;
|
|
111
|
+
return model ? { action: "retry", model } : { action: "stop" };
|
|
177
112
|
}
|
|
178
113
|
|
|
179
114
|
export function installModelPolicyGate(pi: ExtensionAPI, state: ContextManagerState): void {
|
|
@@ -181,18 +116,18 @@ export function installModelPolicyGate(pi: ExtensionAPI, state: ContextManagerSt
|
|
|
181
116
|
if (event.toolName !== "takomi_subagent") return;
|
|
182
117
|
const snapshot = await loadSnapshot(ctx.cwd);
|
|
183
118
|
const approved = snapshot.approvedModels;
|
|
119
|
+
if (snapshot.policyConflicts?.length) {
|
|
120
|
+
const reason = ["Blocked by conflicting Takomi routing guidance and executable settings.", "", ...snapshot.policyConflicts.map((item) => `- ${item}`)].join("\n");
|
|
121
|
+
recordBlocked(state, event.toolName, reason);
|
|
122
|
+
persistReportSnapshot(pi, state, "model-policy-conflict");
|
|
123
|
+
return { block: true, reason };
|
|
124
|
+
}
|
|
184
125
|
if (approved.length === 0) return;
|
|
185
126
|
|
|
186
127
|
const refs = collectRequestedModelRefs(event.input);
|
|
187
128
|
const corrections: Array<{ from: string; to: string; recovery?: string }> = [];
|
|
188
129
|
for (const ref of refs) {
|
|
189
130
|
if (isTakomiModelApproved(ref.value, approved)) continue;
|
|
190
|
-
const equivalent = approvedModelEquivalent(ref.value, approved);
|
|
191
|
-
if (equivalent) {
|
|
192
|
-
setModelRef(ref, equivalent);
|
|
193
|
-
corrections.push({ from: ref.value, to: equivalent });
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
131
|
const recovery = await askForInvalidModelRecovery(ctx, ref.value, approved);
|
|
197
132
|
if (recovery.action === "retry") {
|
|
198
133
|
setModelRef(ref, recovery.model);
|
|
@@ -221,7 +156,8 @@ export function installModelPolicyGate(pi: ExtensionAPI, state: ContextManagerSt
|
|
|
221
156
|
timestamp,
|
|
222
157
|
})));
|
|
223
158
|
persistReportSnapshot(pi, state, "model-policy-correction");
|
|
224
|
-
|
|
159
|
+
const notification = `Takomi model routing changed only after explicit user selection:\n- ${corrections.map((correction) => `${sanitizePresentation(correction.from)} -> ${sanitizePresentation(correction.to)}${correction.recovery ? ` (${sanitizePresentation(correction.recovery)})` : ""}`).join("\n- ")}`;
|
|
160
|
+
ctx.ui.notify(notification, "warning");
|
|
225
161
|
}
|
|
226
162
|
});
|
|
227
163
|
|
|
@@ -231,12 +167,9 @@ export function installModelPolicyGate(pi: ExtensionAPI, state: ContextManagerSt
|
|
|
231
167
|
if (!isModelFailure(content)) return;
|
|
232
168
|
|
|
233
169
|
const snapshot = await loadSnapshot(ctx.cwd);
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
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;
|
|
170
|
+
const recovery = recoveryOptions(snapshot.approvedModels);
|
|
171
|
+
const choice = await ctx.ui.select("Takomi subagent model/provider failure. How do you want to continue?", recovery.options);
|
|
172
|
+
const retryModel = choice ? recovery.retryModels.get(choice) : undefined;
|
|
240
173
|
const stopped = !retryModel;
|
|
241
174
|
const guidance = [
|
|
242
175
|
"Takomi subagent failed with a model/provider-related error.",
|