@oh-my-pi/pi-coding-agent 12.8.2 → 12.10.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 (43) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/package.json +7 -7
  3. package/src/capability/rule.ts +173 -5
  4. package/src/cli/args.ts +3 -0
  5. package/src/cli/update-cli.ts +1 -66
  6. package/src/commands/launch.ts +3 -0
  7. package/src/config/model-registry.ts +300 -21
  8. package/src/config/model-resolver.ts +4 -4
  9. package/src/config/settings-schema.ts +12 -0
  10. package/src/discovery/agents.ts +175 -12
  11. package/src/discovery/builtin.ts +3 -13
  12. package/src/discovery/cline.ts +4 -45
  13. package/src/discovery/cursor.ts +2 -29
  14. package/src/discovery/helpers.ts +43 -0
  15. package/src/discovery/index.ts +1 -0
  16. package/src/discovery/opencode.ts +394 -0
  17. package/src/discovery/windsurf.ts +5 -44
  18. package/src/export/ttsr.ts +324 -54
  19. package/src/extensibility/custom-tools/wrapper.ts +1 -11
  20. package/src/internal-urls/index.ts +4 -2
  21. package/src/internal-urls/memory-protocol.ts +133 -0
  22. package/src/internal-urls/router.ts +4 -2
  23. package/src/internal-urls/skill-protocol.ts +1 -1
  24. package/src/internal-urls/types.ts +6 -2
  25. package/src/main.ts +5 -0
  26. package/src/memories/index.ts +6 -13
  27. package/src/modes/components/settings-defs.ts +6 -0
  28. package/src/modes/components/status-line/segments.ts +3 -2
  29. package/src/modes/rpc/rpc-client.ts +16 -0
  30. package/src/prompts/memories/consolidation.md +1 -1
  31. package/src/prompts/memories/read_path.md +4 -4
  32. package/src/prompts/memories/stage_one_input.md +1 -2
  33. package/src/prompts/tools/bash.md +10 -23
  34. package/src/prompts/tools/read.md +2 -0
  35. package/src/sdk.ts +25 -10
  36. package/src/session/agent-session.ts +252 -44
  37. package/src/session/session-manager.ts +79 -36
  38. package/src/tools/bash-skill-urls.ts +177 -0
  39. package/src/tools/bash.ts +7 -1
  40. package/src/tools/fetch.ts +6 -2
  41. package/src/tools/index.ts +2 -2
  42. package/src/tools/output-meta.ts +49 -42
  43. package/src/tools/read.ts +2 -2
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Time Traveling Stream Rules (TTSR) Manager
3
3
  *
4
- * Manages rules that get injected mid-stream when their trigger pattern matches
4
+ * Manages rules that get injected mid-stream when their condition pattern matches
5
5
  * the agent's output. When a match occurs, the stream is aborted, the rule is
6
6
  * injected as a system reminder, and the request is retried.
7
7
  */
@@ -9,36 +9,72 @@ import { logger } from "@oh-my-pi/pi-utils";
9
9
  import type { Rule } from "../capability/rule";
10
10
  import type { TtsrSettings } from "../config/settings";
11
11
 
12
+ export type TtsrMatchSource = "text" | "thinking" | "tool";
13
+
14
+ /** Context about the stream content currently being checked against TTSR rules. */
15
+ export interface TtsrMatchContext {
16
+ source: TtsrMatchSource;
17
+ /** Tool name for tool argument deltas, e.g. "edit" or "write". */
18
+ toolName?: string;
19
+ /** Candidate file paths associated with the current stream chunk. */
20
+ filePaths?: string[];
21
+ /** Stable key to isolate buffering (for example a tool call ID). */
22
+ streamKey?: string;
23
+ }
24
+
25
+ interface ToolScope {
26
+ toolName?: string;
27
+ pathGlob?: Bun.Glob;
28
+ pathPattern?: string;
29
+ }
30
+
31
+ interface TtsrScope {
32
+ allowText: boolean;
33
+ allowThinking: boolean;
34
+ allowAnyTool: boolean;
35
+ toolScopes: ToolScope[];
36
+ }
37
+
12
38
  interface TtsrEntry {
13
39
  rule: Rule;
14
- regex: RegExp;
40
+ conditions: RegExp[];
41
+ scope: TtsrScope;
42
+ globalPathGlobs?: Bun.Glob[];
15
43
  }
16
44
 
