@oh-my-pi/pi-coding-agent 4.0.1 → 4.2.0

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 (85) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +2 -1
  3. package/docs/sdk.md +0 -3
  4. package/package.json +6 -5
  5. package/src/config.ts +9 -0
  6. package/src/core/agent-storage.ts +450 -0
  7. package/src/core/auth-storage.ts +111 -184
  8. package/src/core/compaction/branch-summarization.ts +5 -4
  9. package/src/core/compaction/compaction.ts +7 -6
  10. package/src/core/compaction/utils.ts +6 -11
  11. package/src/core/custom-commands/bundled/review/index.ts +22 -94
  12. package/src/core/custom-share.ts +66 -0
  13. package/src/core/history-storage.ts +174 -0
  14. package/src/core/index.ts +1 -0
  15. package/src/core/keybindings.ts +3 -0
  16. package/src/core/prompt-templates.ts +271 -1
  17. package/src/core/sdk.ts +14 -3
  18. package/src/core/settings-manager.ts +100 -34
  19. package/src/core/slash-commands.ts +4 -1
  20. package/src/core/storage-migration.ts +215 -0
  21. package/src/core/system-prompt.ts +87 -289
  22. package/src/core/title-generator.ts +3 -2
  23. package/src/core/tools/ask.ts +2 -2
  24. package/src/core/tools/bash.ts +2 -1
  25. package/src/core/tools/calculator.ts +2 -1
  26. package/src/core/tools/edit.ts +2 -1
  27. package/src/core/tools/find.ts +2 -1
  28. package/src/core/tools/gemini-image.ts +2 -1
  29. package/src/core/tools/git.ts +2 -2
  30. package/src/core/tools/grep.ts +2 -1
  31. package/src/core/tools/index.test.ts +0 -28
  32. package/src/core/tools/index.ts +0 -6
  33. package/src/core/tools/lsp/index.ts +2 -1
  34. package/src/core/tools/output.ts +2 -1
  35. package/src/core/tools/read.ts +4 -1
  36. package/src/core/tools/ssh.ts +4 -2
  37. package/src/core/tools/task/agents.ts +56 -30
  38. package/src/core/tools/task/commands.ts +9 -8
  39. package/src/core/tools/task/index.ts +7 -15
  40. package/src/core/tools/web-fetch.ts +2 -1
  41. package/src/core/tools/web-search/auth.ts +106 -16
  42. package/src/core/tools/web-search/index.ts +3 -2
  43. package/src/core/tools/web-search/providers/anthropic.ts +44 -6
  44. package/src/core/tools/write.ts +2 -1
  45. package/src/core/voice.ts +3 -1
  46. package/src/main.ts +1 -1
  47. package/src/migrations.ts +20 -20
  48. package/src/modes/interactive/components/custom-editor.ts +7 -0
  49. package/src/modes/interactive/components/history-search.ts +158 -0
  50. package/src/modes/interactive/controllers/command-controller.ts +527 -0
  51. package/src/modes/interactive/controllers/event-controller.ts +340 -0
  52. package/src/modes/interactive/controllers/extension-ui-controller.ts +600 -0
  53. package/src/modes/interactive/controllers/input-controller.ts +585 -0
  54. package/src/modes/interactive/controllers/selector-controller.ts +585 -0
  55. package/src/modes/interactive/interactive-mode.ts +370 -3115
  56. package/src/modes/interactive/theme/theme.ts +5 -5
  57. package/src/modes/interactive/types.ts +189 -0
  58. package/src/modes/interactive/utils/ui-helpers.ts +449 -0
  59. package/src/modes/interactive/utils/voice-manager.ts +96 -0
  60. package/src/prompts/{explore.md → agents/explore.md} +7 -5
  61. package/src/prompts/agents/frontmatter.md +7 -0
  62. package/src/prompts/{plan.md → agents/plan.md} +3 -3
  63. package/src/prompts/{task.md → agents/task.md} +1 -1
  64. package/src/prompts/review-request.md +44 -8
  65. package/src/prompts/system/custom-system-prompt.md +80 -0
  66. package/src/prompts/system/file-operations.md +12 -0
  67. package/src/prompts/system/system-prompt.md +232 -0
  68. package/src/prompts/system/title-system.md +2 -0
  69. package/src/prompts/tools/bash.md +1 -1
  70. package/src/prompts/tools/read.md +1 -1
  71. package/src/prompts/tools/task.md +9 -3
  72. package/src/core/tools/rulebook.ts +0 -132
  73. package/src/prompts/system-prompt.md +0 -43
  74. package/src/prompts/title-system.md +0 -8
  75. /package/src/prompts/{architect-plan.md → agents/architect-plan.md} +0 -0
  76. /package/src/prompts/{implement-with-critic.md → agents/implement-with-critic.md} +0 -0
  77. /package/src/prompts/{implement.md → agents/implement.md} +0 -0
  78. /package/src/prompts/{init.md → agents/init.md} +0 -0
  79. /package/src/prompts/{reviewer.md → agents/reviewer.md} +0 -0
  80. /package/src/prompts/{branch-summary-preamble.md → compaction/branch-summary-preamble.md} +0 -0
  81. /package/src/prompts/{branch-summary.md → compaction/branch-summary.md} +0 -0
  82. /package/src/prompts/{compaction-summary.md → compaction/compaction-summary.md} +0 -0
  83. /package/src/prompts/{compaction-turn-prefix.md → compaction/compaction-turn-prefix.md} +0 -0
  84. /package/src/prompts/{compaction-update-summary.md → compaction/compaction-update-summary.md} +0 -0
  85. /package/src/prompts/{summarization-system.md → system/summarization-system.md} +0 -0
