@oh-my-pi/pi-coding-agent 13.15.3 → 13.16.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +30 -16
  2. package/package.json +7 -7
  3. package/src/commit/agentic/tools/analyze-file.ts +1 -0
  4. package/src/config/model-registry.ts +215 -57
  5. package/src/config/settings-schema.ts +27 -0
  6. package/src/extensibility/custom-tools/types.ts +3 -0
  7. package/src/extensibility/extensions/runner.ts +7 -0
  8. package/src/extensibility/extensions/types.ts +10 -1
  9. package/src/extensibility/hooks/types.ts +1 -1
  10. package/src/internal-urls/docs-index.generated.ts +1 -1
  11. package/src/ipy/cancellation.ts +28 -0
  12. package/src/ipy/executor.ts +252 -77
  13. package/src/ipy/kernel.ts +181 -35
  14. package/src/ipy/modules.ts +39 -4
  15. package/src/modes/acp/acp-agent.ts +1 -0
  16. package/src/modes/components/hook-editor.ts +57 -8
  17. package/src/modes/components/model-selector.ts +48 -29
  18. package/src/modes/components/settings-defs.ts +10 -1
  19. package/src/modes/components/settings-selector.ts +92 -5
  20. package/src/modes/controllers/extension-ui-controller.ts +35 -4
  21. package/src/modes/controllers/input-controller.ts +4 -3
  22. package/src/modes/controllers/selector-controller.ts +2 -2
  23. package/src/modes/interactive-mode.ts +7 -2
  24. package/src/modes/print-mode.ts +1 -0
  25. package/src/modes/prompt-action-autocomplete.ts +5 -3
  26. package/src/modes/rpc/rpc-mode.ts +79 -30
  27. package/src/modes/rpc/rpc-types.ts +9 -1
  28. package/src/modes/theme/theme.ts +70 -0
  29. package/src/modes/types.ts +6 -1
  30. package/src/prompts/system/custom-system-prompt.md +5 -0
  31. package/src/prompts/system/system-prompt.md +6 -0
  32. package/src/prompts/tools/ask.md +1 -0
  33. package/src/prompts/tools/grep.md +1 -1
  34. package/src/prompts/tools/hashline.md +20 -5
  35. package/src/sdk.ts +26 -2
  36. package/src/session/agent-session.ts +18 -11
  37. package/src/system-prompt.ts +63 -2
  38. package/src/task/executor.ts +4 -0
  39. package/src/task/index.ts +2 -0
  40. package/src/tools/ask.ts +109 -61
  41. package/src/tools/ast-edit.ts +2 -16
  42. package/src/tools/ast-grep.ts +2 -17
  43. package/src/tools/browser.ts +35 -17
  44. package/src/tools/find.ts +1 -0
  45. package/src/tools/grep.ts +25 -34
  46. package/src/tools/index.ts +3 -0
  47. package/src/tools/path-utils.ts +7 -0
  48. package/src/tools/python.ts +3 -2
  49. package/src/tools/render-utils.ts +27 -0
  50. package/src/tui/tree-list.ts +51 -22