17
- /** Tracks when a rule was last injected (for repeat-after-gap mode) */
45
+ /** Tracks when a rule was last injected (for repeat gating). */
18
46
  interface InjectionRecord {
19
- /** Message count when the rule was last injected */
47
+ /** Message count (turn index) when the rule was last injected. */
20
48
  lastInjectedAt: number;
21
49
  }
22
50
 
23
51
  const DEFAULT_SETTINGS: Required<TtsrSettings> = {
24
52
  enabled: true,
25
53
  contextMode: "discard",
54
+ interruptMode: "always",
26
55
  repeatMode: "once",
27
56
  repeatGap: 10,
28
57
  };
29
58
 
59
+ const DEFAULT_SCOPE: TtsrScope = {
60
+ allowText: true,
61
+ allowThinking: false,
62
+ allowAnyTool: true,
63
+ toolScopes: [],
64
+ };
65
+
30
66
  export class TtsrManager {
31
67
  readonly #settings: Required<TtsrSettings>;
32
68
  readonly #rules = new Map<string, TtsrEntry>();
33
69
  readonly #injectionRecords = new Map<string, InjectionRecord>();
34
- #buffer = "";
70
+ readonly #buffers = new Map<string, string>();
35
71
  #messageCount = 0;
36
72
 
37
73
  constructor(settings?: TtsrSettings) {
38
74
  this.#settings = { ...DEFAULT_SETTINGS, ...settings };
39
75
  }
40
76
 
41
- /** Check if a rule can be triggered based on repeat settings */
77
+ /** Check if a rule can be triggered based on repeat settings. */
42
78
  #canTrigger(ruleName: string): boolean {
43
79
  const record = this.#injectionRecords.get(ruleName);
44
80
  if (!record) {
@@ -53,71 +89,315 @@ export class TtsrManager {
53
89
  return gap >= this.#settings.repeatGap;
54
90
  }
55
91
 
56
- /** Add a TTSR rule to be monitored */
57
- addRule(rule: Rule): void {
58
- if (!rule.ttsrTrigger) {
59
- return;
92
+ #compileConditions(rule: Rule): RegExp[] {
93
+ const compiled: RegExp[] = [];
94
+ for (const pattern of rule.condition ?? []) {
95
+ try {
96
+ compiled.push(new RegExp(pattern));
97
+ } catch (error) {
98
+ logger.warn("TTSR condition has invalid regex pattern, skipping condition", {
99
+ ruleName: rule.name,
100
+ pattern,
101
+ error: error instanceof Error ? error.message : String(error),
102
+ });
103
+ }
104
+ }
105
+
106
+ return compiled;
107
+ }
108
+
109
+ #compileGlobalPathGlobs(globs: Rule["globs"]): Bun.Glob[] | undefined {
110
+ if (!globs || globs.length === 0) {
111
+ return undefined;
112
+ }
113
+
114
+ const compiled = globs
115
+ .map(glob => glob.trim())
116
+ .filter(glob => glob.length > 0)
117
+ .map(glob => new Bun.Glob(glob));
118
+ return compiled.length > 0 ? compiled : undefined;
119
+ }
120
+
121
+ #parseToolScopeToken(token: string): ToolScope | undefined {
122
+ const match = /^(?:(?<prefix>tool)(?::(?<tool>[a-z0-9_-]+))?|(?<bare>[a-z0-9_-]+))(?:\((?<path>[^)]+)\))?$/i.exec(
123
+ token,
124
+ );
125
+ if (!match) {
126
+ return undefined;
127
+ }
128
+
129
+ const groups = match.groups;
130
+ const hasToolPrefix = groups?.prefix !== undefined;
131
+ const toolName = (groups?.tool ?? (hasToolPrefix ? undefined : groups?.bare))?.trim().toLowerCase();
132
+ const pathPattern = groups?.path?.trim();
133
+
134
+ if (!pathPattern) {
135
+ return { toolName };
136
+ }
137
+
138
+ return {
139
+ toolName,
140
+ pathPattern,
141
+ pathGlob: new Bun.Glob(pathPattern),
142
+ };
143
+ }
144
+
145
+ #buildScope(rule: Rule): TtsrScope {
146
+ if (!rule.scope || rule.scope.length === 0) {
147
+ return {
148
+ allowText: DEFAULT_SCOPE.allowText,
149
+ allowThinking: DEFAULT_SCOPE.allowThinking,
150
+ allowAnyTool: DEFAULT_SCOPE.allowAnyTool,
151
+ toolScopes: [...DEFAULT_SCOPE.toolScopes],
152
+ };
153
+ }
154
+
155
+ const scope: TtsrScope = {
156
+ allowText: false,
157
+ allowThinking: false,
158
+ allowAnyTool: false,
159
+ toolScopes: [],
160
+ };
161
+
162
+ for (const rawToken of rule.scope) {
163
+ const token = rawToken.trim();
164
+ const normalizedToken = token.toLowerCase();
165
+ if (token.length === 0) {
166
+ continue;
167
+ }
168
+
169
+ if (normalizedToken === "text") {
170
+ scope.allowText = true;
171
+ continue;
172
+ }
173
+
174
+ if (normalizedToken === "thinking") {
175
+ scope.allowThinking = true;
176
+ continue;
177
+ }
178
+
179
+ if (normalizedToken === "tool" || normalizedToken === "toolcall") {
180
+ scope.allowAnyTool = true;
181
+ continue;
182
+ }
183
+
184
+ const toolScope = this.#parseToolScopeToken(token);
185
+ if (!toolScope) {
186
+ logger.warn("TTSR scope token is invalid, skipping token", {
187
+ ruleName: rule.name,
188
+ token: rawToken,
189
+ });
190
+ continue;
191
+ }
192
+
193
+ if (!toolScope.toolName && !toolScope.pathGlob) {
194
+ scope.allowAnyTool = true;
195
+ continue;
196
+ }
197
+
198
+ scope.toolScopes.push(toolScope);
199
+ }
200
+
201
+ return scope;
202
+ }
203
+
204
+ #hasReachableScope(scope: TtsrScope): boolean {
205
+ return scope.allowText || scope.allowThinking || scope.allowAnyTool || scope.toolScopes.length > 0;
206
+ }
207
+
208
+ #bufferKey(context: TtsrMatchContext): string {
209
+ if (context.streamKey && context.streamKey.trim().length > 0) {
210
+ return context.streamKey;
211
+ }
212
+ if (context.source !== "tool") {
213
+ return context.source;
214
+ }
215
+ const toolName = context.toolName?.trim().toLowerCase();
216
+ return toolName ? `tool:${toolName}` : "tool";
217
+ }
218
+
219
+ #normalizePath(pathValue: string): string {
220
+ return pathValue.replaceAll("\\", "/");
221
+ }
222
+
223
+ #matchesGlob(glob: Bun.Glob, filePaths: string[] | undefined): boolean {
224
+ if (!filePaths || filePaths.length === 0) {
225
+ return false;
226
+ }
227
+ for (const filePath of filePaths) {
228
+ const normalized = this.#normalizePath(filePath);
229
+ if (glob.match(normalized)) {
230
+ return true;
231
+ }
232
+ const slashIndex = normalized.lastIndexOf("/");
233
+ const basename = slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1);
234
+ if (basename !== normalized && glob.match(basename)) {
235
+ return true;
236
+ }
237
+ }
238
+
239
+ return false;
240
+ }
241
+
242
+ #matchesGlobalPaths(entry: TtsrEntry, context: TtsrMatchContext): boolean {
243
+ if (!entry.globalPathGlobs || entry.globalPathGlobs.length === 0) {
244
+ return true;
245
+ }
246
+
247
+ for (const glob of entry.globalPathGlobs) {
248
+ if (this.#matchesGlob(glob, context.filePaths)) {
249
+ return true;
250
+ }
251
+ }
252
+
253
+ return false;
254
+ }
255
+
256
+ #matchesScope(entry: TtsrEntry, context: TtsrMatchContext): boolean {
257
+ if (context.source === "text") {
258
+ return entry.scope.allowText;
259
+ }
260
+
261
+ if (context.source === "thinking") {
262
+ return entry.scope.allowThinking;
263
+ }
264
+
265
+ if (entry.scope.allowAnyTool) {
266
+ return true;
267
+ }
268
+
269
+ const toolName = context.toolName?.trim().toLowerCase();
270
+ for (const toolScope of entry.scope.toolScopes) {
271
+ if (toolScope.toolName && toolScope.toolName !== toolName) {
272
+ continue;
273
+ }
274
+ if (toolScope.pathGlob && !this.#matchesGlob(toolScope.pathGlob, context.filePaths)) {
275
+ continue;
276
+ }
277
+ return true;
278
+ }
279
+
280
+ return false;
281
+ }
282
+
283
+ #matchesCondition(entry: TtsrEntry, streamBuffer: string): boolean {
284
+ for (const condition of entry.conditions) {
285
+ condition.lastIndex = 0;
286
+ if (condition.test(streamBuffer)) {
287
+ return true;
288
+ }
60
289
  }
