@oh-my-pi/pi-coding-agent 15.7.2 → 15.7.5

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 (188) hide show
  1. package/CHANGELOG.md +114 -6
  2. package/dist/types/cli/args.d.ts +1 -1
  3. package/dist/types/cli/extension-flags.d.ts +36 -0
  4. package/dist/types/config/config-file.d.ts +4 -0
  5. package/dist/types/config/file-lock.d.ts +23 -0
  6. package/dist/types/config/keybindings.d.ts +2 -1
  7. package/dist/types/config/model-registry.d.ts +6 -0
  8. package/dist/types/config/settings-schema.d.ts +69 -65
  9. package/dist/types/edit/hashline/diff.d.ts +3 -3
  10. package/dist/types/eval/__tests__/agent-bridge.test.d.ts +1 -0
  11. package/dist/types/eval/__tests__/budget-bridge.test.d.ts +1 -0
  12. package/dist/types/eval/__tests__/idle-timeout.test.d.ts +1 -0
  13. package/dist/types/eval/agent-bridge.d.ts +25 -0
  14. package/dist/types/eval/backend.d.ts +17 -2
  15. package/dist/types/eval/budget-bridge.d.ts +29 -0
  16. package/dist/types/eval/idle-timeout.d.ts +28 -0
  17. package/dist/types/eval/js/executor.d.ts +8 -0
  18. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  19. package/dist/types/eval/py/executor.d.ts +13 -0
  20. package/dist/types/exec/bash-executor.d.ts +1 -0
  21. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  22. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  23. package/dist/types/extensibility/plugins/git-url.d.ts +11 -1
  24. package/dist/types/extensibility/plugins/manager.d.ts +12 -1
  25. package/dist/types/extensibility/shared-events.d.ts +2 -2
  26. package/dist/types/internal-urls/local-protocol.d.ts +19 -9
  27. package/dist/types/internal-urls/types.d.ts +14 -0
  28. package/dist/types/lsp/client.d.ts +3 -0
  29. package/dist/types/mcp/manager.d.ts +14 -5
  30. package/dist/types/memory-backend/index.d.ts +1 -1
  31. package/dist/types/memory-backend/resolve.d.ts +1 -1
  32. package/dist/types/memory-backend/types.d.ts +3 -3
  33. package/dist/types/mnemopi/backend.d.ts +4 -0
  34. package/dist/types/mnemopi/config.d.ts +29 -0
  35. package/dist/types/mnemopi/state.d.ts +72 -0
  36. package/dist/types/modes/components/custom-editor.d.ts +2 -2
  37. package/dist/types/modes/components/omfg-panel.d.ts +19 -0
  38. package/dist/types/modes/controllers/command-controller.d.ts +6 -0
  39. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  40. package/dist/types/modes/controllers/omfg-controller.d.ts +10 -0
  41. package/dist/types/modes/controllers/omfg-rule.d.ts +26 -0
  42. package/dist/types/modes/gradient-highlight.d.ts +5 -1
  43. package/dist/types/modes/interactive-mode.d.ts +7 -3
  44. package/dist/types/modes/magic-keywords.d.ts +14 -0
  45. package/dist/types/modes/markdown-prose.d.ts +27 -0
  46. package/dist/types/modes/orchestrate.d.ts +7 -2
  47. package/dist/types/modes/shared.d.ts +1 -1
  48. package/dist/types/modes/turn-budget.d.ts +18 -0
  49. package/dist/types/modes/types.d.ts +7 -3
  50. package/dist/types/modes/ultrathink.d.ts +7 -2
  51. package/dist/types/modes/workflow.d.ts +15 -0
  52. package/dist/types/sdk.d.ts +13 -3
  53. package/dist/types/session/agent-session.d.ts +36 -17
  54. package/dist/types/session/session-manager.d.ts +18 -0
  55. package/dist/types/session/session-storage.d.ts +6 -0
  56. package/dist/types/session/shake-types.d.ts +24 -0
  57. package/dist/types/task/executor.d.ts +2 -2
  58. package/dist/types/task/repair-args.d.ts +52 -0
  59. package/dist/types/tiny/models.d.ts +1 -1
  60. package/dist/types/tiny/title-client.d.ts +28 -2
  61. package/dist/types/tiny/title-protocol.d.ts +8 -5
  62. package/dist/types/tools/find.d.ts +1 -1
  63. package/dist/types/tools/index.d.ts +19 -3
  64. package/dist/types/tools/memory-edit.d.ts +1 -1
  65. package/dist/types/tools/path-utils.d.ts +7 -0
  66. package/dist/types/tui/output-block.d.ts +7 -7
  67. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  68. package/package.json +10 -10
  69. package/scripts/build-binary.ts +0 -1
  70. package/src/autoresearch/tools/run-experiment.ts +45 -113
  71. package/src/cli/args.ts +39 -16
  72. package/src/cli/extension-flags.ts +48 -0
  73. package/src/cli/plugin-cli.ts +11 -2
  74. package/src/cli.ts +59 -0
  75. package/src/config/config-file.ts +98 -13
  76. package/src/config/file-lock.ts +60 -17
  77. package/src/config/keybindings.ts +78 -27
  78. package/src/config/model-registry.ts +7 -1
  79. package/src/config/settings-schema.ts +73 -67
  80. package/src/config/settings.ts +22 -0
  81. package/src/edit/hashline/diff.ts +81 -24
  82. package/src/edit/renderer.ts +16 -12
  83. package/src/eval/__tests__/agent-bridge.test.ts +433 -0
  84. package/src/eval/__tests__/budget-bridge.test.ts +69 -0
  85. package/src/eval/__tests__/idle-timeout.test.ts +66 -0
  86. package/src/eval/__tests__/shared-executors.test.ts +21 -0
  87. package/src/eval/agent-bridge.ts +295 -0
  88. package/src/eval/backend.ts +17 -2
  89. package/src/eval/budget-bridge.ts +48 -0
  90. package/src/eval/idle-timeout.ts +80 -0
  91. package/src/eval/js/executor.ts +35 -7
  92. package/src/eval/js/index.ts +2 -1
  93. package/src/eval/js/shared/prelude.txt +85 -1
  94. package/src/eval/js/tool-bridge.ts +9 -0
  95. package/src/eval/py/executor.ts +41 -14
  96. package/src/eval/py/index.ts +2 -1
  97. package/src/eval/py/prelude.py +132 -1
  98. package/src/exec/bash-executor.ts +2 -3
  99. package/src/extensibility/custom-tools/types.ts +2 -2
  100. package/src/extensibility/extensions/runner.ts +12 -2
  101. package/src/extensibility/plugins/git-url.ts +90 -4
  102. package/src/extensibility/plugins/manager.ts +103 -7
  103. package/src/extensibility/shared-events.ts +2 -2
  104. package/src/internal-urls/docs-index.generated.ts +88 -88
  105. package/src/internal-urls/local-protocol.ts +23 -11
  106. package/src/internal-urls/types.ts +15 -0
  107. package/src/lsp/client.ts +28 -5
  108. package/src/main.ts +44 -55
  109. package/src/mcp/manager.ts +87 -4
  110. package/src/memory-backend/index.ts +1 -1
  111. package/src/memory-backend/resolve.ts +3 -3
  112. package/src/memory-backend/types.ts +3 -3
  113. package/src/{mnemosyne → mnemopi}/backend.ts +70 -70
  114. package/src/{mnemosyne → mnemopi}/config.ts +36 -36
  115. package/src/{mnemosyne → mnemopi}/state.ts +67 -67
  116. package/src/modes/components/agent-dashboard.ts +6 -6
  117. package/src/modes/components/custom-editor.ts +4 -11
  118. package/src/modes/components/extensions/state-manager.ts +3 -4
  119. package/src/modes/components/footer.ts +8 -9
  120. package/src/modes/components/hook-selector.ts +86 -20
  121. package/src/modes/components/oauth-selector.ts +93 -21
  122. package/src/modes/components/omfg-panel.ts +141 -0
  123. package/src/modes/components/settings-defs.ts +2 -2
  124. package/src/modes/components/tips.txt +2 -1
  125. package/src/modes/components/tool-execution.ts +38 -19
  126. package/src/modes/components/tree-selector.ts +4 -3
  127. package/src/modes/components/user-message-selector.ts +94 -19
  128. package/src/modes/components/user-message.ts +8 -1
  129. package/src/modes/controllers/command-controller.ts +25 -0
  130. package/src/modes/controllers/event-controller.ts +68 -3
  131. package/src/modes/controllers/input-controller.ts +14 -11
  132. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  133. package/src/modes/controllers/omfg-controller.ts +283 -0
  134. package/src/modes/controllers/omfg-rule.ts +647 -0
  135. package/src/modes/controllers/selector-controller.ts +1 -0
  136. package/src/modes/gradient-highlight.ts +23 -6
  137. package/src/modes/interactive-mode.ts +41 -7
  138. package/src/modes/magic-keywords.ts +20 -0
  139. package/src/modes/markdown-prose.ts +247 -0
  140. package/src/modes/orchestrate.ts +17 -11
  141. package/src/modes/shared.ts +3 -11
  142. package/src/modes/turn-budget.ts +31 -0
  143. package/src/modes/types.ts +7 -1
  144. package/src/modes/ultrathink.ts +16 -10
  145. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  146. package/src/modes/workflow.ts +42 -0
  147. package/src/prompts/system/omfg-user.md +51 -0
  148. package/src/prompts/system/project-prompt.md +3 -2
  149. package/src/prompts/system/subagent-system-prompt.md +12 -8
  150. package/src/prompts/system/system-prompt.md +9 -6
  151. package/src/prompts/system/workflow-notice.md +70 -0
  152. package/src/prompts/tools/eval.md +13 -1
  153. package/src/prompts/tools/memory-edit.md +1 -1
  154. package/src/sdk.ts +63 -33
  155. package/src/session/agent-session.ts +290 -55
  156. package/src/session/session-manager.ts +32 -0
  157. package/src/session/session-storage.ts +68 -8
  158. package/src/session/shake-types.ts +43 -0
  159. package/src/slash-commands/builtin-registry.ts +39 -16
  160. package/src/task/executor.ts +17 -7
  161. package/src/task/index.ts +9 -8
  162. package/src/task/repair-args.ts +117 -0
  163. package/src/tiny/models.ts +2 -2
  164. package/src/tiny/title-client.ts +133 -43
  165. package/src/tiny/title-protocol.ts +10 -5
  166. package/src/tiny/worker.ts +3 -46
  167. package/src/tools/ast-edit.ts +3 -0
  168. package/src/tools/ast-grep.ts +3 -0
  169. package/src/tools/eval.ts +202 -26
  170. package/src/tools/find.ts +20 -6
  171. package/src/tools/gh.ts +1 -0
  172. package/src/tools/grouped-file-output.ts +9 -2
  173. package/src/tools/index.ts +17 -5
  174. package/src/tools/memory-edit.ts +4 -4
  175. package/src/tools/memory-recall.ts +5 -5
  176. package/src/tools/memory-reflect.ts +5 -5
  177. package/src/tools/memory-retain.ts +4 -4
  178. package/src/tools/path-utils.ts +13 -2
  179. package/src/tools/read.ts +1 -0
  180. package/src/tools/render-utils.ts +2 -1
  181. package/src/tools/search.ts +491 -76
  182. package/src/tui/output-block.ts +37 -75
  183. package/src/utils/git.ts +9 -3
  184. package/dist/types/mnemosyne/backend.d.ts +0 -4
  185. package/dist/types/mnemosyne/config.d.ts +0 -29
  186. package/dist/types/mnemosyne/state.d.ts +0 -72
  187. /package/dist/types/{mnemosyne → mnemopi}/index.d.ts +0 -0
  188. /package/src/{mnemosyne → mnemopi}/index.ts +0 -0