@@ -1,4 +1,5 @@
1
1
  import { join, resolve } from "node:path";
2
+ import Handlebars from "handlebars";
2
3
  import { CONFIG_DIR_NAME, getPromptsDir } from "../config";
3
4
 
4
5
  /**
@@ -11,6 +12,273 @@ export interface PromptTemplate {
11
12
  source: string; // e.g., "(user)", "(project)", "(project:frontend)"
12
13
  }
13
14
 
15
+ export interface TemplateContext extends Record<string, unknown> {
16
+ args?: string[];
17
+ ARGUMENTS?: string;
18
+ arguments?: string;
19
+ }
20
+
21
+ const handlebars = Handlebars.create();
22
+
23
+ handlebars.registerHelper("arg", function (this: TemplateContext, index: number | string): string {
24
+ const args = this.args ?? [];
25
+ const parsedIndex = typeof index === "number" ? index : Number.parseInt(index, 10);
26
+ if (!Number.isFinite(parsedIndex)) return "";
27
+ const zeroBased = parsedIndex - 1;
28
+ if (zeroBased < 0) return "";
29
+ return args[zeroBased] ?? "";
30
+ });
31
+
32
+ /**
33
+ * {{#list items prefix="- " suffix="" join="\n"}}{{this}}{{/list}}
34
+ * Renders an array with customizable prefix, suffix, and join separator.
35
+ * Note: Use \n in join for newlines (will be unescaped automatically).
36
+ */
37
+ handlebars.registerHelper(
38
+ "list",
39
+ function (this: unknown, context: unknown[], options: Handlebars.HelperOptions): string {
40
+ if (!Array.isArray(context) || context.length === 0) return "";
41
+ const prefix = (options.hash.prefix as string) ?? "";
42
+ const suffix = (options.hash.suffix as string) ?? "";
43
+ const rawSeparator = (options.hash.join as string) ?? "\n";
44
+ const separator = rawSeparator.replace(/\\n/g, "\n").replace(/\\t/g, "\t");
45
+ return context.map((item) => `${prefix}${options.fn(item)}${suffix}`).join(separator);
46
+ },
47
+ );
48
+
49
+ /**
50
+ * {{join array ", "}}
51
+ * Joins an array with a separator (default: ", ").
52
+ */
53
+ handlebars.registerHelper("join", (context: unknown[], separator?: unknown): string => {
54
+ if (!Array.isArray(context)) return "";
55
+ const sep = typeof separator === "string" ? separator : ", ";
56
+ return context.join(sep);
57
+ });
58
+
59
+ /**
60
+ * {{default value "fallback"}}
61
+ * Returns the value if truthy, otherwise returns the fallback.
62
+ */
63
+ handlebars.registerHelper("default", (value: unknown, defaultValue: unknown): unknown => value || defaultValue);
64
+
65
+ /**
66
+ * {{pluralize count "item" "items"}}
67
+ * Returns "1 item" or "5 items" based on count.
68
+ */
69
+ handlebars.registerHelper(
70
+ "pluralize",
71
+ (count: number, singular: string, plural: string): string => `${count} ${count === 1 ? singular : plural}`,
72
+ );
73
+
74
+ /**
75
+ * {{#when value "==" compare}}...{{else}}...{{/when}}
76
+ * Conditional block with comparison operators: ==, ===, !=, !==, >, <, >=, <=
77
+ */
78
+ handlebars.registerHelper(
79
+ "when",
80
+ function (this: unknown, lhs: unknown, operator: string, rhs: unknown, options: Handlebars.HelperOptions): string {
81
+ const ops: Record<string, (a: unknown, b: unknown) => boolean> = {
82
+ "==": (a, b) => a === b,
83
+ "===": (a, b) => a === b,
84
+ "!=": (a, b) => a !== b,
85
+ "!==": (a, b) => a !== b,
86
+ ">": (a, b) => (a as number) > (b as number),
87
+ "<": (a, b) => (a as number) < (b as number),
88
+ ">=": (a, b) => (a as number) >= (b as number),
89
+ "<=": (a, b) => (a as number) <= (b as number),
90
+ };
91
+ const fn = ops[operator];
92
+ if (!fn) return options.inverse(this);
93
+ return fn(lhs, rhs) ? options.fn(this) : options.inverse(this);
94
+ },
95
+ );
96
+
97
+ /**
98
+ * {{#ifAny a b c}}...{{else}}...{{/ifAny}}
99
+ * True if any argument is truthy.
100
+ */
101
+ handlebars.registerHelper("ifAny", function (this: unknown, ...args: unknown[]): string {
102
+ const options = args.pop() as Handlebars.HelperOptions;
103
+ return args.some(Boolean) ? options.fn(this) : options.inverse(this);
104
+ });
105
+
106
+ /**
107
+ * {{#ifAll a b c}}...{{else}}...{{/ifAll}}
108
+ * True if all arguments are truthy.
109
+ */
110
+ handlebars.registerHelper("ifAll", function (this: unknown, ...args: unknown[]): string {
111
+ const options = args.pop() as Handlebars.HelperOptions;
112
+ return args.every(Boolean) ? options.fn(this) : options.inverse(this);
113
+ });
114
+
115
+ /**
116
+ * {{#table rows headers="Col1|Col2"}}{{col1}}|{{col2}}{{/table}}
117
+ * Generates a markdown table from an array of objects.
118
+ */
119
+ handlebars.registerHelper(
120
+ "table",
121
+ function (this: unknown, context: unknown[], options: Handlebars.HelperOptions): string {
122
+ if (!Array.isArray(context) || context.length === 0) return "";
123
+ const headersStr = options.hash.headers as string | undefined;
124
+ const headers = headersStr?.split("|") ?? [];
125
+ const separator = headers.map(() => "---").join(" | ");
126
+ const headerRow = headers.length > 0 ? `| ${headers.join(" | ")} |\n| ${separator} |\n` : "";
127
+ const rows = context.map((item) => `| ${options.fn(item).trim()} |`).join("\n");
128
+ return headerRow + rows;
129
+ },
130
+ );
131
+
132
+ /**
133
+ * {{#codeblock lang="diff"}}...{{/codeblock}}
134
+ * Wraps content in a fenced code block.
135
+ */
136
+ handlebars.registerHelper("codeblock", function (this: unknown, options: Handlebars.HelperOptions): string {
137
+ const lang = (options.hash.lang as string) ?? "";
138
+ const content = options.fn(this).trim();
139
+ return `\`\`\`${lang}\n${content}\n\`\`\``;
140
+ });
141
+
142
+ /**
143
+ * {{#xml "tag"}}content{{/xml}}
144
+ * Wraps content in XML-style tags. Returns empty string if content is empty.
145
+ */
146
+ handlebars.registerHelper("xml", function (this: unknown, tag: string, options: Handlebars.HelperOptions): string {
147
+ const content = options.fn(this).trim();
148
+ if (!content) return "";
149
+ return `<${tag}>\n${content}\n</${tag}>`;
150
+ });
151
+
152
+ /**
153
+ * {{escapeXml value}}
154
+ * Escapes XML special characters: & < > "
155
+ */
156
+ handlebars.registerHelper("escapeXml", (value: unknown): string => {
157
+ if (value == null) return "";
158
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
159
+ });
160
+
161
+ /**
162
+ * {{len array}}
163
+ * Returns the length of an array or string.
164
+ */
165
+ handlebars.registerHelper("len", (value: unknown): number => {
166
+ if (Array.isArray(value)) return value.length;
167
+ if (typeof value === "string") return value.length;
168
+ return 0;
169
+ });
170
+
171
+ /**
172
+ * {{add a b}}
173
+ * Adds two numbers.
174
+ */
175
+ handlebars.registerHelper("add", (a: number, b: number): number => (a ?? 0) + (b ?? 0));
176
+
177
+ /**
178
+ * {{sub a b}}
179
+ * Subtracts b from a.
180
+ */
181
+ handlebars.registerHelper("sub", (a: number, b: number): number => (a ?? 0) - (b ?? 0));
182
+
183
+ /**
184
+ * {{#has collection item}}...{{else}}...{{/has}}
185
+ * Checks if an array includes an item or if a Set/Map has a key.
186
+ */
187
+ handlebars.registerHelper(
188
+ "has",
189
+ function (this: unknown, collection: unknown, item: unknown, options: Handlebars.HelperOptions): string {
190
+ let found = false;
191
+ if (Array.isArray(collection)) {
192
+ found = collection.includes(item);
193
+ } else if (collection instanceof Set) {
194
+ found = collection.has(item);
195
+ } else if (collection instanceof Map) {
196
+ found = collection.has(item);
197
+ } else if (collection && typeof collection === "object") {
198
+ if (typeof item === "string" || typeof item === "number" || typeof item === "symbol") {
199
+ found = item in collection;
200
+ }
201
+ }
202
+ return found ? options.fn(this) : options.inverse(this);
203
+ },
204
+ );
205
+
206
+ /**
207
+ * {{includes array item}}
208
+ * Returns true if array includes item. For use in other helpers.
209
+ */
210
+ handlebars.registerHelper("includes", (collection: unknown, item: unknown): boolean => {
211
+ if (Array.isArray(collection)) return collection.includes(item);
212
+ if (collection instanceof Set) return collection.has(item);
213
+ if (collection instanceof Map) return collection.has(item);
214
+ return false;
215
+ });
216
+
217
+ /**
218
+ * {{not value}}
219
+ * Returns logical NOT of value. For use in subexpressions.
220
+ */
221
+ handlebars.registerHelper("not", (value: unknown): boolean => !value);
222
+
223
+ export function renderPromptTemplate(template: string, context: TemplateContext = {}): string {
224
+ const compiled = handlebars.compile(template, { noEscape: true, strict: false });
225
+ const rendered = compiled(context ?? {});
226
+ return optimizePromptLayout(rendered);
227
+ }
228
+
229
+ function optimizePromptLayout(input: string): string {
230
+ // 1) strip CR / normalize line endings
231
+ let s = input.replace(/\r\n?/g, "\n");
232
+
233
+ // normalize NBSP -> space
234
+ s = s.replace(/\u00A0/g, " ");
235
+
236
+ const lines = s.split("\n").map((line) => {
237
+ // 2) remove trailing whitespace (spaces/tabs) per line
238
+ let l = line.replace(/[ \t]+$/g, "");
239
+
240
+ // 3) lines with only whitespace -> empty line
241
+ if (/^[ \t]*$/.test(l)) return "";
242
+
243
+ // 4) normalize leading indentation: every 2 spaces -> \t (preserve leftover 1 space)
244
+ // NOTE: This is intentionally *only* leading indentation to avoid mangling prose.
245
+ const m = l.match(/^[ \t]+/);
246
+ if (m) {
247
+ const indent = m[0];
248
+ const rest = l.slice(indent.length);
249
+
250
+ let out = "";
251
+ let spaces = 0;
252
+
253
+ for (const ch of indent) {
254
+ if (ch === "\t") {
255
+ // flush pending spaces before existing tab
256
+ out += "\t".repeat(Math.floor(spaces / 2));
257
+ if (spaces % 2) out += " ";
258
+ spaces = 0;
259
+ out += "\t";
260
+ } else {
261
+ spaces++;
262
+ }
263
+ }
264
+
265
+ out += "\t".repeat(Math.floor(spaces / 2));
266
+ if (spaces % 2) out += " ";
267
+
268
+ l = out + rest;
269
+ }
270
+
271
+ return l;
272
+ });
273
+
274
+ s = lines.join("\n");
275
+
276
+ // 5) collapse excessive blank lines
277
+ s = s.replace(/\n{3,}/g, "\n\n");
278
+
279
+ return s.trim();
280
+ }
281
+
14
282
  /**
15
283
  * Parse YAML frontmatter from markdown content
16
284
  * Returns { frontmatter, content } where content has frontmatter stripped
@@ -235,7 +503,9 @@ export function expandPromptTemplate(text: string, templates: PromptTemplate[]):
235
503
  const template = templates.find((t) => t.name === templateName);
236
504
  if (template) {
237
505
  const args = parseCommandArgs(argsString);
238
- return substituteArgs(template.content, args);
506
+ const argsText = args.join(" ");
507
+ const substituted = substituteArgs(template.content, args);
508
+ return renderPromptTemplate(substituted, { args, ARGUMENTS: argsText, arguments: argsText });
239
509
  }
240
510
 
241
511
  return text;
package/src/core/sdk.ts CHANGED
@@ -71,6 +71,7 @@ import { loadSkills as loadSkillsInternal, type Skill, type SkillWarning } from
71
71
  import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./slash-commands";
72
72
  import { closeAllConnections } from "./ssh/connection-manager";
73
73
  import { unmountAll } from "./ssh/sshfs-mount";
74
+ import { migrateJsonStorage } from "./storage-migration";
74
75
  import {
75
76
  buildSystemPrompt as buildSystemPromptInternal,
76
77
  loadProjectContextFiles as loadContextFilesInternal,
@@ -90,7 +91,6 @@ import {
90
91
  createSshTool,
91
92
  createTools,
92
93
  createWriteTool,
93
- filterRulebookRules,
94
94
  getWebSearchTools,
95
95
  setPreferredImageProvider,
96
96
  setPreferredWebSearchProvider,
@@ -242,6 +242,13 @@ export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir(
242
242
 
243
243
  logger.debug("discoverAuthStorage", { agentDir, primaryPath, allPaths, fallbackPaths });
244
244
 
245
+ // Migrate legacy JSON files (settings.json, auth.json) to SQLite before loading
246
+ await migrateJsonStorage({
247
+ agentDir,
248
+ settingsPath: join(agentDir, "settings.json"),
249
+ authPaths: [primaryPath, ...fallbackPaths],
250
+ });
251
+
245
252
  const storage = new AuthStorage(primaryPath, fallbackPaths);
246
253
  await storage.reload();
247
254
  return storage;
@@ -646,7 +653,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
646
653
  time("discoverTtsrRules");
647
654
 
648
655
  // Filter rules for the rulebook (non-TTSR, non-alwaysApply, with descriptions)
649
- const rulebookRules = filterRulebookRules(rulesResult.items);
656
+ const rulebookRules = rulesResult.items.filter((rule) => {
657
+ if (rule.ttsrTrigger) return false;
658
+ if (rule.alwaysApply) return false;
659
+ if (!rule.description) return false;
660
+ return true;
661
+ });
650
662
  time("filterRulebookRules");
651
663
 
652
664
  const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
@@ -658,7 +670,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
658
670
  const toolSession: ToolSession = {
659
671
  cwd,
660
672
  hasUI: options.hasUI ?? false,
661
- rulebookRules,
662
673
  eventBus,
663
674
  outputSchema: options.outputSchema,
664
675
  requireCompleteTool: options.requireCompleteTool,
@@ -1,9 +1,11 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { existsSync, readFileSync, renameSync } from "node:fs";
2
+ import { join } from "node:path";
3
3
  import { type Settings as SettingsItem, settingsCapability } from "../capability/settings";
4
- import { getAgentDir } from "../config";
4
+ import { getAgentDbPath, getAgentDir } from "../config";
5
5
  import { loadSync } from "../discovery";
6
6
  import type { SymbolPreset } from "../modes/interactive/theme/theme";
7
+ import { AgentStorage } from "./agent-storage";
8
+ import { logger } from "./logger";
7
9
 
8
10
  export interface CompactionSettings {
9
11
  enabled?: boolean; // default: true
@@ -337,10 +339,36 @@ function normalizeBashInterceptorSettings(
337
339
  return { enabled, simpleLs, patterns };
338
340
  }
339
341
 
342
+ let cachedNerdFonts: boolean | null = null;
343
+
344
+ function hasNerdFonts(): boolean {
345
+ if (cachedNerdFonts !== null) {
346
+ return cachedNerdFonts;
347
+ }
348
+
349
+ const envOverride = process.env.NERD_FONTS;
350
+ if (envOverride === "1") {
351
+ cachedNerdFonts = true;
352
+ return true;
353
+ }
354
+ if (envOverride === "0") {
355
+ cachedNerdFonts = false;
356
+ return false;
357
+ }
358
+
359
+ const termProgram = (process.env.TERM_PROGRAM || "").toLowerCase();
360
+ const term = (process.env.TERM || "").toLowerCase();
361
+ const nerdTerms = ["iterm", "wezterm", "kitty", "ghostty", "alacritty"];
362
+ cachedNerdFonts = nerdTerms.some((candidate) => termProgram.includes(candidate) || term.includes(candidate));
363
+ return cachedNerdFonts;
364
+ }
365
+
340
366
  function normalizeSettings(settings: Settings): Settings {
341
367
  const merged = deepMergeSettings(DEFAULT_SETTINGS, settings);
368
+ const symbolPreset = merged.symbolPreset ?? (hasNerdFonts() ? "nerd" : "unicode");
342
369
  return {
343
370
  ...merged,
371
+ symbolPreset,
344
372
  bashInterceptor: normalizeBashInterceptorSettings(merged.bashInterceptor),
345
373
  };
346
374
  }
@@ -377,15 +405,23 @@ function deepMergeSettings(base: Settings, overrides: Settings): Settings {
377
405
  }
378
406
 
379
407
  export class SettingsManager {
380
- private settingsPath: string | null;
408
+ /** SQLite storage for persisted settings (null for in-memory mode) */
409
+ private storage: AgentStorage | null;
381
410
  private cwd: string | null;
