@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.
- package/CHANGELOG.md +57 -0
- package/package.json +7 -7
- package/src/capability/rule.ts +173 -5
- package/src/cli/args.ts +3 -0
- package/src/cli/update-cli.ts +1 -66
- package/src/commands/launch.ts +3 -0
- package/src/config/model-registry.ts +300 -21
- package/src/config/model-resolver.ts +4 -4
- package/src/config/settings-schema.ts +12 -0
- package/src/discovery/agents.ts +175 -12
- package/src/discovery/builtin.ts +3 -13
- package/src/discovery/cline.ts +4 -45
- package/src/discovery/cursor.ts +2 -29
- package/src/discovery/helpers.ts +43 -0
- package/src/discovery/index.ts +1 -0
- package/src/discovery/opencode.ts +394 -0
- package/src/discovery/windsurf.ts +5 -44
- package/src/export/ttsr.ts +324 -54
- package/src/extensibility/custom-tools/wrapper.ts +1 -11
- package/src/internal-urls/index.ts +4 -2
- package/src/internal-urls/memory-protocol.ts +133 -0
- package/src/internal-urls/router.ts +4 -2
- package/src/internal-urls/skill-protocol.ts +1 -1
- package/src/internal-urls/types.ts +6 -2
- package/src/main.ts +5 -0
- package/src/memories/index.ts +6 -13
- package/src/modes/components/settings-defs.ts +6 -0
- package/src/modes/components/status-line/segments.ts +3 -2
- package/src/modes/rpc/rpc-client.ts +16 -0
- package/src/prompts/memories/consolidation.md +1 -1
- package/src/prompts/memories/read_path.md +4 -4
- package/src/prompts/memories/stage_one_input.md +1 -2
- package/src/prompts/tools/bash.md +10 -23
- package/src/prompts/tools/read.md +2 -0
- package/src/sdk.ts +25 -10
- package/src/session/agent-session.ts +252 -44
- package/src/session/session-manager.ts +79 -36
- package/src/tools/bash-skill-urls.ts +177 -0
- package/src/tools/bash.ts +7 -1
- package/src/tools/fetch.ts +6 -2
- package/src/tools/index.ts +2 -2
- package/src/tools/output-meta.ts +49 -42
- package/src/tools/read.ts +2 -2
package/src/export/ttsr.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
40
|
+
conditions: RegExp[];
|
|
41
|
+
scope: TtsrScope;
|
|
42
|
+
globalPathGlobs?: Bun.Glob[];
|
|
15
43
|
}
|
|
16
44
|
|
|
17
|
-
/** Tracks when a rule was last injected (for repeat
|
|
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
|
-
#
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
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
|
|
410
|
+
/** Reset stream buffers (called on new turn). */
|
|
131
411
|
resetBuffer(): void {
|
|
132
|
-
this.#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 `
|
|
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
|
}
|