290
+ return false;
291
+ }
61
292
 
293
+ /** Add a TTSR rule to be monitored. */
294
+ addRule(rule: Rule): boolean {
62
295
  if (this.#rules.has(rule.name)) {
63
- return;
296
+ return false;
64
297
  }
65
298
 
66
- try {
67
- const regex = new RegExp(rule.ttsrTrigger);
68
- this.#rules.set(rule.name, { rule, regex });
69
- logger.debug("TTSR rule registered", {
70
- ruleName: rule.name,
71
- pattern: rule.ttsrTrigger,
72
- });
73
- } catch (err) {
74
- logger.warn("TTSR rule has invalid regex pattern, skipping", {
299
+ const conditions = this.#compileConditions(rule);
300
+ if (conditions.length === 0) {
301
+ return false;
302
+ }
303
+
304
+ const scope = this.#buildScope(rule);
305
+ if (!this.#hasReachableScope(scope)) {
306
+ logger.warn("TTSR scope excludes all streams, skipping rule", {
75
307
  ruleName: rule.name,
76
- pattern: rule.ttsrTrigger,
77
- error: err instanceof Error ? err.message : String(err),
308
+ scope: rule.scope,
78
309
  });
310
+ return false;
79
311
  }
312
+ const globalPathGlobs = this.#compileGlobalPathGlobs(rule.globs);
313
+ this.#rules.set(rule.name, {
314
+ rule,
315
+ conditions,
316
+ scope,
317
+ globalPathGlobs,
318
+ });
319
+
320
+ logger.debug("TTSR rule registered", {
321
+ ruleName: rule.name,
322
+ conditions: rule.condition,
323
+ scope: rule.scope,
324
+ globs: rule.globs,
325
+ });
326
+
327
+ return true;
80
328
  }
81
329
 
82
- /** Check if any uninjected TTSR matches the stream buffer. Returns matching rules. */
83
- check(streamBuffer: string): Rule[] {
84
- const matches: Rule[] = [];
330
+ /**
331
+ * Add a stream chunk to its scoped buffer and return matching rules.
332
+ *
333
+ * Buffers are isolated by source/tool key so matches don't bleed across
334
+ * assistant prose, thinking text, and unrelated tool argument streams.
335
+ */
336
+ checkDelta(delta: string, context: TtsrMatchContext): Rule[] {
337
+ const bufferKey = this.#bufferKey(context);
338
+ const nextBuffer = `${this.#buffers.get(bufferKey) ?? ""}${delta}`;
339
+ this.#buffers.set(bufferKey, nextBuffer);
85
340
 
341
+ const matches: Rule[] = [];
86
342
  for (const [name, entry] of this.#rules) {
87
343
  if (!this.#canTrigger(name)) {
88
344
  continue;
89
345
  }
90
-
91
- if (entry.regex.test(streamBuffer)) {
92
- matches.push(entry.rule);
93
- logger.debug("TTSR pattern matched", {
94
- ruleName: name,
95
- pattern: entry.rule.ttsrTrigger,
96
- });
346
+ if (!this.#matchesScope(entry, context)) {
347
+ continue;
97
348
  }
349
+ if (!this.#matchesGlobalPaths(entry, context)) {
350
+ continue;
351
+ }
352
+ if (!this.#matchesCondition(entry, nextBuffer)) {
353
+ continue;
354
+ }
355
+
356
+ matches.push(entry.rule);
357
+ logger.debug("TTSR condition matched", {
358
+ ruleName: name,
359
+ conditions: entry.rule.condition,
360
+ source: context.source,
361
+ toolName: context.toolName,
362
+ filePaths: context.filePaths,
363
+ });
98
364
  }
99
365
 
100
366
  return matches;
101
367
  }
102
368
 
103
- /** Mark rules as injected (won't trigger again until conditions allow) */
369
+ /** Mark rules as injected (won't trigger again until conditions allow). */
104
370
  markInjected(rulesToMark: Rule[]): void {
105
- for (const rule of rulesToMark) {
106
- this.#injectionRecords.set(rule.name, { lastInjectedAt: this.#messageCount });
371
+ this.markInjectedByNames(rulesToMark.map(rule => rule.name));
372
+ }
373
+
374
+ /** Mark rule names as injected (won't trigger again until conditions allow). */
375
+ markInjectedByNames(ruleNames: string[]): void {
376
+ for (const rawName of ruleNames) {
377
+ const ruleName = rawName.trim();
378
+ if (ruleName.length === 0) {
379
+ continue;
380
+ }
381
+ const record = this.#injectionRecords.get(ruleName);
382
+ if (!record) {
383
+ this.#injectionRecords.set(ruleName, { lastInjectedAt: this.#messageCount });
384
+ } else {
385
+ record.lastInjectedAt = this.#messageCount;
386
+ }
107
387
  logger.debug("TTSR rule marked as injected", {
108
- ruleName: rule.name,
388
+ ruleName,
109
389
  messageCount: this.#messageCount,
110
390
  repeatMode: this.#settings.repeatMode,
111
391
  });
112
392
  }
113
393
  }
114
394
 
115
- /** Get names of all injected rules (for persistence) */
395
+ /** Get names of all injected rules (for persistence). */
116
396
  getInjectedRuleNames(): string[] {
117
397
  return Array.from(this.#injectionRecords.keys());
118
398
  }
119
399
 
120
- /** Restore injected state from a list of rule names */
400
+ /** Restore injected state from a list of rule names. */
121
401
  restoreInjected(ruleNames: string[]): void {
122
402
  for (const name of ruleNames) {
123
403
  this.#injectionRecords.set(name, { lastInjectedAt: 0 });
@@ -127,37 +407,27 @@ export class TtsrManager {
127
407
  }
128
408
  }
129
409
 
130
- /** Reset stream buffer (called on new turn) */
410
+ /** Reset stream buffers (called on new turn). */
131
411
  resetBuffer(): void {
132
- this.#buffer = "";
133
- }
134
-
135
- /** Get current stream buffer */
136
- getBuffer(): string {
137
- return this.#buffer;
138
- }
139
-
140
- /** Append to stream buffer */
141
- appendToBuffer(text: string): void {
142
- this.#buffer += text;
412
+ this.#buffers.clear();
143
413
  }
144
414
 
145
- /** Check if any TTSRs are registered */
415
+ /** Check if any TTSR rules are registered. */
146
416
  hasRules(): boolean {
147
417
  return this.#rules.size > 0;
148
418
  }
149
419
 
150
- /** Increment message counter (call after each turn) */
420
+ /** Increment message counter (call after each turn). */
151
421
  incrementMessageCount(): void {
152
422
  this.#messageCount++;
153
423
  }
154
424
 
155
- /** Get current message count */
425
+ /** Get current message count. */
156
426
  getMessageCount(): number {
157
427
  return this.#messageCount;
158
428
  }
159
429
 
160
- /** Get settings */
430
+ /** Get settings. */
161
431
  getSettings(): Required<TtsrSettings> {
162
432
  return this.#settings;
163
433
  }
@@ -5,7 +5,7 @@ import type { AgentTool, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core
5
5
  import type { Static, TSchema } from "@sinclair/typebox";
6
6
  import type { Theme } from "../../modes/theme/theme";
7
7
  import { applyToolProxy } from "../tool-proxy";
8
- import type { CustomTool, CustomToolContext, LoadedCustomTool } from "./types";
8
+ import type { CustomTool, CustomToolContext } from "./types";
9
9
 
10
10
  export class CustomToolAdapter<TParams extends TSchema = TSchema, TDetails = any, TTheme extends Theme = Theme>
11
11
  implements AgentTool<TParams, TDetails, TTheme>
@@ -42,14 +42,4 @@ export class CustomToolAdapter<TParams extends TSchema = TSchema, TDetails = any
42
42
  ): AgentTool<TParams, TDetails, TTheme> {
43
43
  return new CustomToolAdapter(tool, getContext);
44
44
  }
45
-
46
- /**
47
- * Wrap all loaded custom tools into AgentTools.
48
- */
49
- static wrapTools<TParams extends TSchema = TSchema, TDetails = any, TTheme extends Theme = Theme>(
50
- loadedTools: LoadedCustomTool<TParams, TDetails>[],
51
- getContext: () => CustomToolContext,
52
- ): AgentTool<TParams, TDetails, TTheme>[] {
53
- return loadedTools.map(lt => CustomToolAdapter.wrap(lt.tool, getContext));
54
- }
55
45
  }
@@ -1,15 +1,16 @@
1
1
  /**
2
- * Internal URL routing system for agent:// and skill:// URLs.
2
+ * Internal URL routing system for internal protocols like agent://, memory://, and skill://.
3
3
  *
4
4
  * This module provides a unified way to resolve internal URLs without
5
5
  * exposing filesystem paths to the agent.
6
6
  *
7
7
  * @example
8
8
  * ```ts
9
- * import { InternalUrlRouter, AgentProtocolHandler, SkillProtocolHandler } from './internal-urls';
9
+ * import { InternalUrlRouter, AgentProtocolHandler, MemoryProtocolHandler, SkillProtocolHandler } from './internal-urls';
10
10
  *
11
11
  * const router = new InternalUrlRouter();
12
12
  * router.register(new AgentProtocolHandler({ getArtifactsDir: () => sessionDir }));
13
+ * router.register(new MemoryProtocolHandler({ getMemoryRoot: () => memoryRoot }));
13
14
  * router.register(new SkillProtocolHandler({ getSkills: () => skills }));
14
15
  *
15
16
  * if (router.canHandle('agent://reviewer_0')) {
@@ -22,6 +23,7 @@
22
23
  export { AgentProtocolHandler, type AgentProtocolOptions } from "./agent-protocol";
23
24
  export { ArtifactProtocolHandler, type ArtifactProtocolOptions } from "./artifact-protocol";
24
25
  export { applyQuery, parseQuery, pathToQuery } from "./json-query";
26
+ export { MemoryProtocolHandler, type MemoryProtocolOptions, resolveMemoryUrlToPath } from "./memory-protocol";
25
27
  export { PlanProtocolHandler, type PlanProtocolOptions, resolvePlanUrlToPath } from "./plan-protocol";
26
28
  export { InternalUrlRouter } from "./router";
27
29
  export { RuleProtocolHandler, type RuleProtocolOptions } from "./rule-protocol";
@@ -0,0 +1,133 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { isEnoent } from "@oh-my-pi/pi-utils";
4
+ import { validateRelativePath } from "./skill-protocol";
5
+ import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
6
+
7
+ const DEFAULT_MEMORY_FILE = "memory_summary.md";
8
+ const MEMORY_NAMESPACE = "root";
9
+
10
+ /**
11
+ * Options for the memory:// URL protocol.
12
+ */
13
+ export interface MemoryProtocolOptions {
14
+ /**
15
+ * Returns the absolute path to the current project's memory root.
16
+ */
17
+ getMemoryRoot: () => string;
18
+ }
19
+
20
+ function ensureWithinRoot(targetPath: string, rootPath: string): void {
21
+ if (targetPath !== rootPath && !targetPath.startsWith(`${rootPath}${path.sep}`)) {
22
+ throw new Error("memory:// URL escapes memory root");
23
+ }
24
+ }
25
+
26
+ function toMemoryValidationError(error: unknown): Error {
27
+ const message = error instanceof Error ? error.message : String(error);
28
+ return new Error(message.replace("skill://", "memory://"));
29
+ }
30
+
31
+ /**
32
+ * Resolve a memory:// URL to an absolute filesystem path under memory root.
33
+ */
34
+ export function resolveMemoryUrlToPath(url: InternalUrl, memoryRoot: string): string {
35
+ const namespace = url.rawHost || url.hostname;
36
+ if (!namespace) {
37
+ throw new Error("memory:// URL requires a namespace: memory://root");
38
+ }
39
+ if (namespace !== MEMORY_NAMESPACE) {
40
+ throw new Error(`Unknown memory namespace: ${namespace}. Supported: ${MEMORY_NAMESPACE}`);
41
+ }
42
+
43
+ const rawPathname = url.rawPathname ?? url.pathname;
44
+ const hasPath = rawPathname && rawPathname !== "/" && rawPathname !== "";
45
+ if (!hasPath) {
46
+ return path.resolve(memoryRoot, DEFAULT_MEMORY_FILE);
47
+ }
48
+ let relativePath: string;
49
+ try {
50
+ relativePath = decodeURIComponent(rawPathname.slice(1));
51
+ } catch {
52
+ throw new Error(`Invalid URL encoding in memory:// path: ${url.href}`);
53
+ }
54
+
55
+ try {
56
+ validateRelativePath(relativePath);
57
+ } catch (error) {
58
+ throw toMemoryValidationError(error);
59
+ }
60
+
61
+ return path.resolve(memoryRoot, relativePath);
62
+ }
63
+
64
+ /**
65
+ * Protocol handler for memory:// URLs.
66
+ *
67
+ * URL forms:
68
+ * - memory://root - Reads memory_summary.md
69
+ * - memory://root/<path> - Reads a relative file under memory root
70
+ */
71
+ export class MemoryProtocolHandler implements ProtocolHandler {
72
+ readonly scheme = "memory";
73
+
74
+ constructor(private readonly options: MemoryProtocolOptions) {}
75
+
76
+ async resolve(url: InternalUrl): Promise<InternalResource> {
77
+ const memoryRoot = path.resolve(this.options.getMemoryRoot());
78
+ let resolvedRoot: string;
79
+ try {
80
+ resolvedRoot = await fs.realpath(memoryRoot);
81
+ } catch (error) {
82
+ if (isEnoent(error)) {
83
+ throw new Error(
84
+ "Memory artifacts are not available for this project yet. Run a session with memories enabled first.",
85
+ );
86
+ }
87
+ throw error;
88
+ }
89
+
90
+ const targetPath = resolveMemoryUrlToPath(url, resolvedRoot);
91
+ ensureWithinRoot(targetPath, resolvedRoot);
92
+
93
+ const parentDir = path.dirname(targetPath);
94
+ try {
95
+ const realParent = await fs.realpath(parentDir);
96
+ ensureWithinRoot(realParent, resolvedRoot);
97
+ } catch (error) {
98
+ if (!isEnoent(error)) {
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ let realTargetPath: string;
104
+ try {
105
+ realTargetPath = await fs.realpath(targetPath);
106
+ } catch (error) {
107
+ if (isEnoent(error)) {
108
+ throw new Error(`Memory file not found: ${url.href}`);
109
+ }
110
+ throw error;
111
+ }
112
+
113
+ ensureWithinRoot(realTargetPath, resolvedRoot);
114
+
115
+ const stat = await fs.stat(realTargetPath);
116
+ if (!stat.isFile()) {
117
+ throw new Error(`memory:// URL must resolve to a file: ${url.href}`);
118
+ }
119
+
120
+ const content = await Bun.file(realTargetPath).text();
121
+ const ext = path.extname(realTargetPath).toLowerCase();
122
+ const contentType: InternalResource["contentType"] = ext === ".md" ? "text/markdown" : "text/plain";
123
+
124
+ return {
125
+ url: url.href,
126
+ content,
127
+ contentType,
128
+ size: Buffer.byteLength(content, "utf-8"),
129
+ sourcePath: realTargetPath,
130
+ notes: [],
131
+ };
132
+ }
133
+ }
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Internal URL router for resolving agent:// and skill:// URLs.
2
+ * Internal URL router for internal protocols (agent://, artifact://, plan://, memory://, skill://, rule://).
3
3
  */
4
4
  import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
5
5
 
6
6
  /**
7
7
  * Router for internal URL schemes.
8
8
  *
9
- * Dispatches URLs like `agent://output_id` or `skill://skill-name` to
9
+ * Dispatches URLs like `agent://output_id` or `memory://root/memory_summary.md` to
10
10
  * registered protocol handlers.
11
11
  */
12
12
  export class InternalUrlRouter {
@@ -52,6 +52,8 @@ export class InternalUrlRouter {
52
52
  // Leave rawHost as-is if decoding fails.
53
53
  }
54
54
  (parsed as InternalUrl).rawHost = rawHost;
55
+ const pathMatch = input.match(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*(\/[^?#]*)?/i);
56
+ (parsed as InternalUrl).rawPathname = pathMatch?.[1] ?? parsed.pathname;
55
57
 
56
58
  const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
57
59
  const handler = this.#handlers.get(scheme);
@@ -30,7 +30,7 @@ function getContentType(filePath: string): InternalResource["contentType"] {
30
30
  /**
31
31
  * Validate that a path is safe (no traversal, no absolute paths).
32
32
  */
33
- function validateRelativePath(relativePath: string): void {
33
+ export function validateRelativePath(relativePath: string): void {
34
34
  if (path.isAbsolute(relativePath)) {
35
35
  throw new Error("Absolute paths are not allowed in skill:// URLs");
36
36
  }