@@ -29,6 +29,77 @@ import type {
29
29
  // Re-export types for consumers
30
30
  export type * from "./rpc-types";
31
31
 
32
+ export type PendingExtensionRequest = {
33
+ resolve: (response: RpcExtensionUIResponse) => void;
34
+ reject: (error: Error) => void;
35
+ };
36
+
37
+ type RpcOutput = (obj: RpcResponse | RpcExtensionUIRequest | object) => void;
38
+
39
+ export function requestRpcEditor(
40
+ pendingRequests: Map<string, PendingExtensionRequest>,
41
+ output: RpcOutput,
42
+ title: string,
43
+ prefill?: string,
44
+ dialogOptions?: ExtensionUIDialogOptions,
45
+ editorOptions?: { promptStyle?: boolean },
46
+ ): Promise<string | undefined> {
47
+ if (dialogOptions?.signal?.aborted) return Promise.resolve(undefined);
48
+
49
+ const id = Snowflake.next() as string;
50
+ const { promise, resolve, reject } = Promise.withResolvers<string | undefined>();
51
+ let settled = false;
52
+
53
+ const cleanup = () => {
54
+ dialogOptions?.signal?.removeEventListener("abort", onAbort);
55
+ pendingRequests.delete(id);
56
+ };
57
+ const finish = (value: string | undefined) => {
58
+ if (settled) return;
59
+ settled = true;
60
+ cleanup();
61
+ resolve(value);
62
+ };
63
+ const fail = (error: Error) => {
64
+ if (settled) return;
65
+ settled = true;
66
+ cleanup();
67
+ reject(error);
68
+ };
69
+ const onAbort = () => {
70
+ output({
71
+ type: "extension_ui_request",
72
+ id: Snowflake.next() as string,
73
+ method: "cancel",
74
+ targetId: id,
75
+ } as RpcExtensionUIRequest);
76
+ finish(undefined);
77
+ };
78
+
79
+ dialogOptions?.signal?.addEventListener("abort", onAbort, { once: true });
80
+ pendingRequests.set(id, {
81
+ resolve: response => {
82
+ if ("cancelled" in response && response.cancelled) {
83
+ finish(undefined);
84
+ } else if ("value" in response) {
85
+ finish(response.value);
86
+ } else {
87
+ finish(undefined);
88
+ }
89
+ },
90
+ reject: fail,
91
+ });
92
+ output({
93
+ type: "extension_ui_request",
94
+ id,
95
+ method: "editor",
96
+ title,
97
+ prefill,
98
+ promptStyle: editorOptions?.promptStyle,
99
+ } as RpcExtensionUIRequest);
100
+ return promise;
101
+ }
102
+
32
103
  /**
33
104
  * Run in RPC mode.
34
105
  * Listens for JSON commands on stdin, outputs events and responses on stdout.
@@ -55,12 +126,6 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
55
126
  return { id, type: "response", command, success: false, error: message };
56
127
  };
57
128
 
58
- // Pending extension UI requests waiting for response
59
- type PendingExtensionRequest = {
60
- resolve: (response: RpcExtensionUIResponse) => void;
61
- reject: (error: Error) => void;
62
- };
63
-
64
129
  const pendingExtensionRequests = new Map<string, PendingExtensionRequest>();
65
130
 
66
131
  // Shutdown request flag (wrapped in object to allow mutation with const)
@@ -261,30 +326,13 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
261
326
  return "";
262
327
  }
263
328
 
264
- async editor(title: string, prefill?: string): Promise<string | undefined> {
265
- const id = Snowflake.next() as string;
266
- const { promise, resolve, reject } = Promise.withResolvers<string | undefined>();
267
- this.pendingRequests.set(id, {
268
- resolve: (response: RpcExtensionUIResponse) => {
269
- this.pendingRequests.delete(id);
270
- if ("cancelled" in response && response.cancelled) {
271
- resolve(undefined);
272
- } else if ("value" in response) {
273
- resolve(response.value);
274
- } else {
275
- resolve(undefined);
276
- }
277
- },
278
- reject,
279
- });
280
- this.output({
281
- type: "extension_ui_request",
282
- id,
283
- method: "editor",
284
- title,
285
- prefill,
286
- } as RpcExtensionUIRequest);
287
- return promise;
329
+ async editor(
330
+ title: string,
331
+ prefill?: string,
332
+ dialogOptions?: ExtensionUIDialogOptions,
333
+ editorOptions?: { promptStyle?: boolean },
334
+ ): Promise<string | undefined> {
335
+ return requestRpcEditor(this.pendingRequests, this.output, title, prefill, dialogOptions, editorOptions);
288
336
  }
289
337
 
290
338
  get theme(): Theme {
@@ -356,6 +404,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
356
404
  // ExtensionContextActions
357
405
  {
358
406
  getModel: () => session.agent.state.model,
407
+ getSearchDb: () => session.searchDb,
359
408
  isIdle: () => !session.isStreaming,
360
409
  abort: () => session.abort(),
361
410
  hasPendingMessages: () => session.queuedMessageCount > 0,
@@ -194,7 +194,15 @@ export type RpcExtensionUIRequest =
194
194
  placeholder?: string;
195
195
  timeout?: number;
196
196
  }
197
- | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string }
197
+ | {
198
+ type: "extension_ui_request";
199
+ id: string;
200
+ method: "editor";
201
+ title: string;
202
+ prefill?: string;
203
+ promptStyle?: boolean;
204
+ }
205
+ | { type: "extension_ui_request"; id: string; method: "cancel"; targetId: string }
198
206
  | {
199
207
  type: "extension_ui_request";
200
208
  id: string;
@@ -955,6 +955,76 @@ export type ThemeColor =
955
955
  | "statusLineCost"
956
956
  | "statusLineSubagents";
957
957
 
958
+ /** Set of all valid ThemeColor string values for runtime validation */
959
+ const THEME_COLOR_RECORD = {
960
+ accent: true,
961
+ border: true,
962
+ borderAccent: true,
963
+ borderMuted: true,
964
+ success: true,
965
+ error: true,
966
+ warning: true,
967
+ muted: true,
968
+ dim: true,
969
+ text: true,
970
+ thinkingText: true,
971
+ userMessageText: true,
972
+ customMessageText: true,
973
+ customMessageLabel: true,
974
+ toolTitle: true,
975
+ toolOutput: true,
976
+ mdHeading: true,
977
+ mdLink: true,
978
+ mdLinkUrl: true,
979
+ mdCode: true,
980
+ mdCodeBlock: true,
981
+ mdCodeBlockBorder: true,
982
+ mdQuote: true,
983
+ mdQuoteBorder: true,
984
+ mdHr: true,
985
+ mdListBullet: true,
986
+ toolDiffAdded: true,
987
+ toolDiffRemoved: true,
988
+ toolDiffContext: true,
989
+ syntaxComment: true,
990
+ syntaxKeyword: true,
991
+ syntaxFunction: true,
992
+ syntaxVariable: true,
993
+ syntaxString: true,
994
+ syntaxNumber: true,
995
+ syntaxType: true,
996
+ syntaxOperator: true,
997
+ syntaxPunctuation: true,
998
+ thinkingOff: true,
999
+ thinkingMinimal: true,
1000
+ thinkingLow: true,
1001
+ thinkingMedium: true,
1002
+ thinkingHigh: true,
1003
+ thinkingXhigh: true,
1004
+ bashMode: true,
1005
+ pythonMode: true,
1006
+ statusLineSep: true,
1007
+ statusLineModel: true,
1008
+ statusLinePath: true,
1009
+ statusLineGitClean: true,
1010
+ statusLineGitDirty: true,
1011
+ statusLineContext: true,
1012
+ statusLineSpend: true,
1013
+ statusLineStaged: true,
1014
+ statusLineDirty: true,
1015
+ statusLineUntracked: true,
1016
+ statusLineOutput: true,
1017
+ statusLineCost: true,
1018
+ statusLineSubagents: true,
1019
+ } satisfies Record<ThemeColor, true>;
1020
+
1021
+ const VALID_THEME_COLORS: ReadonlySet<string> = new Set(Object.keys(THEME_COLOR_RECORD));
1022
+
1023
+ /** Check if a string is a valid ThemeColor value */
1024
+ export function isValidThemeColor(color: string): color is ThemeColor {
1025
+ return VALID_THEME_COLORS.has(color);
1026
+ }
1027
+
958
1028
  export type ThemeBg =
959
1029
  | "selectedBg"
960
1030
  | "userMessageBg"
@@ -243,7 +243,12 @@ export interface InteractiveModeContext {
243
243
  hideHookSelector(): void;
244
244
  showHookInput(title: string, placeholder?: string): Promise<string | undefined>;
245
245
  hideHookInput(): void;
246
- showHookEditor(title: string, prefill?: string): Promise<string | undefined>;
246
+ showHookEditor(
247
+ title: string,
248
+ prefill?: string,
249
+ dialogOptions?: ExtensionUIDialogOptions,
250
+ editorOptions?: { promptStyle?: boolean },
251
+ ): Promise<string | undefined>;
247
252
  hideHookEditor(): void;
248
253
  showHookNotify(message: string, type?: "info" | "warning" | "error"): void;
249
254
  showHookCustom<T>(
@@ -40,6 +40,11 @@ If a skill covers your output, you **MUST** read `skill://<name>` before proceed
40
40
  {{/list}}
41
41
  </skills>
42
42
  {{/if}}
43
+ {{#if alwaysApplyRules.length}}
44
+ {{#each alwaysApplyRules}}
45
+ {{content}}
46
+ {{/each}}
47
+ {{/if}}
43
48
  {{#if rules.length}}
44
49
  Rules are local constraints.
45
50
  You **MUST** read `rule://<name>` when working in that domain.
@@ -124,6 +124,12 @@ You **MUST** use the following skills, to save you time, when working in their d
124
124
  {{/each}}
125
125
  {{/if}}
126
126
 
127
+ {{#if alwaysApplyRules.length}}
128
+ {{#each alwaysApplyRules}}
129
+ {{content}}
130
+ {{/each}}
131
+ {{/if}}
132
+
127
133
  {{#if rules.length}}
128
134
  # Rules
129
135
  Domain-specific rules from past experience. **MUST** read `rule://<name>` when working in their territory.
@@ -8,6 +8,7 @@ Asks user when you need clarification or input during task execution.
8
8
  - Use `recommended: <index>` to mark default (0-indexed); " (Recommended)" added automatically
9
9
  - Use `questions` for multiple related questions instead of asking one at a time
10
10
  - Set `multi: true` on question to allow multiple selections
11
+ - `ask.timeout` only applies while choosing options; once the user selects "Other (type your own)", there is no timeout
11
12
  </instruction>
12
13
 
13
14
  <caution>
@@ -1,4 +1,4 @@
1
- Searches files using powerful regex matching built on ripgrep.
1
+ Searches files using powerful regex matching.
2
2
 
3
3
  <instruction>
4
4
  - Supports full regex syntax (e.g., `log.*Error`, `function\\s+\\w+`); literal braces need escaping (`interface\\{\\}` for `interface{}` in Go)
@@ -2,8 +2,6 @@ Applies precise file edits using `LINE#ID` anchors from `read` output.
2
2
 
3
3
  Read the file first. Copy anchors exactly from the latest `read` output. In one `edit` call, batch all edits for one file. After any successful edit, re-read before editing that file again.
4
4
 
5
- This matters: your output is checked against the real file state. Invalid anchors, duplicated boundary lines, or semantically equivalent rewrites will fail.
6
-
7
5
  <operations>
8
6
  **Top level**
9
7
  - `path` — file path
@@ -61,6 +59,24 @@ Replace only the catch body. Do not target the shared boundary line `} catch (er
61
59
  ```
62
60
  </example>
63
61
 
62
+ <example name="replace whole block including closing brace">
63
+ Replace the entire body of `alpha`, including its closing `}`. `end` **MUST** be {{hlineref 7 "}"}} because `content` includes `}`.
64
+ ```
65
+ {
66
+ path: "util.ts",
67
+ edits: [{
68
+ loc: { block: { pos: {{hlineref 6 "\tlog();"}}, end: {{hlineref 7 "}"}} } },
69
+ content: [
70
+ "\tvalidate();",
71
+ "\tlog();",
72
+ "}"
73
+ ]
74
+ }]
75
+ }
76
+ ```
77
+ **Wrong**: using `end: {{hlineref 6 "\tlog();"}}` with the same content — line 7 (`}`) survives the replacement AND content emits `}`, producing two closing braces.
78
+ </example>
79
+
64
80
  <example name="replace one line">
65
81
  ```
66
82
  {
@@ -108,9 +124,8 @@ When adding a sibling declaration, prefer `prepend` on the next declaration.
108
124
  - Make the minimum exact edit. Do not rewrite nearby code unless the consumed range requires it.
109
125
  - Use anchors exactly as `N#ID` from the latest `read` output.
110
126
  - `block` requires both `pos` and `end`. Other anchored ops require one anchor.
111
- - Replace exactly the owned span. If `content` re-emits content beyond `end`, it will duplicate.
112
- - **Boundary duplication trap**: when replacing a block, `end` must be the **last line of the block** (e.g. the closing `}`), not the last *content* line before it. Otherwise the closing delimiter survives and your replacement adds a second copy.
113
- - Do not target shared boundary lines such as `} else {`, `} catch (…) {`, `}),`, or `},{`.
127
+ - When your replacement `content` ends with a closing delimiter (`}`, `*/`, `)`, `]`), verify `end` includes the original line carrying that delimiter. If `end` stops one line too early, the original delimiter survives and your content adds a second copy.
128
+ - **Self-check**: compare the last line of `content` with the line immediately after `end` in the file. If they match (e.g., both are `}`), extend `end` to include that line.
114
129
  - For a block, either replace only the body or replace the whole block. Do not split block boundaries.
115
130
  - `content` must be literal file content with matching indentation. If the file uses tabs, use real tabs.
116
131
  - Do not use this tool to reformat or clean up unrelated code.
package/src/sdk.ts CHANGED
@@ -9,8 +9,17 @@ import {
9
9
  import type { Message, Model } from "@oh-my-pi/pi-ai";
10
10
 
11
11
  import { prewarmOpenAICodexResponses } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
12
+ import { SearchDb } from "@oh-my-pi/pi-natives";
12
13
  import type { Component } from "@oh-my-pi/pi-tui";
13
- import { $env, getAgentDbPath, getAgentDir, getProjectDir, logger, postmortem } from "@oh-my-pi/pi-utils";
14
+ import {
15
+ $env,
16
+ getAgentDbPath,
17
+ getAgentDir,
18
+ getProjectDir,
19
+ getSearchDbDir,
20
+ logger,
21
+ postmortem,
22
+ } from "@oh-my-pi/pi-utils";
14
23
  import chalk from "chalk";
15
24
  import { AsyncJobManager } from "./async";
16
25
  import { createAutoresearchExtension } from "./autoresearch";
@@ -131,6 +140,8 @@ export interface CreateAgentSessionOptions {
131
140
  authStorage?: AuthStorage;
132
141
  /** Model registry. Default: discoverModels(authStorage, agentDir) */
133
142
  modelRegistry?: ModelRegistry;
143
+ /** Shared native search DB for grep/glob/fuzzyFind-backed workflows. */
144
+ searchDb?: SearchDb;
134
145
 
135
146
  /** Model to use. Default: from settings, else first available */
136
147
  model?: Model;
@@ -381,6 +392,7 @@ function createCustomToolContext(ctx: ExtensionContext): CustomToolContext {
381
392
  sessionManager: ctx.sessionManager,
382
393
  modelRegistry: ctx.modelRegistry,
383
394
  model: ctx.model,
395
+ searchDb: ctx.searchDb,
384
396
  isIdle: ctx.isIdle,
385
397
  hasQueuedMessages: ctx.hasPendingMessages,
386
398
  abort: ctx.abort,
@@ -796,6 +808,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
796
808
  }),
797
809
  );
798
810
 
811
+ // collect alwaysApply rules — full content injected into system prompt
812
+ const alwaysApplyRules = rulesResult.items.filter((rule: Rule) => {
813
+ if (registeredTtsrRuleNames.has(rule.name)) return false;
814
+ return rule.alwaysApply === true;
815
+ });
816
+
799
817
  const contextFiles = await logger.timeAsync(
800
818
  "discoverContextFiles",
801
819
  async () => options.contextFiles ?? (await discoverContextFiles(cwd, agentDir)),
@@ -856,6 +874,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
856
874
  })
857
875
  : undefined;
858
876
 
877
+ const searchDb = options.searchDb ?? new SearchDb(getSearchDbDir(agentDir));
859
878
  const pendingActionStore = new PendingActionStore();
860
879
  const toolSession: ToolSession = {
861
880
  cwd,
@@ -905,6 +924,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
905
924
  modelRegistry,
906
925
  asyncJobManager,
907
926
  pendingActionStore,
927
+ searchDb,
908
928
  };
909
929
 
910
930
  // Initialize internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://, mcp://, local://)
@@ -930,7 +950,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
930
950
  );
931
951
  internalRouter.register(
932
952
  new RuleProtocolHandler({
933
- getRules: () => rulebookRules,
953
+ getRules: () => [...rulebookRules, ...alwaysApplyRules],
934
954
  }),
935
955
  );
936
956
  internalRouter.register(new PiProtocolHandler());
@@ -1167,6 +1187,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1167
1187
  sessionManager,
1168
1188
  modelRegistry,
1169
1189
  model: agent?.state.model,
1190
+ searchDb,
1170
1191
  isIdle: () => !session?.isStreaming,
1171
1192
  hasQueuedMessages: () => (session?.queuedMessageCount ?? 0) > 0,
1172
1193
  abort: () => session?.abort(),
@@ -1252,6 +1273,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1252
1273
  tools: promptTools,
1253
1274
  toolNames,
1254
1275
  rules: rulebookRules,
1276
+ alwaysApplyRules,
1255
1277
  skillsSettings: settings.getGroup("skills"),
1256
1278
  appendSystemPrompt: appendPrompt,
1257
1279
  repeatToolDescriptions,
@@ -1272,6 +1294,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1272
1294
  tools: promptTools,
1273
1295
  toolNames,
1274
1296
  rules: rulebookRules,
1297
+ alwaysApplyRules,
1275
1298
  skillsSettings: settings.getGroup("skills"),
1276
1299
  customPrompt: options.systemPrompt,
1277
1300
  appendSystemPrompt: appendPrompt,
@@ -1531,6 +1554,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1531
1554
  obfuscator,
1532
1555
  asyncJobManager,
1533
1556
  pendingActionStore,
1557
+ searchDb,
1534
1558
  });
1535
1559
 
1536
1560
  if (model?.api === "openai-codex-responses") {
@@ -50,10 +50,11 @@ import {
50
50
  modelsAreEqual,
51
51
  parseRateLimitReason,
52
52
  } from "@oh-my-pi/pi-ai";
53
+ import type { SearchDb } from "@oh-my-pi/pi-natives";
53
54
  import { abortableSleep, getAgentDbPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
54
55
  import type { AsyncJob, AsyncJobManager } from "../async";
55
56
  import type { Rule } from "../capability/rule";
56
- import { MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "../config/model-registry";
57
+ import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
57
58
  import { extractExplicitThinkingSelector, parseModelString, resolveModelRoleValue } from "../config/model-resolver";
58
59
  import { expandPromptTemplate, type PromptTemplate, renderPromptTemplate } from "../config/prompt-templates";
59
60
  import type { Settings, SkillsSettings } from "../config/settings";
@@ -237,6 +238,8 @@ export interface AgentSessionConfig {
237
238
  obfuscator?: SecretObfuscator;
238
239
  /** Pending action store for preview/apply workflows */
239
240
  pendingActionStore?: PendingActionStore;
241
+ /** Shared native search DB for grep/glob/fuzzyFind-backed workflows. */
242
+ searchDb?: SearchDb;
240
243
  }
241
244
 
242
245
  /** Options for AgentSession.prompt() */
@@ -269,7 +272,7 @@ export interface ModelCycleResult {
269
272
  export interface RoleModelCycleResult {
270
273
  model: Model;
271
274
  thinkingLevel: ThinkingLevel | undefined;
272
- role: ModelRole;
275
+ role: string;
273
276
  }
274
277
 
275
278
  /** Session statistics for /session command */
@@ -348,6 +351,7 @@ export class AgentSession {
348
351
  readonly agent: Agent;
349
352
  readonly sessionManager: SessionManager;
350
353
  readonly settings: Settings;
354
+ readonly searchDb: SearchDb | undefined;
351
355
 
352
356
  #asyncJobManager: AsyncJobManager | undefined = undefined;
353
357
  #scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
@@ -462,6 +466,7 @@ export class AgentSession {
462
466
  this.agent = config.agent;
463
467
  this.sessionManager = config.sessionManager;
464
468
  this.settings = config.settings;
469
+ this.searchDb = config.searchDb;
465
470
  this.#asyncJobManager = config.asyncJobManager;
466
471
  this.#scopedModels = config.scopedModels ?? [];
467
472
  this.#thinkingLevel = config.thinkingLevel;
@@ -1888,6 +1893,7 @@ export class AgentSession {
1888
1893
  sessionManager: this.sessionManager,
1889
1894
  modelRegistry: this.#modelRegistry,
1890
1895
  model: this.model,
1896
+ searchDb: this.searchDb,
1891
1897
  isIdle: () => !this.isStreaming,
1892
1898
  hasQueuedMessages: () => this.queuedMessageCount > 0,
1893
1899
  abort: () => {
@@ -2039,7 +2045,7 @@ export class AgentSession {
2039
2045
  );
2040
2046
  }
2041
2047
 
2042
- resolveRoleModel(role: ModelRole): Model | undefined {
2048
+ resolveRoleModel(role: string): Model | undefined {
2043
2049
  return this.#resolveRoleModel(role, this.#modelRegistry.getAvailable(), this.model);
2044
2050
  }
2045
2051
 
@@ -3096,7 +3102,7 @@ export class AgentSession {
3096
3102
  * Validates API key, saves to session and settings.
3097
3103
  * @throws Error if no API key available for the model
3098
3104
  */
3099
- async setModel(model: Model, role: ModelRole = "default"): Promise<void> {
3105
+ async setModel(model: Model, role: string = "default"): Promise<void> {
3100
3106
  const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
3101
3107
  if (!apiKey) {
3102
3108
  throw new Error(`No API key for ${model.provider}/${model.id}`);
@@ -3150,7 +3156,7 @@ export class AgentSession {
3150
3156
  * @param options - Optional settings: `temporary` to not persist to settings
3151
3157
  */
3152
3158
  async cycleRoleModels(
3153
- roleOrder: readonly ModelRole[],
3159
+ roleOrder: readonly string[],
3154
3160
  options?: { temporary?: boolean },
3155
3161
  ): Promise<RoleModelCycleResult | undefined> {
3156
3162
  const availableModels = this.#modelRegistry.getAvailable();
@@ -3160,7 +3166,7 @@ export class AgentSession {
3160
3166
  if (!currentModel) return undefined;
3161
3167
  const matchPreferences = { usageOrder: this.settings.getStorage()?.getModelUsageOrder() };
3162
3168
  const roleModels: Array<{
3163
- role: ModelRole;
3169
+ role: string;
3164
3170
  model: Model;
3165
3171
  thinkingLevel?: ThinkingLevel;
3166
3172
  explicitThinkingLevel: boolean;
@@ -3190,9 +3196,10 @@ export class AgentSession {
3190
3196
  if (roleModels.length <= 1) return undefined;
3191
3197
 
3192
3198
  const lastRole = this.sessionManager.getLastModelChangeRole();
3193
- let currentIndex = lastRole
3194
- ? roleModels.findIndex(entry => entry.role === lastRole)
3195
- : roleModels.findIndex(entry => modelsAreEqual(entry.model, currentModel));
3199
+ let currentIndex = lastRole ? roleModels.findIndex(entry => entry.role === lastRole) : -1;
3200
+ if (currentIndex === -1) {
3201
+ currentIndex = roleModels.findIndex(entry => modelsAreEqual(entry.model, currentModel));
3202
+ }
3196
3203
  if (currentIndex === -1) currentIndex = 0;
3197
3204
 
3198
3205
  const nextIndex = (currentIndex + 1) % roleModels.length;
@@ -4273,7 +4280,7 @@ export class AgentSession {
4273
4280
  return `${model.provider}/${model.id}`;
4274
4281
  }
4275
4282
 
4276
- #formatRoleModelValue(role: ModelRole, model: Model): string {
4283
+ #formatRoleModelValue(role: string, model: Model): string {
4277
4284
  const modelKey = `${model.provider}/${model.id}`;
4278
4285
  const existingRoleValue = this.settings.getModelRole(role);
4279
4286
  if (!existingRoleValue) return modelKey;
@@ -4295,7 +4302,7 @@ export class AgentSession {
4295
4302
  return availableModels.find(m => m.provider === currentModel.provider && m.id === configuredTarget);
4296
4303
  }
4297
4304
 
4298
- #resolveRoleModel(role: ModelRole, availableModels: Model[], currentModel: Model | undefined): Model | undefined {
4305
+ #resolveRoleModel(role: string, availableModels: Model[], currentModel: Model | undefined): Model | undefined {
4299
4306
  const roleModelStr =
4300
4307
  role === "default"
4301
4308
  ? (this.settings.getModelRole("default") ??
@@ -16,6 +16,57 @@ import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile
16
16
  import { loadSkills, type Skill } from "./extensibility/skills";
17
17
  import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
18
18
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
19
+ import { formatPromptContent } from "./utils/prompt-format";
20
+
21
+ interface AlwaysApplyRule {
22
+ name: string;
23
+ content: string;
24
+ path: string;
25
+ }
26
+
27
+ function normalizePromptBlock(content: string): string {
28
+ return formatPromptContent(content, { renderPhase: "post-render" }).trim();
29
+ }
30
+
31
+ function splitComparablePromptBlocks(content: string | null | undefined): string[] {
32
+ const normalized = firstNonEmpty(content);
33
+ if (!normalized) return [];
34
+
35
+ return normalizePromptBlock(normalized)
36
+ .split(/\n{2,}/)
37
+ .map(block => block.trim())
38
+ .filter(block => block.length > 0);
39
+ }
40
+
41
+ function promptSourceContainsRule(source: string | null | undefined, ruleContent: string): boolean {
42
+ const sourceBlocks = splitComparablePromptBlocks(source);
43
+ const ruleBlocks = splitComparablePromptBlocks(ruleContent);
44
+ if (sourceBlocks.length === 0 || ruleBlocks.length === 0 || ruleBlocks.length > sourceBlocks.length) return false;
45
+
46
+ for (let start = 0; start <= sourceBlocks.length - ruleBlocks.length; start += 1) {
47
+ if (ruleBlocks.every((block, offset) => sourceBlocks[start + offset] === block)) return true;
48
+ }
49
+
50
+ return false;
51
+ }
52
+
53
+ function dedupeAlwaysApplyRules(
54
+ alwaysApplyRules: AlwaysApplyRule[] | undefined,
55
+ promptSources: Array<string | null | undefined>,
56
+ ): AlwaysApplyRule[] {
57
+ if (!alwaysApplyRules || alwaysApplyRules.length === 0) return [];
58
+
59
+ return alwaysApplyRules.filter(
60
+ rule => !promptSources.some(source => promptSourceContainsRule(source, rule.content)),
61
+ );
62
+ }
63
+
64
+ function dedupePromptSource(source: string | null | undefined, otherSources: Array<string | null | undefined>): string {
65
+ const resolvedSource = firstNonEmpty(source);
66
+ if (!resolvedSource) return "";
67
+
68
+ return otherSources.some(otherSource => promptSourceContainsRule(otherSource, resolvedSource)) ? "" : resolvedSource;
69
+ }
19
70
 
20
71
  function firstNonEmpty(...values: (string | undefined | null)[]): string | null {
21
72
  for (const value of values) {
@@ -379,6 +430,8 @@ export interface BuildSystemPromptOptions {
379
430
  mcpDiscoveryServerSummaries?: string[];
380
431
  /** Encourage the agent to delegate via tasks unless changes are trivial. */
381
432
  eagerTasks?: boolean;
433
+ /** Rules with alwaysApply=true — their full content is injected into the prompt. */
434
+ alwaysApplyRules?: AlwaysApplyRule[];
382
435
  }
383
436
 
384
437
  /** Build the system prompt with tools, guidelines, and context */
@@ -398,6 +451,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
398
451
  contextFiles: providedContextFiles,
399
452
  skills: providedSkills,
400
453
  rules,
454
+ alwaysApplyRules,
401
455
  intentField,
402
456
  mcpDiscoveryMode = false,
403
457
  mcpDiscoveryServerSummaries = [],
@@ -519,10 +573,16 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
519
573
  const hasRead = tools?.has("read");
520
574
  const filteredSkills = hasRead ? skills : [];
521
575
 
576
+ const effectiveSystemPromptCustomization = dedupePromptSource(systemPromptCustomization, [
577
+ resolvedCustomPrompt,
578
+ resolvedAppendPrompt,
579
+ ]);
580
+ const promptSources = [effectiveSystemPromptCustomization, resolvedCustomPrompt, resolvedAppendPrompt];
581
+ const injectedAlwaysApplyRules = dedupeAlwaysApplyRules(alwaysApplyRules, promptSources);
582
+
522
583
  const environment = await logger.timeAsync("getEnvironmentInfo", getEnvironmentInfo);
523
584
  const data = {
524
- // Explicit custom prompts replace discovered SYSTEM.md content rather than layering it twice.
525
- systemPromptCustomization: resolvedCustomPrompt ? "" : (systemPromptCustomization ?? ""),
585
+ systemPromptCustomization: effectiveSystemPromptCustomization,
526
586
  customPrompt: resolvedCustomPrompt,
527
587
  appendPrompt: resolvedAppendPrompt ?? "",
528
588
  tools: toolNames,
@@ -533,6 +593,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
533
593
  agentsMdSearch,
534
594
  skills: filteredSkills,
535
595
  rules: rules ?? [],
596
+ alwaysApplyRules: injectedAlwaysApplyRules,
536
597
  date,
537
598
  dateTime,
538
599
  cwd: promptCwd,