@amanm/openpaw 0.1.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/AGENTS.md +1 -0
- package/README.md +144 -0
- package/agent/agent.ts +217 -0
- package/agent/context-scan.ts +81 -0
- package/agent/file-editor-store.ts +27 -0
- package/agent/index.ts +31 -0
- package/agent/memory-store.ts +404 -0
- package/agent/model.ts +14 -0
- package/agent/prompt-builder.ts +139 -0
- package/agent/prompt-context-files.ts +151 -0
- package/agent/sandbox-paths.ts +52 -0
- package/agent/session-store.ts +80 -0
- package/agent/skill-catalog.ts +25 -0
- package/agent/skills/discover.ts +100 -0
- package/agent/tool-stream-format.ts +126 -0
- package/agent/tool-yaml-like.ts +96 -0
- package/agent/tools/bash.ts +100 -0
- package/agent/tools/file-editor.ts +293 -0
- package/agent/tools/list-dir.ts +58 -0
- package/agent/tools/load-skill.ts +40 -0
- package/agent/tools/memory.ts +84 -0
- package/agent/turn-context.ts +46 -0
- package/agent/types.ts +37 -0
- package/agent/workspace-bootstrap.ts +98 -0
- package/bin/openpaw.cjs +177 -0
- package/bundled-skills/find-skills/SKILL.md +163 -0
- package/cli/components/chat-app.tsx +759 -0
- package/cli/components/onboard-ui.tsx +325 -0
- package/cli/components/theme.ts +16 -0
- package/cli/configure.tsx +0 -0
- package/cli/lib/chat-transcript-types.ts +11 -0
- package/cli/lib/markdown-render-node.ts +523 -0
- package/cli/lib/onboard-markdown-syntax-style.ts +55 -0
- package/cli/lib/ui-messages-to-chat-transcript.ts +157 -0
- package/cli/lib/use-auto-copy-selection.ts +38 -0
- package/cli/onboard.tsx +248 -0
- package/cli/openpaw.tsx +144 -0
- package/cli/reset.ts +12 -0
- package/cli/tui.tsx +31 -0
- package/config/index.ts +3 -0
- package/config/paths.ts +71 -0
- package/config/personality-copy.ts +68 -0
- package/config/storage.ts +80 -0
- package/config/types.ts +37 -0
- package/gateway/bootstrap.ts +25 -0
- package/gateway/channel-adapter.ts +8 -0
- package/gateway/daemon-manager.ts +191 -0
- package/gateway/index.ts +18 -0
- package/gateway/session-key.ts +13 -0
- package/gateway/slash-command-tokens.ts +39 -0
- package/gateway/start-messaging.ts +40 -0
- package/gateway/telegram/active-thread-store.ts +89 -0
- package/gateway/telegram/adapter.ts +290 -0
- package/gateway/telegram/assistant-markdown.ts +48 -0
- package/gateway/telegram/bot-commands.ts +40 -0
- package/gateway/telegram/chat-preferences.ts +100 -0
- package/gateway/telegram/constants.ts +5 -0
- package/gateway/telegram/index.ts +4 -0
- package/gateway/telegram/message-html.ts +138 -0
- package/gateway/telegram/message-queue.ts +19 -0
- package/gateway/telegram/reserved-command-filter.ts +33 -0
- package/gateway/telegram/session-file-discovery.ts +62 -0
- package/gateway/telegram/session-key.ts +13 -0
- package/gateway/telegram/session-label.ts +14 -0
- package/gateway/telegram/sessions-list-reply.ts +39 -0
- package/gateway/telegram/stream-delivery.ts +618 -0
- package/gateway/tui/constants.ts +2 -0
- package/gateway/tui/tui-active-thread-store.ts +103 -0
- package/gateway/tui/tui-session-discovery.ts +94 -0
- package/gateway/tui/tui-session-label.ts +22 -0
- package/gateway/tui/tui-sessions-list-message.ts +37 -0
- package/package.json +52 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { tool } from "ai";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { popHistory, pushHistory } from "../file-editor-store";
|
|
6
|
+
import { refreshSkillCatalog, type OpenPawSkillCatalog } from "../skill-catalog";
|
|
7
|
+
import { resolveScopePath } from "../sandbox-paths";
|
|
8
|
+
|
|
9
|
+
type ToolSuccess = { success: true; output: string };
|
|
10
|
+
type ToolFailure = { success: false; error: string };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Reads a file and returns numbered lines in Anthropic text-editor style: padded index, ` | `, content.
|
|
14
|
+
* Optional {@link viewRange} is `[start_line, end_line]` (1-based, inclusive); the slice uses the same
|
|
15
|
+
* rules as the classic str_replace editor (end is passed directly to {@link String#slice}’s end index).
|
|
16
|
+
*/
|
|
17
|
+
async function readFileWithLines(
|
|
18
|
+
absPath: string,
|
|
19
|
+
viewRange?: [number, number],
|
|
20
|
+
): Promise<string> {
|
|
21
|
+
const raw = await readFile(absPath, "utf-8");
|
|
22
|
+
const lines = raw.split("\n");
|
|
23
|
+
const start = viewRange ? viewRange[0] - 1 : 0;
|
|
24
|
+
const end = viewRange ? viewRange[1] : lines.length;
|
|
25
|
+
const width = String(lines.length).length;
|
|
26
|
+
return lines
|
|
27
|
+
.slice(start, end)
|
|
28
|
+
.map((l, i) => `${String(start + i + 1).padStart(width)} | ${l}`)
|
|
29
|
+
.join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* str_replace-style file editor (Anthropic `text_editor_20250728`-like), scoped by sandbox.
|
|
34
|
+
* Single flat input object so models can pass one JSON shape per call.
|
|
35
|
+
*/
|
|
36
|
+
const FileEditorInputSchema = z.object({
|
|
37
|
+
command: z
|
|
38
|
+
.enum([
|
|
39
|
+
"view",
|
|
40
|
+
"create",
|
|
41
|
+
"delete",
|
|
42
|
+
"str_replace",
|
|
43
|
+
"insert",
|
|
44
|
+
"delete_lines",
|
|
45
|
+
"undo_edit",
|
|
46
|
+
])
|
|
47
|
+
.describe(
|
|
48
|
+
'"view" to read a file or list a directory, "create" to write a new file, ' +
|
|
49
|
+
'"delete" to remove a file, "str_replace" for an exact substring replace, ' +
|
|
50
|
+
'"insert" to add lines after a line number, "delete_lines" to remove a line range, ' +
|
|
51
|
+
'"undo_edit" to revert the last mutating change to that path.',
|
|
52
|
+
),
|
|
53
|
+
|
|
54
|
+
path: z.string().describe("Relative or absolute path (sandbox still applies)."),
|
|
55
|
+
|
|
56
|
+
view_range: z
|
|
57
|
+
.array(z.number().int().positive())
|
|
58
|
+
.length(2)
|
|
59
|
+
.optional()
|
|
60
|
+
.describe("Optional [start_line, end_line] to view a slice (1-indexed, inclusive)."),
|
|
61
|
+
|
|
62
|
+
file_text: z.string().optional().describe('Full content for "create".'),
|
|
63
|
+
|
|
64
|
+
old_str: z
|
|
65
|
+
.string()
|
|
66
|
+
.optional()
|
|
67
|
+
.describe(
|
|
68
|
+
"Exact string to find for str_replace — must match whitespace/indentation; copy from view output.",
|
|
69
|
+
),
|
|
70
|
+
|
|
71
|
+
new_str: z
|
|
72
|
+
.string()
|
|
73
|
+
.optional()
|
|
74
|
+
.describe("Replacement for str_replace; may be empty to delete old_str."),
|
|
75
|
+
|
|
76
|
+
insert_line: z
|
|
77
|
+
.number()
|
|
78
|
+
.int()
|
|
79
|
+
.min(0)
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("Insert insert_text AFTER this line; 0 inserts at the top."),
|
|
82
|
+
|
|
83
|
+
insert_text: z.string().optional().describe("Text to insert (may be multiple lines)."),
|
|
84
|
+
|
|
85
|
+
start_line: z
|
|
86
|
+
.number()
|
|
87
|
+
.int()
|
|
88
|
+
.positive()
|
|
89
|
+
.optional()
|
|
90
|
+
.describe("First line to delete (1-based, inclusive) for delete_lines."),
|
|
91
|
+
|
|
92
|
+
end_line: z
|
|
93
|
+
.number()
|
|
94
|
+
.int()
|
|
95
|
+
.positive()
|
|
96
|
+
.optional()
|
|
97
|
+
.describe("Last line to delete (1-based, inclusive) for delete_lines."),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
type FileEditorInput = z.infer<typeof FileEditorInputSchema>;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Portable str_replace-style file editor: view, create, delete, str_replace, insert, delete_lines,
|
|
104
|
+
* undo_edit. When the sandbox is on, paths must stay under the workspace (or `FILE_EDITOR_ROOT`)
|
|
105
|
+
* or under one of the discovered skill directories. Rescans skill metadata at each invocation so
|
|
106
|
+
* installs in the same turn (e.g. after `bash`) are visible before the next gateway `prepareCall`.
|
|
107
|
+
*/
|
|
108
|
+
export function createFileEditorTool(workspaceRoot: string, skillCatalog: OpenPawSkillCatalog) {
|
|
109
|
+
return tool({
|
|
110
|
+
description: `
|
|
111
|
+
A file editor for viewing and editing files.
|
|
112
|
+
|
|
113
|
+
COMMANDS:
|
|
114
|
+
- view → Read a file with line numbers (pipe-separated). Always do this before editing.
|
|
115
|
+
- create → Create a new file with given content.
|
|
116
|
+
- delete → Delete a file.
|
|
117
|
+
- str_replace → Replace an exact string with new text. old_str must match exactly, including
|
|
118
|
+
whitespace and indentation. Use "view" first to copy it precisely.
|
|
119
|
+
- insert → Insert new lines after a given line number (0 = top).
|
|
120
|
+
- delete_lines → Delete a range of lines (1-based, inclusive).
|
|
121
|
+
- undo_edit → Revert the last mutating change to that file (OpenPaw extension).
|
|
122
|
+
|
|
123
|
+
IMPORTANT: For str_replace, copy old_str character-for-character from the view output.
|
|
124
|
+
Even a single space difference will cause the edit to fail.
|
|
125
|
+
`.trim(),
|
|
126
|
+
inputSchema: FileEditorInputSchema,
|
|
127
|
+
execute: async (params: FileEditorInput): Promise<ToolSuccess | ToolFailure> => {
|
|
128
|
+
await refreshSkillCatalog(skillCatalog);
|
|
129
|
+
const skillRoots = skillCatalog.skills.map((s) => s.path);
|
|
130
|
+
const {
|
|
131
|
+
command,
|
|
132
|
+
path: filePath,
|
|
133
|
+
view_range,
|
|
134
|
+
file_text,
|
|
135
|
+
old_str,
|
|
136
|
+
new_str,
|
|
137
|
+
insert_line,
|
|
138
|
+
insert_text,
|
|
139
|
+
start_line,
|
|
140
|
+
end_line,
|
|
141
|
+
} = params;
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
switch (command) {
|
|
145
|
+
case "view": {
|
|
146
|
+
const abs = resolveScopePath(workspaceRoot, skillRoots, filePath);
|
|
147
|
+
const st = await stat(abs);
|
|
148
|
+
if (st.isDirectory()) {
|
|
149
|
+
const names = await readdir(abs);
|
|
150
|
+
return {
|
|
151
|
+
success: true,
|
|
152
|
+
output: `Directory: ${filePath}\n${names.join("\n")}`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const rangeTuple =
|
|
156
|
+
view_range !== undefined
|
|
157
|
+
? ([view_range[0], view_range[1]] as [number, number])
|
|
158
|
+
: undefined;
|
|
159
|
+
const content = await readFileWithLines(abs, rangeTuple);
|
|
160
|
+
return { success: true, output: content };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
case "create": {
|
|
164
|
+
if (file_text == null) {
|
|
165
|
+
return { success: false, error: "file_text is required for create." };
|
|
166
|
+
}
|
|
167
|
+
const abs = resolveScopePath(workspaceRoot, skillRoots, filePath);
|
|
168
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
169
|
+
try {
|
|
170
|
+
const existing = await readFile(abs, "utf-8");
|
|
171
|
+
pushHistory(abs, existing);
|
|
172
|
+
} catch {
|
|
173
|
+
/* new file */
|
|
174
|
+
}
|
|
175
|
+
await writeFile(abs, file_text, "utf-8");
|
|
176
|
+
const lineCount = file_text.split("\n").length;
|
|
177
|
+
return {
|
|
178
|
+
success: true,
|
|
179
|
+
output: `Created ${filePath} (${lineCount} lines).`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
case "delete": {
|
|
184
|
+
const abs = resolveScopePath(workspaceRoot, skillRoots, filePath);
|
|
185
|
+
const previous = await readFile(abs, "utf-8");
|
|
186
|
+
pushHistory(abs, previous);
|
|
187
|
+
await unlink(abs);
|
|
188
|
+
return { success: true, output: `Deleted ${filePath}.` };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
case "str_replace": {
|
|
192
|
+
if (old_str == null) {
|
|
193
|
+
return { success: false, error: "old_str is required for str_replace." };
|
|
194
|
+
}
|
|
195
|
+
const abs = resolveScopePath(workspaceRoot, skillRoots, filePath);
|
|
196
|
+
const content = await readFile(abs, "utf-8");
|
|
197
|
+
const occurrences = content.split(old_str).length - 1;
|
|
198
|
+
if (occurrences === 0) {
|
|
199
|
+
return {
|
|
200
|
+
success: false,
|
|
201
|
+
error:
|
|
202
|
+
`old_str not found in ${filePath}. ` +
|
|
203
|
+
`Tip: Use "view" to copy the exact string including whitespace/indentation.`,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (occurrences > 1) {
|
|
207
|
+
return {
|
|
208
|
+
success: false,
|
|
209
|
+
error:
|
|
210
|
+
`old_str matches ${occurrences} locations in ${filePath}. ` +
|
|
211
|
+
`Add more surrounding context to old_str to make it unique.`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
pushHistory(abs, content);
|
|
215
|
+
await writeFile(abs, content.replace(old_str, new_str ?? ""), "utf-8");
|
|
216
|
+
return {
|
|
217
|
+
success: true,
|
|
218
|
+
output: `str_replace applied successfully in ${filePath}.`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case "insert": {
|
|
223
|
+
if (insert_line == null) {
|
|
224
|
+
return { success: false, error: "insert_line is required for insert." };
|
|
225
|
+
}
|
|
226
|
+
if (insert_text == null || insert_text === "") {
|
|
227
|
+
return { success: false, error: "insert_text is required for insert." };
|
|
228
|
+
}
|
|
229
|
+
const abs = resolveScopePath(workspaceRoot, skillRoots, filePath);
|
|
230
|
+
const original = await readFile(abs, "utf-8");
|
|
231
|
+
const lines = original.split("\n");
|
|
232
|
+
pushHistory(abs, original);
|
|
233
|
+
lines.splice(insert_line, 0, ...insert_text.split("\n"));
|
|
234
|
+
await writeFile(abs, lines.join("\n"), "utf-8");
|
|
235
|
+
return {
|
|
236
|
+
success: true,
|
|
237
|
+
output: `Inserted ${insert_text.split("\n").length} line(s) after line ${insert_line} in ${filePath}.`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
case "delete_lines": {
|
|
242
|
+
if (start_line == null) {
|
|
243
|
+
return { success: false, error: "start_line is required for delete_lines." };
|
|
244
|
+
}
|
|
245
|
+
if (end_line == null) {
|
|
246
|
+
return { success: false, error: "end_line is required for delete_lines." };
|
|
247
|
+
}
|
|
248
|
+
const abs = resolveScopePath(workspaceRoot, skillRoots, filePath);
|
|
249
|
+
const original = await readFile(abs, "utf-8");
|
|
250
|
+
const lines = original.split("\n");
|
|
251
|
+
pushHistory(abs, original);
|
|
252
|
+
lines.splice(start_line - 1, end_line - start_line + 1);
|
|
253
|
+
await writeFile(abs, lines.join("\n"), "utf-8");
|
|
254
|
+
return {
|
|
255
|
+
success: true,
|
|
256
|
+
output: `Deleted lines ${start_line}–${end_line} from ${filePath}.`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
case "undo_edit": {
|
|
261
|
+
const abs = resolveScopePath(workspaceRoot, skillRoots, filePath);
|
|
262
|
+
const previous = popHistory(abs);
|
|
263
|
+
if (previous === null) {
|
|
264
|
+
return {
|
|
265
|
+
success: false,
|
|
266
|
+
error: `No edit history found for "${filePath}".`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
await stat(abs);
|
|
271
|
+
await writeFile(abs, previous, "utf-8");
|
|
272
|
+
} catch {
|
|
273
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
274
|
+
await writeFile(abs, previous, "utf-8");
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
success: true,
|
|
278
|
+
output: `Reverted "${filePath}" to its previous state.`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
default: {
|
|
283
|
+
const _exhaustive: never = command;
|
|
284
|
+
return { success: false, error: `Unknown command: ${_exhaustive}` };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} catch (err: unknown) {
|
|
288
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
289
|
+
return { success: false, error: msg };
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { tool } from "ai";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { refreshSkillCatalog, type OpenPawSkillCatalog } from "../skill-catalog";
|
|
5
|
+
import { resolveScopePath, workspaceSandboxBase } from "../sandbox-paths";
|
|
6
|
+
import { isSandboxRestricted } from "../turn-context";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolves which directory to list: optional path, or workspace root (sandbox on) / cwd (sandbox off).
|
|
10
|
+
*/
|
|
11
|
+
function resolveListDirPath(
|
|
12
|
+
workspaceRoot: string,
|
|
13
|
+
skillRoots: readonly string[],
|
|
14
|
+
pathArg: string | undefined,
|
|
15
|
+
): string {
|
|
16
|
+
if (pathArg === undefined || pathArg === "") {
|
|
17
|
+
if (isSandboxRestricted()) {
|
|
18
|
+
return workspaceSandboxBase(workspaceRoot);
|
|
19
|
+
}
|
|
20
|
+
return process.cwd();
|
|
21
|
+
}
|
|
22
|
+
return resolveScopePath(workspaceRoot, skillRoots, pathArg);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Lists directory entries as newline-separated names. Paths follow the same sandbox rules as
|
|
27
|
+
* `file_editor` and `bash`.
|
|
28
|
+
*/
|
|
29
|
+
export function createListDirTool(workspaceRoot: string, skillCatalog: OpenPawSkillCatalog) {
|
|
30
|
+
return tool({
|
|
31
|
+
description:
|
|
32
|
+
"List the contents of a directory at the given path. If no path is provided, lists the " +
|
|
33
|
+
"workspace root when the filesystem sandbox is on, or the current working directory when it is off.",
|
|
34
|
+
inputSchema: z.object({
|
|
35
|
+
path: z
|
|
36
|
+
.string()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe(
|
|
39
|
+
"Directory path to list; relative to workspace when sandbox is on, or under a loaded skill directory.",
|
|
40
|
+
),
|
|
41
|
+
}),
|
|
42
|
+
execute: async ({ path: pathArg }): Promise<{ contents: string }> => {
|
|
43
|
+
try {
|
|
44
|
+
await refreshSkillCatalog(skillCatalog);
|
|
45
|
+
const dirPath = resolveListDirPath(
|
|
46
|
+
workspaceRoot,
|
|
47
|
+
skillCatalog.skills.map((s) => s.path),
|
|
48
|
+
pathArg,
|
|
49
|
+
);
|
|
50
|
+
const names = await readdir(dirPath);
|
|
51
|
+
return { contents: names.join("\n") };
|
|
52
|
+
} catch (e) {
|
|
53
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
54
|
+
return { contents: `Error: ${msg}` };
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tool } from "ai";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { OpenPawSkillCatalog } from "../skill-catalog";
|
|
6
|
+
import { refreshSkillCatalog } from "../skill-catalog";
|
|
7
|
+
import { stripSkillBody } from "../skills/discover";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Tool that loads the markdown body of a discovered skill and returns its directory for bundled assets.
|
|
11
|
+
* Rescans the skill directories before each load so skills installed during the session are visible immediately.
|
|
12
|
+
*/
|
|
13
|
+
export function createLoadSkillTool(catalog: OpenPawSkillCatalog) {
|
|
14
|
+
return tool({
|
|
15
|
+
description:
|
|
16
|
+
"Load a skill by name to get specialized instructions (markdown, no frontmatter) and " +
|
|
17
|
+
"`skillDirectory` for paths to references, scripts, etc. Use when the user's task matches " +
|
|
18
|
+
"a skill listed in the system prompt.",
|
|
19
|
+
inputSchema: z.object({
|
|
20
|
+
name: z.string().describe("Skill name from the available skills list"),
|
|
21
|
+
}),
|
|
22
|
+
execute: async ({ name }) => {
|
|
23
|
+
await refreshSkillCatalog(catalog);
|
|
24
|
+
const key = name.trim().toLowerCase();
|
|
25
|
+
const skill = catalog.skills.find((s) => s.name.toLowerCase() === key);
|
|
26
|
+
if (!skill) {
|
|
27
|
+
return { error: `Skill "${name}" not found` };
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const raw = await readFile(join(skill.path, "SKILL.md"), "utf-8");
|
|
31
|
+
return {
|
|
32
|
+
skillDirectory: skill.path,
|
|
33
|
+
content: stripSkillBody(raw),
|
|
34
|
+
};
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent curated memory tool — add / replace / remove entries in MEMORY.md or USER.md.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { tool } from "ai";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import type { MemoryStore } from "../memory-store";
|
|
8
|
+
|
|
9
|
+
const DESCRIPTION =
|
|
10
|
+
"Save durable information to persistent memory (survives across sessions; compact entries).\n\n" +
|
|
11
|
+
"PARAMETERS BY ACTION (do not skip required fields):\n" +
|
|
12
|
+
"- action add: MUST set `content` (the new entry). Do NOT use replace for brand-new facts.\n" +
|
|
13
|
+
"- action replace: MUST set BOTH `old_text` AND `content`. `old_text` is a short unique substring that appears in exactly one existing entry (copy from the memory block in the prompt or from your last memory tool result). `content` is the full replacement text for that entry.\n" +
|
|
14
|
+
"- action remove: MUST set `old_text` (substring identifying the entry). `content` is ignored.\n\n" +
|
|
15
|
+
"Common mistake: using action replace with only `content` — that fails; use add for new entries, or include `old_text` when replacing.\n\n" +
|
|
16
|
+
"WHEN TO SAVE: corrections, preferences, timezone/location when relevant, stable environment facts. Not ephemeral logs.\n\n" +
|
|
17
|
+
"TARGETS: user = user profile facts; memory = your notes.\n\n" +
|
|
18
|
+
"When you tell the user you saved something, say it in ordinary words — do not name backing files or tools.";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Registers the memory tool against a shared {@link MemoryStore} instance.
|
|
22
|
+
*/
|
|
23
|
+
export function createMemoryTool(store: MemoryStore) {
|
|
24
|
+
return tool({
|
|
25
|
+
description: DESCRIPTION,
|
|
26
|
+
inputSchema: z.object({
|
|
27
|
+
action: z
|
|
28
|
+
.enum(["add", "replace", "remove"])
|
|
29
|
+
.describe(
|
|
30
|
+
"add = append one entry (needs content only). replace = rewrite one entry (needs old_text + content). remove = delete one entry (needs old_text only).",
|
|
31
|
+
),
|
|
32
|
+
target: z
|
|
33
|
+
.enum(["memory", "user"])
|
|
34
|
+
.describe("memory = agent notes; user = user profile."),
|
|
35
|
+
content: z
|
|
36
|
+
.string()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe(
|
|
39
|
+
"For add: required full text of the new entry. For replace: required full text that will replace the matched entry. Omit for remove.",
|
|
40
|
+
),
|
|
41
|
+
old_text: z
|
|
42
|
+
.string()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe(
|
|
45
|
+
"For replace and remove: required. A substring unique to one existing entry (e.g. 'Birthday:' or an old date). Not needed for add.",
|
|
46
|
+
),
|
|
47
|
+
}),
|
|
48
|
+
execute: async ({ action, target, content, old_text }) => {
|
|
49
|
+
if (action === "add") {
|
|
50
|
+
if (!content?.trim()) {
|
|
51
|
+
return JSON.stringify({ success: false, error: "content is required for add." });
|
|
52
|
+
}
|
|
53
|
+
const result = await store.add(target, content);
|
|
54
|
+
return JSON.stringify(result);
|
|
55
|
+
}
|
|
56
|
+
if (action === "replace") {
|
|
57
|
+
if (!old_text?.trim()) {
|
|
58
|
+
return JSON.stringify({
|
|
59
|
+
success: false,
|
|
60
|
+
error:
|
|
61
|
+
"replace requires `old_text` (substring of the entry to change) AND `content` (new full entry). For a new fact with no prior entry, use action `add` with only `content`.",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (!content?.trim()) {
|
|
65
|
+
return JSON.stringify({
|
|
66
|
+
success: false,
|
|
67
|
+
error:
|
|
68
|
+
"replace requires `content` (the full replacement text for the entry matched by `old_text`).",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const result = await store.replace(target, old_text, content);
|
|
72
|
+
return JSON.stringify(result);
|
|
73
|
+
}
|
|
74
|
+
if (action === "remove") {
|
|
75
|
+
if (!old_text?.trim()) {
|
|
76
|
+
return JSON.stringify({ success: false, error: "old_text is required for remove." });
|
|
77
|
+
}
|
|
78
|
+
const result = await store.remove(target, old_text);
|
|
79
|
+
return JSON.stringify(result);
|
|
80
|
+
}
|
|
81
|
+
return JSON.stringify({ success: false, error: "Unknown action." });
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type { OpenPawSurface } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-turn flags for tool execution. Used with {@link runWithTurnContext} so concurrent
|
|
6
|
+
* gateway chats do not share mutable sandbox state on a single AgentRuntime.
|
|
7
|
+
*/
|
|
8
|
+
export type OpenPawTurnContext = {
|
|
9
|
+
/** When true (default), file_editor and bash are scoped to the workspace; when false, broader access. */
|
|
10
|
+
sandboxRestricted: boolean;
|
|
11
|
+
/** Chat surface for platform hints in the system prompt. */
|
|
12
|
+
surface: OpenPawSurface;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const turnStorage = new AsyncLocalStorage<OpenPawTurnContext>();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Runs {@link fn} with turn context stored for the current async execution chain.
|
|
19
|
+
*/
|
|
20
|
+
export function runWithTurnContext<T>(
|
|
21
|
+
ctx: OpenPawTurnContext,
|
|
22
|
+
fn: () => Promise<T>,
|
|
23
|
+
): Promise<T> {
|
|
24
|
+
return turnStorage.run(ctx, fn);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns the current turn context, or undefined if not inside {@link runWithTurnContext}.
|
|
29
|
+
*/
|
|
30
|
+
export function getTurnContext(): OpenPawTurnContext | undefined {
|
|
31
|
+
return turnStorage.getStore();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether the filesystem/shell sandbox is restricted for this turn. Defaults to true when unset.
|
|
36
|
+
*/
|
|
37
|
+
export function isSandboxRestricted(): boolean {
|
|
38
|
+
return getTurnContext()?.sandboxRestricted ?? true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Current chat surface for system prompt hints. Defaults to `cli` when not in a turn.
|
|
43
|
+
*/
|
|
44
|
+
export function getTurnSurface(): OpenPawSurface {
|
|
45
|
+
return getTurnContext()?.surface ?? "cli";
|
|
46
|
+
}
|
package/agent/types.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opaque session identifier, e.g. `telegram:12345`, `tui:main`, or `cli:main`.
|
|
3
|
+
*/
|
|
4
|
+
export type SessionId = string;
|
|
5
|
+
|
|
6
|
+
/** Chat surface — used for platform-specific system prompt hints. */
|
|
7
|
+
export type OpenPawSurface = "cli" | "telegram";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* High-signal tool lifecycle events from the UI message stream (for channels like Telegram).
|
|
11
|
+
*/
|
|
12
|
+
export type ToolStreamEvent =
|
|
13
|
+
| { type: "tool_input"; toolCallId: string; toolName: string; input: unknown }
|
|
14
|
+
| { type: "tool_output"; toolCallId: string; toolName: string; output: unknown }
|
|
15
|
+
| { type: "tool_error"; toolCallId: string; toolName: string; errorText: string }
|
|
16
|
+
| { type: "tool_denied"; toolCallId: string; toolName: string };
|
|
17
|
+
|
|
18
|
+
export type RunTurnParams = {
|
|
19
|
+
sessionId: SessionId;
|
|
20
|
+
userText: string;
|
|
21
|
+
/**
|
|
22
|
+
* Where the user is chatting from — affects formatting hints in the system prompt.
|
|
23
|
+
* Defaults to `telegram` when `sessionId` starts with `telegram:`, otherwise `cli`.
|
|
24
|
+
*/
|
|
25
|
+
surface?: OpenPawSurface;
|
|
26
|
+
/**
|
|
27
|
+
* When true (default), file_editor paths and bash cwd are workspace-scoped.
|
|
28
|
+
* When false, file_editor may access the broader filesystem and bash uses $HOME as cwd.
|
|
29
|
+
*/
|
|
30
|
+
sandboxRestricted?: boolean;
|
|
31
|
+
/** Called for streamed assistant text tokens (optional). */
|
|
32
|
+
onTextDelta?: (delta: string) => void;
|
|
33
|
+
/** Called for streamed model reasoning tokens when the provider exposes them (optional). */
|
|
34
|
+
onReasoningDelta?: (delta: string) => void;
|
|
35
|
+
/** Tool status for live UI (optional). */
|
|
36
|
+
onToolStatus?: (event: ToolStreamEvent) => void;
|
|
37
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
ensureWorkspaceDirectories,
|
|
6
|
+
getWorkspaceRoot,
|
|
7
|
+
} from "../config/paths";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_AGENTS_MD = `<!-- OpenPaw workspace instructions -->
|
|
10
|
+
# Workspace
|
|
11
|
+
|
|
12
|
+
You are operating in the user's OpenPaw workspace. Follow project conventions and prefer small, focused changes.
|
|
13
|
+
|
|
14
|
+
## How you come across
|
|
15
|
+
|
|
16
|
+
When you reply to the user, speak like a normal person who remembers — not like software citing files. Use "you mentioned", "you told me", "sounds like you're in…" and avoid lines like "your profile says" or naming markdown paths.
|
|
17
|
+
|
|
18
|
+
## Persisting things (for you, not for the user to hear about)
|
|
19
|
+
|
|
20
|
+
Use the \`memory\` tool for facts worth keeping: \`add\` with \`content\` for new items; \`replace\` needs both \`old_text\` and \`content\`. Use the file editor for longer persona or workspace wording when needed. Entries in memory are separated by §.
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_SOUL_MD = `<!-- Assistant persona — fill in with the user's help -->
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_USER_MD = `<!-- Legacy: prefer the memory tool (target user) for durable profile facts -->
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
/** Only this skill is seeded by default; users add more via \`npx skills\` (see find-skills SKILL). */
|
|
30
|
+
const BUNDLED_FIND_SKILLS_DIR = "find-skills";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Shipped skill trees live under \`bundled-skills/\` at the package root (not under \`.agents/skills\` in the repo).
|
|
34
|
+
*/
|
|
35
|
+
const BUNDLED_SKILLS_ROOT = "bundled-skills";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Path to the shipped \`find-skills\` folder (\`SKILL.md\` + any assets), from \`bundled-skills/find-skills\`.
|
|
39
|
+
*/
|
|
40
|
+
function bundledFindSkillsSourceDir(): string | null {
|
|
41
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
const candidate = join(moduleDir, "..", BUNDLED_SKILLS_ROOT, BUNDLED_FIND_SKILLS_DIR);
|
|
43
|
+
return existsSync(candidate) ? candidate : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Seeds \`workspace/.agents/skills/find-skills\` when that folder is missing (onboard / reset).
|
|
48
|
+
* Other skills are installed later into the same \`skills\` directory by the user or agent.
|
|
49
|
+
*/
|
|
50
|
+
function seedBundledFindSkillsIfAbsent(workspaceRoot: string): void {
|
|
51
|
+
const src = bundledFindSkillsSourceDir();
|
|
52
|
+
if (!src) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const dest = join(workspaceRoot, ".agents", "skills", BUNDLED_FIND_SKILLS_DIR);
|
|
56
|
+
if (existsSync(dest)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
mkdirSync(join(workspaceRoot, ".agents", "skills"), { recursive: true });
|
|
60
|
+
cpSync(src, dest, { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Creates \`~/.openpaw/workspace\`, \`sessions/\`, \`memories/\`, default markdown files if absent,
|
|
65
|
+
* and seeds the default \`find-skills\` skill under \`.agents/skills/\` when absent.
|
|
66
|
+
*/
|
|
67
|
+
export function ensureWorkspaceLayout(): void {
|
|
68
|
+
ensureWorkspaceDirectories();
|
|
69
|
+
const root = getWorkspaceRoot();
|
|
70
|
+
const memoriesDir = join(root, "memories");
|
|
71
|
+
if (!existsSync(memoriesDir)) {
|
|
72
|
+
mkdirSync(memoriesDir, { recursive: true });
|
|
73
|
+
}
|
|
74
|
+
seedBundledFindSkillsIfAbsent(root);
|
|
75
|
+
const files: { name: string; content: string }[] = [
|
|
76
|
+
{ name: "agents.md", content: DEFAULT_AGENTS_MD },
|
|
77
|
+
{ name: "soul.md", content: DEFAULT_SOUL_MD },
|
|
78
|
+
{ name: "user.md", content: DEFAULT_USER_MD },
|
|
79
|
+
];
|
|
80
|
+
for (const { name, content } of files) {
|
|
81
|
+
const p = join(root, name);
|
|
82
|
+
if (!existsSync(p)) {
|
|
83
|
+
writeFileSync(p, content, "utf8");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Deletes the entire workspace directory (sessions, markdown, and any other files), then
|
|
90
|
+
* recreates it with the same defaults as a fresh onboarding run.
|
|
91
|
+
*/
|
|
92
|
+
export function resetWorkspaceToOnboardingDefaults(): void {
|
|
93
|
+
const root = getWorkspaceRoot();
|
|
94
|
+
if (existsSync(root)) {
|
|
95
|
+
rmSync(root, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
ensureWorkspaceLayout();
|
|
98
|
+
}
|