382
411
  private globalSettings: Settings;
383
412
  private overrides: Settings;
384
413
  private settings!: Settings;
385
414
  private persist: boolean;
386
415
 
387
- private constructor(settingsPath: string | null, cwd: string | null, initialSettings: Settings, persist: boolean) {
388
- this.settingsPath = settingsPath;
416
+ /**
417
+ * Private constructor - use static factory methods instead.
418
+ * @param storage - SQLite storage instance for persistence, or null for in-memory mode
419
+ * @param cwd - Current working directory for project settings discovery
420
+ * @param initialSettings - Initial global settings to use
421
+ * @param persist - Whether to persist settings changes to storage
422
+ */
423
+ private constructor(storage: AgentStorage | null, cwd: string | null, initialSettings: Settings, persist: boolean) {
424
+ this.storage = storage;
389
425
  this.cwd = cwd;
390
426
  this.persist = persist;
391
427
  this.globalSettings = initialSettings;
@@ -416,9 +452,15 @@ export class SettingsManager {
416
452
  }
417
453
  }
418
454
 
419
- /** Create a SettingsManager that loads from files */
455
+ /**
456
+ * Create a SettingsManager that loads from persistent SQLite storage.
457
+ * @param cwd - Current working directory for project settings discovery
458
+ * @param agentDir - Agent directory containing agent.db
459
+ * @returns Configured SettingsManager with merged global and user settings
460
+ */
420
461
  static create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): SettingsManager {
421
- const settingsPath = join(agentDir, "settings.json");
462
+ const storage = AgentStorage.open(getAgentDbPath(agentDir));
463
+ SettingsManager.migrateLegacySettingsFile(storage, agentDir);
422
464
 
423
465
  // Use capability API to load user-level settings from all providers
424
466
  const result = loadSync(settingsCapability.id, { cwd });
@@ -431,29 +473,58 @@ export class SettingsManager {
431
473
  }
432
474
  }