@@ -0,0 +1,647 @@
1
+ import * as path from "node:path";
2
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
4
+ import type { Rule } from "../../capability/rule";
5
+ import { buildRuleFromMarkdown, createSourceMeta } from "../../discovery/helpers";
6
+ import { TtsrManager, type TtsrMatchContext } from "../../export/ttsr";
7
+
8
+ export interface ParsedGeneratedRule {
9
+ rule: Rule;
10
+ fileContent: string;
11
+ }
12
+
13
+ export type GeneratedRuleParseResult = ParsedGeneratedRule | { error: string };
14
+
15
+ export interface RuleHistoryValidation {
16
+ matched: boolean;
17
+ feedback?: string;
18
+ }
19
+
20
+ export interface ParsedRuleHistoryValidation {
21
+ candidate: ParsedGeneratedRule;
22
+ validation: RuleHistoryValidation;
23
+ repairedCondition: boolean;
24
+ }
25
+ export type OmfgRuleSourceLevel = "project" | "user";
26
+
27
+ const JSON_FENCE_PATTERN = /```(?:json)?\s*([\s\S]*?)```/i;
28
+
29
+ export function extractGeneratedRuleJson(text: string): string | null {
30
+ const trimmed = text.trim();
31
+ const fenced = JSON_FENCE_PATTERN.exec(trimmed);
32
+ if (fenced?.[1]) {
33
+ const fencedObject = extractBalancedJsonObject(fenced[1]);
34
+ if (fencedObject) return fencedObject;
35
+ }
36
+ return extractBalancedJsonObject(trimmed);
37
+ }
38
+
39
+ export function sanitizeRuleName(rawName: string): string {
40
+ return rawName
41
+ .trim()
42
+ .toLowerCase()
43
+ .replace(/["'`]/g, "")
44
+ .replace(/[^a-z0-9_-]+/g, "-")
45
+ .replace(/-+/g, "-")
46
+ .replace(/^[-_]+|[-_]+$/g, "");
47
+ }
48
+
49
+ export function buildOmfgRuleForPath(
50
+ ruleName: string,
51
+ fileContent: string,
52
+ filePath: string,
53
+ level: OmfgRuleSourceLevel,
54
+ ): Rule {
55
+ return buildRuleFromMarkdown(ruleName, fileContent, filePath, createSourceMeta("omfg", filePath, level), {
56
+ ruleName,
57
+ });
58
+ }
59
+
60
+ function normalizeConditionRegexes(conditions: readonly string[]): { condition: string[] } | { error: string } {
61
+ const normalized: string[] = [];
62
+ for (const condition of conditions) {
63
+ const normalizedCondition = normalizeConditionRegex(condition);
64
+ if ("error" in normalizedCondition) {
65
+ return normalizedCondition;
66
+ }
67
+ if (!normalized.includes(normalizedCondition.condition)) {
68
+ normalized.push(normalizedCondition.condition);
69
+ }
70
+ }
71
+ return { condition: normalized };
72
+ }
73
+
74
+ function normalizeConditionRegex(condition: string): { condition: string } | { error: string } {
75
+ try {
76
+ new RegExp(condition);
77
+ return { condition };
78
+ } catch (originalError) {
79
+ const repaired = unescapeRegexConditionOnce(condition);
80
+ if (repaired !== condition) {
81
+ try {
82
+ new RegExp(repaired);
83
+ return { condition: repaired };
84
+ } catch {}
85
+ }
86
+ const message = originalError instanceof Error ? originalError.message : String(originalError);
87
+ return { error: `Invalid condition regex ${JSON.stringify(condition)}: ${message}` };
88
+ }
89
+ }
90
+
91
+ function unescapeRegexConditionOnce(condition: string): string {
92
+ return condition.replace(/\\\\/g, "\\");
93
+ }
94
+
95
+ export function parseGeneratedRule(text: string): GeneratedRuleParseResult {
96
+ const jsonText = extractGeneratedRuleJson(text);
97
+ if (!jsonText) {
98
+ return { error: "Missing generated rule JSON object" };
99
+ }
100
+
101
+ const payloadResult = parseGeneratedRulePayload(jsonText);
102
+ if ("error" in payloadResult) {
103
+ return payloadResult;
104
+ }
105
+
106
+ const ruleName = sanitizeRuleName(payloadResult.name);
107
+ if (ruleName.length === 0) {
108
+ return { error: "Rule name must contain at least one letter or digit" };
109
+ }
110
+
111
+ const conditionResult = normalizeConditionRegexes(payloadResult.condition);
112
+ if ("error" in conditionResult) {
113
+ return conditionResult;
114
+ }
115
+
116
+ const fileContent = assembleRuleMarkdown({
117
+ name: ruleName,
118
+ description: payloadResult.description,
119
+ condition: conditionResult.condition,
120
+ scope: payloadResult.scope,
121
+ body: payloadResult.body,
122
+ });
123
+
124
+ const virtualPath = path.join(process.cwd(), `${ruleName}.md`);
125
+ let rule: Rule;
126
+ try {
127
+ rule = buildOmfgRuleForPath(ruleName, fileContent, virtualPath, "project");
128
+ } catch (error) {
129
+ return { error: error instanceof Error ? error.message : String(error) };
130
+ }
131
+
132
+ if (!rule.condition || rule.condition.length === 0) {
133
+ return { error: "Generated rule JSON must include at least one condition" };
134
+ }
135
+
136
+ for (const condition of rule.condition) {
137
+ if (isValidRegexCondition(condition) || isRepairableEscapedRegexCondition(condition)) {
138
+ continue;
139
+ }
140
+ try {
141
+ new RegExp(condition);
142
+ } catch (error) {
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ return { error: `Invalid condition regex ${JSON.stringify(condition)}: ${message}` };
145
+ }
146
+ }
147
+
148
+ const manager = new TtsrManager();
149
+ if (!manager.addRule(rule)) {
150
+ return { error: "Rule has no valid condition or reachable scope" };
151
+ }
152
+
153
+ return { rule, fileContent };
154
+ }
155
+
156
+ interface GeneratedRulePayload {
157
+ name: string;
158
+ description: string;
159
+ condition: string[];
160
+ scope: string[];
161
+ body: string;
162
+ }
163
+
164
+ function extractBalancedJsonObject(text: string): string | null {
165
+ const start = text.indexOf("{");
166
+ if (start === -1) return null;
167
+
168
+ let depth = 0;
169
+ let inString = false;
170
+ let escaped = false;
171
+ for (let i = start; i < text.length; i++) {
172
+ const char = text[i];
173
+ if (inString) {
174
+ if (escaped) {
175
+ escaped = false;
176
+ continue;
177
+ }
178
+ if (char === "\\") {
179
+ escaped = true;
180
+ continue;
181
+ }
182
+ if (char === '"') {
183
+ inString = false;
184
+ }
185
+ continue;
186
+ }
187
+
188
+ if (char === '"') {
189
+ inString = true;
190
+ continue;
191
+ }
192
+ if (char === "{") {
193
+ depth++;
194
+ continue;
195
+ }
196
+ if (char === "}") {
197
+ depth--;
198
+ if (depth === 0) {
199
+ return text.slice(start, i + 1);
200
+ }
201
+ }
202
+ }
203
+
204
+ return null;
205
+ }
206
+
207
+ function parseGeneratedRulePayload(jsonText: string): GeneratedRulePayload | { error: string } {
208
+ let parsed: unknown;
209
+ try {
210
+ parsed = JSON.parse(jsonText);
211
+ } catch (error) {
212
+ const message = error instanceof Error ? error.message : String(error);
213
+ return { error: `Generated rule JSON is invalid: ${message}` };
214
+ }
215
+
216
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
217
+ return { error: "Generated rule JSON must be an object" };
218
+ }
219
+
220
+ const object = parsed as Record<string, unknown>;
221
+ const rawName = stringField(object, "name");
222
+ if (!rawName) {
223
+ return { error: "Generated rule JSON must include a non-empty name" };
224
+ }
225
+ const description = stringField(object, "description") ?? stringField(object, "desc");
226
+ if (!description) {
227
+ return { error: "Generated rule JSON must include a non-empty description" };
228
+ }
229
+
230
+ const condition = stringArrayField(object, "condition") ?? stringArrayField(object, "cond");
231
+ if (!condition || condition.length === 0) {
232
+ return { error: "Generated rule JSON must include at least one condition" };
233
+ }
234
+
235
+ const scope = stringArrayField(object, "scope");
236
+ if (!scope || scope.length === 0) {
237
+ return { error: "Generated rule JSON must include at least one scope" };
238
+ }
239
+
240
+ const body = stringField(object, "body");
241
+ if (!body) {
242
+ return { error: "Generated rule JSON must include a non-empty body" };
243
+ }
244
+
245
+ return {
246
+ name: rawName,
247
+ description,
248
+ condition,
249
+ scope,
250
+ body,
251
+ };
252
+ }
253
+
254
+ function stringField(object: Record<string, unknown>, key: string): string | undefined {
255
+ const value = object[key];
256
+ if (typeof value !== "string") return undefined;
257
+ const trimmed = value.trim();
258
+ return trimmed.length > 0 ? trimmed : undefined;
259
+ }
260
+
261
+ function stringArrayField(object: Record<string, unknown>, key: string): string[] | undefined {
262
+ const value = object[key];
263
+ if (typeof value === "string") {
264
+ const trimmed = value.trim();
265
+ return trimmed.length > 0 ? [trimmed] : undefined;
266
+ }
267
+ if (!Array.isArray(value)) return undefined;
268
+
269
+ const items: string[] = [];
270
+ for (const item of value) {
271
+ if (typeof item !== "string") continue;
272
+ const trimmed = item.trim();
273
+ if (trimmed.length > 0 && !items.includes(trimmed)) {
274
+ items.push(trimmed);
275
+ }
276
+ }
277
+ return items.length > 0 ? items : undefined;
278
+ }
279
+
280
+ function assembleRuleMarkdown(payload: GeneratedRulePayload): string {
281
+ return [
282
+ "---",
283
+ `name: ${payload.name}`,
284
+ `description: ${JSON.stringify(payload.description)}`,
285
+ `condition: ${formatFrontmatterStringArray(payload.condition)}`,
286
+ `scope: ${formatFrontmatterStringArray(payload.scope)}`,
287
+ "---",
288
+ "",
289
+ payload.body.trim().replace(/\r\n?/g, "\n"),
290
+ ].join("\n");
291
+ }
292
+
293
+ function formatFrontmatterStringArray(values: readonly string[]): string {
294
+ if (values.length === 1) {
295
+ return JSON.stringify(values[0]);
296
+ }
297
+ return `[${values.map(value => JSON.stringify(value)).join(", ")}]`;
298
+ }
299
+
300
+ interface HistorySurface {
301
+ text: string;
302
+ label: string;
303
+ context: TtsrMatchContext;
304
+ }
305
+
306
+ function collectAssistantSurfaces(messages: readonly AgentMessage[]): HistorySurface[] {
307
+ const surfaces: HistorySurface[] = [];
308
+ for (const message of messages) {
309
+ if (!isAssistantMessage(message)) continue;
310
+ for (let index = 0; index < message.content.length; index++) {
311
+ const block = message.content[index];
312
+ if (block.type === "text") {
313
+ surfaces.push({
314
+ text: block.text,
315
+ label: "assistant text",
316
+ context: { source: "text" },
317
+ });
318
+ continue;
319
+ }
320
+ if (block.type === "thinking") {
321
+ surfaces.push({
322
+ text: block.thinking,
323
+ label: "assistant thinking",
324
+ context: { source: "thinking" },
325
+ });
326
+ continue;
327
+ }
328
+ if (block.type === "toolCall") {
329
+ const filePaths = extractArgPaths(block.arguments);
330
+ surfaces.push({
331
+ text: stringifyToolArguments(block.arguments),
332
+ label: formatToolSurfaceLabel(block.name, filePaths),
333
+ context: {
334
+ source: "tool",
335
+ toolName: block.name,
336
+ filePaths,
337
+ streamKey: block.id ? `toolcall:${block.id}` : `tool:${block.name}:${index}`,
338
+ },
339
+ });
340
+ }
341
+ }
342
+ }
343
+ return surfaces;
344
+ }
345
+
346
+ export function validateRuleAgainstAssistantHistory(
347
+ rule: Rule,
348
+ messages: readonly AgentMessage[],
349
+ ): RuleHistoryValidation {
350
+ const manager = new TtsrManager();
351
+ if (!manager.addRule(rule)) {
352
+ return {
353
+ matched: false,
354
+ feedback: "TTSR rejected the rule: it has no valid condition or its scope cannot reach any stream.",
355
+ };
356
+ }
357
+
358
+ const surfaces = collectAssistantSurfaces(messages);
359
+ const matches: HistorySurface[] = [];
360
+ for (const surface of surfaces) {
361
+ manager.resetBuffer();
362
+ if (surface.text.length > 0 && manager.checkDelta(surface.text, surface.context).length > 0) {
363
+ matches.push(surface);
364
+ }
365
+ }
366
+
367
+ if (matches.length === 0) {
368
+ return { matched: false, feedback: buildNoMatchFeedback(rule, surfaces) };
369
+ }
370
+
371
+ const scopeFeedback = buildScopeFeedback(rule, matches);
372
+ if (scopeFeedback) {
373
+ return { matched: false, feedback: scopeFeedback };
374
+ }
375
+
376
+ return { matched: true };
377
+ }
378
+
379
+ export function validateParsedRuleAgainstAssistantHistory(
380
+ candidate: ParsedGeneratedRule,
381
+ messages: readonly AgentMessage[],
382
+ ): ParsedRuleHistoryValidation {
383
+ const validation = validateRuleAgainstAssistantHistory(candidate.rule, messages);
384
+ if (validation.matched) {
385
+ return { candidate, validation, repairedCondition: false };
386
+ }
387
+
388
+ const repaired = repairEscapedConditions(candidate);
389
+ if (!repaired) {
390
+ return { candidate, validation, repairedCondition: false };
391
+ }
392
+
393
+ const repairedValidation = validateRuleAgainstAssistantHistory(repaired.rule, messages);
394
+ if (repairedValidation.matched) {
395
+ return { candidate: repaired, validation: repairedValidation, repairedCondition: true };
396
+ }
397
+
398
+ return { candidate, validation, repairedCondition: false };
399
+ }
400
+
401
+ export function ruleMatchesAssistantHistory(rule: Rule, messages: readonly AgentMessage[]): boolean {
402
+ return validateRuleAgainstAssistantHistory(rule, messages).matched;
403
+ }
404
+
405
+ function isValidRegexCondition(condition: string): boolean {
406
+ try {
407
+ new RegExp(condition);
408
+ return true;
409
+ } catch {
410
+ return false;
411
+ }
412
+ }
413
+
414
+ function isRepairableEscapedRegexCondition(condition: string): boolean {
415
+ const repaired = condition.replace(/\\\\/g, "\\");
416
+ return repaired !== condition && isValidRegexCondition(repaired);
417
+ }
418
+
419
+ function repairEscapedConditions(candidate: ParsedGeneratedRule): ParsedGeneratedRule | undefined {
420
+ const currentConditions = candidate.rule.condition;
421
+ if (!currentConditions || currentConditions.length === 0) return undefined;
422
+
423
+ const repairedConditions: string[] = [];
424
+ let changed = false;
425
+ for (const condition of currentConditions) {
426
+ const repaired = condition.replace(/\\\\/g, "\\");
427
+ repairedConditions.push(repaired);
428
+ if (repaired !== condition) {
429
+ changed = true;
430
+ }
431
+ }
432
+ if (!changed) return undefined;
433
+
434
+ const scope = candidate.rule.scope;
435
+ if (!scope || scope.length === 0) return undefined;
436
+
437
+ const fileContent = assembleRuleMarkdown({
438
+ name: candidate.rule.name,
439
+ description: candidate.rule.description ?? candidate.rule.name,
440
+ condition: repairedConditions,
441
+ scope,
442
+ body: candidate.rule.content,
443
+ });
444
+ const level = candidate.rule._source.level === "user" ? "user" : "project";
445
+ return {
446
+ rule: buildOmfgRuleForPath(candidate.rule.name, fileContent, candidate.rule.path, level),
447
+ fileContent,
448
+ };
449
+ }
450
+
451
+ function buildNoMatchFeedback(rule: Rule, surfaces: readonly HistorySurface[]): string {
452
+ const hints = extractConditionHints(rule.condition);
453
+ const lines = [
454
+ `No assistant history surface matched condition ${formatRuleList(rule.condition)} within scope ${formatRuleList(rule.scope)}.`,
455
+ ];
456
+ if (surfaces.length === 0) {
457
+ lines.push("No assistant text, thinking, or tool-call argument surfaces were available to check.");
458
+ return lines.join("\n");
459
+ }
460
+
461
+ lines.push("Checked surfaces:");
462
+ const max = Math.min(surfaces.length, 5);
463
+ for (let i = 0; i < max; i++) {
464
+ const surface = surfaces[i];
465
+ lines.push(`- ${surface.label}: ${JSON.stringify(excerptForSurface(surface.text, hints))}`);
466
+ }
467
+ if (surfaces.length > max) {
468
+ lines.push(`- ... ${surfaces.length - max} more surface(s)`);
469
+ }
470
+ lines.push(
471
+ 'If the visible bad code contains quotes, remember tool arguments are checked as serialized JSON, so quotes may appear as escaped sequences such as \\".',
472
+ );
473
+ lines.push("If the condition looks right, fix the scope so it reaches the offending tool and file glob.");
474
+ return lines.join("\n");
475
+ }
476
+
477
+ function buildScopeFeedback(rule: Rule, matches: readonly HistorySurface[]): string | undefined {
478
+ const toolMatch = findFileToolMatch(matches);
479
+ if (!toolMatch) return undefined;
480
+
481
+ const recommendedScope = recommendedToolScope(toolMatch);
482
+ if (!recommendedScope) return undefined;
483
+
484
+ const scope = rule.scope ?? [];
485
+ let hasBroadToolScope = scope.length === 0;
486
+ let hasTextScope = false;
487
+ for (const rawToken of scope) {
488
+ const token = rawToken.trim().toLowerCase();
489
+ if (token === "tool" || token === "toolcall") {
490
+ hasBroadToolScope = true;
491
+ continue;
492
+ }
493
+ if (token === "text") {
494
+ hasTextScope = true;
495
+ }
496
+ }
497
+
498
+ if (!hasBroadToolScope && !hasTextScope) {
499
+ return undefined;
500
+ }
501
+
502
+ const problems: string[] = [];
503
+ if (hasBroadToolScope) {
504
+ problems.push(`scope ${formatRuleList(rule.scope)} is broader than the matching file-specific tool call`);
505
+ }
506
+ if (hasTextScope) {
507
+ problems.push("scope includes `text`, but the offending content was confirmed in tool arguments");
508
+ }
509
+
510
+ return `The condition matched ${toolMatch.label}, but ${problems.join("; ")}. Use a narrow scope such as ${JSON.stringify(
511
+ recommendedScope,
512
+ )} and do not repeat the failed scope ${formatRuleList(rule.scope)}.`;
513
+ }
514
+
515
+ function findFileToolMatch(matches: readonly HistorySurface[]): HistorySurface | undefined {
516
+ for (const match of matches) {
517
+ if (match.context.source !== "tool") continue;
518
+ if (!match.context.toolName) continue;
519
+ if (!extensionGlob(match.context.filePaths)) continue;
520
+ return match;
521
+ }
522
+ return undefined;
523
+ }
524
+
525
+ function recommendedToolScope(surface: HistorySurface): string | undefined {
526
+ const toolName = surface.context.toolName;
527
+ const glob = extensionGlob(surface.context.filePaths);
528
+ if (!toolName || !glob) return undefined;
529
+ return `tool:${toolName}(${glob})`;
530
+ }
531
+
532
+ function extensionGlob(filePaths: readonly string[] | undefined): string | undefined {
533
+ for (const filePath of filePaths ?? []) {
534
+ const extension = path.extname(filePath.replaceAll("\\", "/")).toLowerCase();
535
+ if (extension.length > 1) {
536
+ return `*${extension}`;
537
+ }
538
+ }
539
+ return undefined;
540
+ }
541
+
542
+ function formatToolSurfaceLabel(toolName: string, filePaths: readonly string[] | undefined): string {
543
+ if (!filePaths || filePaths.length === 0) {
544
+ return `tool:${toolName} serialized arguments`;
545
+ }
546
+ return `tool:${toolName}(${filePaths.join(", ")}) serialized arguments`;
547
+ }
548
+
549
+ function formatRuleList(values: readonly string[] | undefined): string {
550
+ if (!values || values.length === 0) {
551
+ return "<default>";
552
+ }
553
+ return values.map(value => JSON.stringify(value)).join(", ");
554
+ }
555
+
556
+ function extractConditionHints(conditions: readonly string[] | undefined): string[] {
557
+ const hints: string[] = [];
558
+ for (const condition of conditions ?? []) {
559
+ const matches = condition.match(/[A-Za-z_][A-Za-z0-9_]{2,}/g) ?? [];
560
+ for (const match of matches) {
561
+ const normalized = match.toLowerCase();
562
+ if (
563
+ normalized === "tool" ||
564
+ normalized === "text" ||
565
+ normalized === "any" ||
566
+ normalized === "true" ||
567
+ normalized === "false"
568
+ ) {
569
+ continue;
570
+ }
571
+ if (!hints.includes(normalized)) {
572
+ hints.push(normalized);
573
+ }
574
+ }
575
+ }
576
+ return hints;
577
+ }
578
+
579
+ function excerptForSurface(text: string, hints: readonly string[]): string {
580
+ const normalized = text.replace(/\s+/g, " ");
581
+ if (normalized.length <= 260) {
582
+ return normalized;
583
+ }
584
+
585
+ const lower = normalized.toLowerCase();
586
+ let bestIndex = -1;
587
+ for (const hint of hints) {
588
+ const index = lower.indexOf(hint);
589
+ if (index !== -1 && (bestIndex === -1 || index < bestIndex)) {
590
+ bestIndex = index;
591
+ }
592
+ }
593
+ if (bestIndex === -1) {
594
+ return `${normalized.slice(0, 260)}…`;
595
+ }
596
+
597
+ const start = Math.max(0, bestIndex - 120);
598
+ const end = Math.min(normalized.length, bestIndex + 140);
599
+ const prefix = start > 0 ? "…" : "";
600
+ const suffix = end < normalized.length ? "…" : "";
601
+ return `${prefix}${normalized.slice(start, end)}${suffix}`;
602
+ }
603
+
604
+ function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
605
+ const candidate = message as { role?: unknown; content?: unknown };
606
+ return candidate.role === "assistant" && Array.isArray(candidate.content);
607
+ }
608
+
609
+ function stringifyToolArguments(args: unknown): string {
610
+ try {
611
+ const text = JSON.stringify(args);
612
+ return typeof text === "string" ? text : "";
613
+ } catch {
614
+ return "";
615
+ }
616
+ }
617
+
618
+ function extractArgPaths(args: unknown): string[] | undefined {
619
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
620
+ return undefined;
621
+ }
622
+
623
+ const paths: string[] = [];
624
+ for (const key in args as Record<string, unknown>) {
625
+ const value = (args as Record<string, unknown>)[key];
626
+ const normalizedKey = key.toLowerCase();
627
+ if (typeof value === "string" && (normalizedKey === "path" || normalizedKey.endsWith("path"))) {
628
+ paths.push(value);
629
+ continue;
630
+ }
631
+ if (Array.isArray(value) && (normalizedKey === "paths" || normalizedKey.endsWith("paths"))) {
632
+ for (const candidate of value) {
633
+ if (typeof candidate === "string") {
634
+ paths.push(candidate);
635
+ }
636
+ }
637
+ }
638
+ }
639
+
640
+ const uniquePaths: string[] = [];
641
+ for (const candidate of paths) {
642
+ if (!uniquePaths.includes(candidate)) {
643
+ uniquePaths.push(candidate);
644
+ }
645
+ }
646
+ return uniquePaths.length > 0 ? uniquePaths : undefined;
647
+ }
@@ -426,6 +426,7 @@ export class SelectorController {
426
426
  await this.ctx.session.setModel(model, role, {
427
427
  selector,
428
428
  thinkingLevel: concreteThinking,
429
+ persist: true,
429
430
  });
430
431
  if (isAuto) {
431
432
  this.ctx.session.setThinkingLevel(AUTO_THINKING, true);
@@ -1,5 +1,11 @@
1
+ import { maskNonProse } from "./markdown-prose";
1
2
  import { theme } from "./theme/theme";
2
3
 
4
+ /** A gradient keyword highlighter. `resetTo` is the SGR foreground sequence
5
+ * re-emitted after each painted keyword so surrounding text keeps its color;
6
+ * it defaults to a plain foreground reset (editor / default-colored text). */
7
+ export type KeywordHighlighter = (text: string, resetTo?: string) => string;
8
+
3
9
  const FG_RESET = "\x1b[39m";
4
10
 
5
11
  /** Declarative spec for {@link createGradientHighlighter}. */
@@ -25,7 +31,7 @@ export interface GradientHighlightSpec {
25
31
  * untouched when `probe` does not match. The palette is compiled lazily and
26
32
  * memoized per active color mode.
27
33
  */
28
- export function createGradientHighlighter(spec: GradientHighlightSpec): (text: string) => string {
34
+ export function createGradientHighlighter(spec: GradientHighlightSpec): KeywordHighlighter {
29
35
  const { probe, highlight, stops, hue, saturation = 90, lightness = 62 } = spec;
30
36
 
31
37
  let cachedMode: string | undefined;
@@ -45,8 +51,8 @@ export function createGradientHighlighter(spec: GradientHighlightSpec): (text: s
45
51
  return next;
46
52
  };
47
53
 
48
- /** Paint each character of `word` with the next gradient stop, resetting fg after. */
49
- const paint = (word: string): string => {
54
+ /** Paint each character of `word` with the next gradient stop, restoring `resetTo` after. */
55
+ const paint = (word: string, resetTo: string): string => {
50
56
  const stopsArr = palette();
51
57
  const n = word.length;
52
58
  let out = "";
@@ -60,11 +66,22 @@ export function createGradientHighlighter(spec: GradientHighlightSpec): (text: s
60
66
  }
61
67
  out += word[i];
62
68
  }
63
- return `${out}${FG_RESET}`;
69
+ return `${out}${resetTo}`;
64
70
  };
65
71
 
66
- return (text: string): string => {
72
+ return (text: string, resetTo: string = FG_RESET): string => {
67
73
  if (!probe.test(text)) return text;
68
- return text.replace(highlight, paint);
74
+ // Match against a code/markup-masked copy so keywords inside code spans,
75
+ // fenced blocks, or XML sections never paint; indices still address `text`.
76
+ const masked = maskNonProse(text);
77
+ let out = "";
78
+ let last = 0;
79
+ for (const m of masked.matchAll(highlight)) {
80
+ const start = m.index ?? 0;
81
+ const end = start + m[0].length;
82
+ out += text.slice(last, start) + paint(text.slice(start, end), resetTo);
83
+ last = end;
84
+ }
85
+ return out + text.slice(last);
69
86
  };
70
87
  }