433
475
 
434
- // Also load from agentDir for backward compatibility (if not covered by providers)
435
- const legacySettings = SettingsManager.loadFromFile(settingsPath);
436
- globalSettings = deepMergeSettings(globalSettings, legacySettings);
476
+ // Load persisted settings from agent.db (legacy settings.json is migrated separately)
477
+ const storedSettings = SettingsManager.loadFromStorage(storage);
478
+ globalSettings = deepMergeSettings(globalSettings, storedSettings);
437
479
 
438
- return new SettingsManager(settingsPath, cwd, globalSettings, true);
480
+ return new SettingsManager(storage, cwd, globalSettings, true);
439
481
  }
440
482
 
441
- /** Create an in-memory SettingsManager (no file I/O) */
483
+ /**
484
+ * Create an in-memory SettingsManager without persistence.
485
+ * @param settings - Initial settings to use
486
+ * @returns SettingsManager that won't persist changes to disk
487
+ */
442
488
  static inMemory(settings: Partial<Settings> = {}): SettingsManager {
443
489
  return new SettingsManager(null, null, settings, false);
444
490
  }
445
491
 
446
- private static loadFromFile(path: string): Settings {
447
- if (!existsSync(path)) {
492
+ /**
493
+ * Load settings from SQLite storage, applying any schema migrations.
494
+ * @param storage - AgentStorage instance, or null for in-memory mode
495
+ * @returns Parsed and migrated settings, or empty object if storage is null/empty
496
+ */
497
+ private static loadFromStorage(storage: AgentStorage | null): Settings {
498
+ if (!storage) {
448
499
  return {};
449
500
  }
501
+ const settings = storage.getSettings();
502
+ if (!settings) {
503
+ return {};
504
+ }
505
+ return SettingsManager.migrateSettings(settings as Record<string, unknown>);
506
+ }
507
+
508
+ private static migrateLegacySettingsFile(storage: AgentStorage, agentDir: string): void {
509
+ const settingsPath = join(agentDir, "settings.json");
510
+ if (!existsSync(settingsPath)) return;
511
+ if (storage.getSettings() !== null) return;
512
+
450
513
  try {
451
- const content = readFileSync(path, "utf-8");
452
- const settings = JSON.parse(content);
453
- return SettingsManager.migrateSettings(settings as Record<string, unknown>);
514
+ const content = readFileSync(settingsPath, "utf-8");
515
+ const parsed = JSON.parse(content);
516
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
517
+ return;
518
+ }
519
+ const migrated = SettingsManager.migrateSettings(parsed as Record<string, unknown>);
520
+ storage.saveSettings(migrated);
521
+ try {
522
+ renameSync(settingsPath, `${settingsPath}.bak`);
523
+ } catch (error) {
524
+ logger.warn("SettingsManager failed to backup settings.json", { error: String(error) });
525
+ }
454
526
  } catch (error) {
455
- console.error(`Warning: Could not read settings file ${path}: ${error}`);
456
- return {};
527
+ logger.warn("SettingsManager failed to migrate settings.json", { error: String(error) });
457
528
  }
458
529
  }
459
530
 
@@ -497,24 +568,19 @@ export class SettingsManager {
497
568
  this.rebuildSettings();
498
569
  }
499
570
 
571
+ /**
572
+ * Persist current global settings to SQLite storage and rebuild merged settings.
573
+ * Merges with any concurrent changes in storage before saving.
574
+ */
500
575
  private save(): void {
501
- if (this.persist && this.settingsPath) {
576
+ if (this.persist && this.storage) {
502
577
  try {
503
- const dir = dirname(this.settingsPath);
504
- if (!existsSync(dir)) {
505
- mkdirSync(dir, { recursive: true });
506
- }
507
-
508
- // Re-read current file to preserve any settings added externally while running
509
- const currentFileSettings = SettingsManager.loadFromFile(this.settingsPath);
510
- // Merge: file settings as base, globalSettings (in-memory changes) as overrides
511
- const mergedSettings = deepMergeSettings(currentFileSettings, this.globalSettings);
578
+ const currentSettings = this.storage.getSettings() ?? {};
579
+ const mergedSettings = deepMergeSettings(currentSettings, this.globalSettings);
512
580
  this.globalSettings = mergedSettings;
513
-
514
- // Save merged settings (project settings are read-only)
515
- writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8");
581
+ this.storage.saveSettings(this.globalSettings);
516
582
  } catch (error) {
517
- console.error(`Warning: Could not save settings file: ${error}`);
583
+ logger.warn("SettingsManager save failed", { error: String(error) });
518
584
  }
519
585
  }
520
586
 
@@ -2,6 +2,7 @@ import { slashCommandCapability } from "../capability/slash-command";
2
2
  import type { SlashCommand } from "../discovery";
3
3
  import { loadSync } from "../discovery";
4
4
  import { parseFrontmatter } from "../discovery/helpers";
5
+ import { renderPromptTemplate } from "./prompt-templates";
5
6
  import { EMBEDDED_COMMAND_TEMPLATES } from "./tools/task/commands";
6
7
 
7
8
  /**
@@ -158,7 +159,9 @@ export function expandSlashCommand(text: string, fileCommands: FileSlashCommand[
158
159
  const fileCommand = fileCommands.find((cmd) => cmd.name === commandName);
159
160
  if (fileCommand) {
160
161
  const args = parseCommandArgs(argsString);
161
- return substituteArgs(fileCommand.content, args);
162
+ const argsText = args.join(" ");
163
+ const substituted = substituteArgs(fileCommand.content, args);
164
+ return renderPromptTemplate(substituted, { args, ARGUMENTS: argsText, arguments: argsText });
162
165
  }
163
166
 
164
167
  return text;