@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,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded curated memory: MEMORY.md (agent notes) and USER.md (user profile facts).
|
|
3
|
+
* Frozen snapshot at load for system prompt; mutations persist to disk and return live state in tool results.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
|
|
17
|
+
/** Same delimiter as Hermes memory_tool — entries may be multiline. */
|
|
18
|
+
export const ENTRY_DELIMITER = "\n§\n";
|
|
19
|
+
|
|
20
|
+
const MEMORY_CHAR_LIMIT = 2200;
|
|
21
|
+
const USER_CHAR_LIMIT = 1375;
|
|
22
|
+
|
|
23
|
+
const MEMORY_THREAT_PATTERNS: [RegExp, string][] = [
|
|
24
|
+
[/ignore\s+(previous|all|above|prior)\s+instructions/i, "prompt_injection"],
|
|
25
|
+
[/you\s+are\s+now\s+/i, "role_hijack"],
|
|
26
|
+
[/do\s+not\s+tell\s+the\s+user/i, "deception_hide"],
|
|
27
|
+
[/system\s+prompt\s+override/i, "sys_prompt_override"],
|
|
28
|
+
[/disregard\s+(your|all|any)\s+(instructions|rules|guidelines)/i, "disregard_rules"],
|
|
29
|
+
[
|
|
30
|
+
/act\s+as\s+(if|though)\s+you\s+(have\s+no|don't\s+have)\s+(restrictions|limits|rules)/i,
|
|
31
|
+
"bypass_restrictions",
|
|
32
|
+
],
|
|
33
|
+
[/curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i, "exfil_curl"],
|
|
34
|
+
[/wget\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i, "exfil_wget"],
|
|
35
|
+
[/cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass|\.npmrc|\.pypirc)/i, "read_secrets"],
|
|
36
|
+
[/authorized_keys/i, "ssh_backdoor"],
|
|
37
|
+
[/\$HOME\/\.ssh|~\/\.ssh/i, "ssh_access"],
|
|
38
|
+
[/\$HOME\/\.openpaw\/\.env|~\/\.openpaw\/\.env/i, "openpaw_env"],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const INVISIBLE_CHARS = new Set([
|
|
42
|
+
"\u200b",
|
|
43
|
+
"\u200c",
|
|
44
|
+
"\u200d",
|
|
45
|
+
"\u2060",
|
|
46
|
+
"\ufeff",
|
|
47
|
+
"\u202a",
|
|
48
|
+
"\u202b",
|
|
49
|
+
"\u202c",
|
|
50
|
+
"\u202d",
|
|
51
|
+
"\u202e",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
export type MemoryTarget = "memory" | "user";
|
|
55
|
+
|
|
56
|
+
export type MemoryMutationResult =
|
|
57
|
+
| {
|
|
58
|
+
success: true;
|
|
59
|
+
target: MemoryTarget;
|
|
60
|
+
entries: string[];
|
|
61
|
+
usage: string;
|
|
62
|
+
entry_count: number;
|
|
63
|
+
/** Present for add/replace/remove confirmations. */
|
|
64
|
+
message?: string;
|
|
65
|
+
}
|
|
66
|
+
| {
|
|
67
|
+
success: false;
|
|
68
|
+
error: string;
|
|
69
|
+
matches?: string[];
|
|
70
|
+
current_entries?: string[];
|
|
71
|
+
usage?: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Returns an error string if content should be blocked for injection/exfil patterns.
|
|
76
|
+
*/
|
|
77
|
+
export function scanMemoryContent(content: string): string | null {
|
|
78
|
+
for (const char of INVISIBLE_CHARS) {
|
|
79
|
+
if (content.includes(char)) {
|
|
80
|
+
return `Blocked: content contains invisible unicode character U+${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")} (possible injection).`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const [re, pid] of MEMORY_THREAT_PATTERNS) {
|
|
85
|
+
if (re.test(content)) {
|
|
86
|
+
return `Blocked: content matches threat pattern '${pid}'. Memory entries are injected into the system prompt and must not contain injection or exfiltration payloads.`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function splitEntries(raw: string): string[] {
|
|
94
|
+
if (!raw.trim()) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
return raw
|
|
98
|
+
.split(ENTRY_DELIMITER)
|
|
99
|
+
.map((e) => e.trim())
|
|
100
|
+
.filter(Boolean);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Writes UTF-8 content atomically: temp file in the same directory, then rename.
|
|
105
|
+
*/
|
|
106
|
+
function writeFileSyncAtomic(path: string, content: string): void {
|
|
107
|
+
const dir = dirname(path);
|
|
108
|
+
mkdirSync(dir, { recursive: true });
|
|
109
|
+
const tmp = join(dir, `.mem_${randomBytes(8).toString("hex")}.tmp`);
|
|
110
|
+
try {
|
|
111
|
+
writeFileSync(tmp, content, "utf-8");
|
|
112
|
+
renameSync(tmp, path);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
try {
|
|
115
|
+
unlinkSync(tmp);
|
|
116
|
+
} catch {
|
|
117
|
+
// ignore
|
|
118
|
+
}
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Persists curated memory files under `workspaceRoot/memories/`.
|
|
125
|
+
*/
|
|
126
|
+
export class MemoryStore {
|
|
127
|
+
memoryEntries: string[] = [];
|
|
128
|
+
userEntries: string[] = [];
|
|
129
|
+
private readonly memoryCharLimit = MEMORY_CHAR_LIMIT;
|
|
130
|
+
private readonly userCharLimit = USER_CHAR_LIMIT;
|
|
131
|
+
private readonly memoryPath: string;
|
|
132
|
+
private readonly userPath: string;
|
|
133
|
+
/** Frozen at {@link loadFromDisk} for system prompt injection. */
|
|
134
|
+
private systemPromptSnapshot: { memory: string; user: string } = { memory: "", user: "" };
|
|
135
|
+
private chain: Promise<void> = Promise.resolve();
|
|
136
|
+
|
|
137
|
+
constructor(workspaceRoot: string) {
|
|
138
|
+
const dir = join(workspaceRoot, "memories");
|
|
139
|
+
this.memoryPath = join(dir, "MEMORY.md");
|
|
140
|
+
this.userPath = join(dir, "USER.md");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Serialize mutations so concurrent tool calls do not corrupt entries.
|
|
145
|
+
*/
|
|
146
|
+
private enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
|
147
|
+
const run = this.chain.then(fn);
|
|
148
|
+
this.chain = run.then(
|
|
149
|
+
() => {},
|
|
150
|
+
() => {},
|
|
151
|
+
);
|
|
152
|
+
return run;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Loads entries from disk and captures the frozen system-prompt snapshot.
|
|
157
|
+
*/
|
|
158
|
+
loadFromDisk(): void {
|
|
159
|
+
mkdirSync(join(this.memoryPath, ".."), { recursive: true });
|
|
160
|
+
|
|
161
|
+
this.memoryEntries = this.dedupe(this.readFileEntries(this.memoryPath));
|
|
162
|
+
this.userEntries = this.dedupe(this.readFileEntries(this.userPath));
|
|
163
|
+
|
|
164
|
+
this.systemPromptSnapshot = {
|
|
165
|
+
memory: this.renderBlock("memory", this.memoryEntries),
|
|
166
|
+
user: this.renderBlock("user", this.userEntries),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Returns the frozen snapshot for system prompt injection (not live mid-session edits).
|
|
172
|
+
*/
|
|
173
|
+
formatForSystemPrompt(target: MemoryTarget): string | null {
|
|
174
|
+
const block = target === "user" ? this.systemPromptSnapshot.user : this.systemPromptSnapshot.memory;
|
|
175
|
+
return block || null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Appends a new entry after validation and capacity checks.
|
|
180
|
+
*/
|
|
181
|
+
async add(target: MemoryTarget, content: string): Promise<MemoryMutationResult> {
|
|
182
|
+
const trimmed = content.trim();
|
|
183
|
+
if (!trimmed) {
|
|
184
|
+
return { success: false, error: "Content cannot be empty." };
|
|
185
|
+
}
|
|
186
|
+
const scanError = scanMemoryContent(trimmed);
|
|
187
|
+
if (scanError) {
|
|
188
|
+
return { success: false, error: scanError };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return this.enqueue(async () => {
|
|
192
|
+
await this.reloadTarget(target);
|
|
193
|
+
const entries = this.entriesFor(target);
|
|
194
|
+
const limit = this.limitFor(target);
|
|
195
|
+
|
|
196
|
+
if (entries.includes(trimmed)) {
|
|
197
|
+
return this.successResponse(target, "Entry already exists (no duplicate added).");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const next = [...entries, trimmed];
|
|
201
|
+
if (this.joinedLength(next) > limit) {
|
|
202
|
+
const current = this.charCount(target);
|
|
203
|
+
return {
|
|
204
|
+
success: false,
|
|
205
|
+
error: `Memory at ${current.toLocaleString()}/${limit.toLocaleString()} chars. Adding this entry (${trimmed.length} chars) would exceed the limit. Replace or remove existing entries first.`,
|
|
206
|
+
current_entries: entries,
|
|
207
|
+
usage: `${current.toLocaleString()}/${limit.toLocaleString()}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
this.setEntries(target, next);
|
|
212
|
+
this.saveToDisk(target);
|
|
213
|
+
return this.successResponse(target, "Entry added.");
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Replaces the first entry containing `oldText` with `newContent`.
|
|
219
|
+
*/
|
|
220
|
+
async replace(target: MemoryTarget, oldText: string, newContent: string): Promise<MemoryMutationResult> {
|
|
221
|
+
const ot = oldText.trim();
|
|
222
|
+
const nc = newContent.trim();
|
|
223
|
+
if (!ot) {
|
|
224
|
+
return { success: false, error: "old_text cannot be empty." };
|
|
225
|
+
}
|
|
226
|
+
if (!nc) {
|
|
227
|
+
return { success: false, error: "new_content cannot be empty. Use 'remove' to delete entries." };
|
|
228
|
+
}
|
|
229
|
+
const scanError = scanMemoryContent(nc);
|
|
230
|
+
if (scanError) {
|
|
231
|
+
return { success: false, error: scanError };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return this.enqueue(async () => {
|
|
235
|
+
await this.reloadTarget(target);
|
|
236
|
+
const entries = this.entriesFor(target);
|
|
237
|
+
const matches = entries.map((e, i) => ({ i, e })).filter(({ e }) => e.includes(ot));
|
|
238
|
+
|
|
239
|
+
if (matches.length === 0) {
|
|
240
|
+
return { success: false, error: `No entry matched '${ot}'.` };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (matches.length > 1) {
|
|
244
|
+
const uniqueTexts = new Set(matches.map((m) => m.e));
|
|
245
|
+
if (uniqueTexts.size > 1) {
|
|
246
|
+
const previews = matches.map((m) =>
|
|
247
|
+
m.e.length > 80 ? `${m.e.slice(0, 80)}...` : m.e,
|
|
248
|
+
);
|
|
249
|
+
return {
|
|
250
|
+
success: false,
|
|
251
|
+
error: `Multiple entries matched '${ot}'. Be more specific.`,
|
|
252
|
+
matches: previews,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const idx = matches[0]!.i;
|
|
258
|
+
const limit = this.limitFor(target);
|
|
259
|
+
const test = [...entries];
|
|
260
|
+
test[idx] = nc;
|
|
261
|
+
|
|
262
|
+
if (this.joinedLength(test) > limit) {
|
|
263
|
+
return {
|
|
264
|
+
success: false,
|
|
265
|
+
error: `Replacement would put memory at ${this.joinedLength(test).toLocaleString()}/${limit.toLocaleString()} chars. Shorten the new content or remove other entries first.`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
entries[idx] = nc;
|
|
270
|
+
this.setEntries(target, entries);
|
|
271
|
+
this.saveToDisk(target);
|
|
272
|
+
return this.successResponse(target, "Entry replaced.");
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Removes the first entry containing `oldText`.
|
|
278
|
+
*/
|
|
279
|
+
async remove(target: MemoryTarget, oldText: string): Promise<MemoryMutationResult> {
|
|
280
|
+
const ot = oldText.trim();
|
|
281
|
+
if (!ot) {
|
|
282
|
+
return { success: false, error: "old_text cannot be empty." };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return this.enqueue(async () => {
|
|
286
|
+
await this.reloadTarget(target);
|
|
287
|
+
const entries = this.entriesFor(target);
|
|
288
|
+
const matches = entries.map((e, i) => ({ i, e })).filter(({ e }) => e.includes(ot));
|
|
289
|
+
|
|
290
|
+
if (matches.length === 0) {
|
|
291
|
+
return { success: false, error: `No entry matched '${ot}'.` };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (matches.length > 1) {
|
|
295
|
+
const uniqueTexts = new Set(matches.map((m) => m.e));
|
|
296
|
+
if (uniqueTexts.size > 1) {
|
|
297
|
+
const previews = matches.map((m) =>
|
|
298
|
+
m.e.length > 80 ? `${m.e.slice(0, 80)}...` : m.e,
|
|
299
|
+
);
|
|
300
|
+
return {
|
|
301
|
+
success: false,
|
|
302
|
+
error: `Multiple entries matched '${ot}'. Be more specific.`,
|
|
303
|
+
matches: previews,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const idx = matches[0]!.i;
|
|
309
|
+
entries.splice(idx, 1);
|
|
310
|
+
this.setEntries(target, entries);
|
|
311
|
+
this.saveToDisk(target);
|
|
312
|
+
return this.successResponse(target, "Entry removed.");
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private dedupe(entries: string[]): string[] {
|
|
317
|
+
return [...new Set(entries)];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private entriesFor(target: MemoryTarget): string[] {
|
|
321
|
+
return target === "user" ? this.userEntries : this.memoryEntries;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private setEntries(target: MemoryTarget, entries: string[]): void {
|
|
325
|
+
if (target === "user") {
|
|
326
|
+
this.userEntries = entries;
|
|
327
|
+
} else {
|
|
328
|
+
this.memoryEntries = entries;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private limitFor(target: MemoryTarget): number {
|
|
333
|
+
return target === "user" ? this.userCharLimit : this.memoryCharLimit;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private charCount(target: MemoryTarget): number {
|
|
337
|
+
const entries = this.entriesFor(target);
|
|
338
|
+
if (entries.length === 0) {
|
|
339
|
+
return 0;
|
|
340
|
+
}
|
|
341
|
+
return this.joinedLength(entries);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private joinedLength(entries: string[]): number {
|
|
345
|
+
return entries.length === 0 ? 0 : entries.join(ENTRY_DELIMITER).length;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private async reloadTarget(target: MemoryTarget): Promise<void> {
|
|
349
|
+
const path = target === "user" ? this.userPath : this.memoryPath;
|
|
350
|
+
const fresh = this.dedupe(this.readFileEntries(path));
|
|
351
|
+
this.setEntries(target, fresh);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private readFileEntries(path: string): string[] {
|
|
355
|
+
if (!existsSync(path)) {
|
|
356
|
+
return [];
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
const raw = readFileSync(path, "utf-8");
|
|
360
|
+
return splitEntries(raw);
|
|
361
|
+
} catch {
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private saveToDisk(target: MemoryTarget): void {
|
|
367
|
+
const path = target === "user" ? this.userPath : this.memoryPath;
|
|
368
|
+
const entries = this.entriesFor(target);
|
|
369
|
+
const content = entries.length === 0 ? "" : entries.join(ENTRY_DELIMITER);
|
|
370
|
+
writeFileSyncAtomic(path, content);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private successResponse(target: MemoryTarget, message?: string): MemoryMutationResult {
|
|
374
|
+
const entries = this.entriesFor(target);
|
|
375
|
+
const current = this.charCount(target);
|
|
376
|
+
const limit = this.limitFor(target);
|
|
377
|
+
const pct = limit > 0 ? Math.floor((current / limit) * 100) : 0;
|
|
378
|
+
const out: MemoryMutationResult = {
|
|
379
|
+
success: true,
|
|
380
|
+
target,
|
|
381
|
+
entries,
|
|
382
|
+
usage: `${pct}% — ${current.toLocaleString()}/${limit.toLocaleString()} chars`,
|
|
383
|
+
entry_count: entries.length,
|
|
384
|
+
...(message ? { message } : {}),
|
|
385
|
+
};
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private renderBlock(target: MemoryTarget, entries: string[]): string {
|
|
390
|
+
if (entries.length === 0) {
|
|
391
|
+
return "";
|
|
392
|
+
}
|
|
393
|
+
const limit = this.limitFor(target);
|
|
394
|
+
const content = entries.join(ENTRY_DELIMITER);
|
|
395
|
+
const current = content.length;
|
|
396
|
+
const pct = limit > 0 ? Math.floor((current / limit) * 100) : 0;
|
|
397
|
+
const header =
|
|
398
|
+
target === "user"
|
|
399
|
+
? `USER PROFILE (who the user is) [${pct}% — ${current.toLocaleString()}/${limit.toLocaleString()} chars]`
|
|
400
|
+
: `MEMORY (your personal notes) [${pct}% — ${current.toLocaleString()}/${limit.toLocaleString()} chars]`;
|
|
401
|
+
const separator = "═".repeat(46);
|
|
402
|
+
return `${separator}\n${header}\n${separator}\n${content}`;
|
|
403
|
+
}
|
|
404
|
+
}
|
package/agent/model.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
2
|
+
import type { OpenPawConfig } from "../config/types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OpenAI-compatible chat model from persisted {@link OpenPawConfig}.
|
|
6
|
+
*/
|
|
7
|
+
export function createLanguageModel(config: OpenPawConfig) {
|
|
8
|
+
const provider = createOpenAICompatible({
|
|
9
|
+
baseURL: config.provider.baseUrl,
|
|
10
|
+
name: "openpaw",
|
|
11
|
+
apiKey: config.provider.apiKey,
|
|
12
|
+
});
|
|
13
|
+
return provider.chatModel(config.provider.model);
|
|
14
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { Personality } from "../config/types";
|
|
3
|
+
import type { SkillMetadata } from "./skills/discover";
|
|
4
|
+
import type { OpenPawSurface } from "./types";
|
|
5
|
+
import {
|
|
6
|
+
getPersonalityProse,
|
|
7
|
+
MEMORY_GUIDANCE,
|
|
8
|
+
OPENPAW_IDENTITY,
|
|
9
|
+
PLATFORM_HINTS,
|
|
10
|
+
SESSION_NOTE,
|
|
11
|
+
USER_FACING_VOICE,
|
|
12
|
+
} from "../config/personality-copy";
|
|
13
|
+
import { scanContextContent, truncateContextContent } from "./context-scan";
|
|
14
|
+
import { loadProjectContextFromCwd } from "./prompt-context-files";
|
|
15
|
+
|
|
16
|
+
async function readUtf8(path: string): Promise<string> {
|
|
17
|
+
try {
|
|
18
|
+
const f = Bun.file(path);
|
|
19
|
+
if (!(await f.exists())) {
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
return await f.text();
|
|
23
|
+
} catch {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type BuildSystemPromptOptions = {
|
|
29
|
+
workspacePath: string;
|
|
30
|
+
personality: Personality;
|
|
31
|
+
/** Where the user is chatting from — affects formatting hints. */
|
|
32
|
+
surface: OpenPawSurface;
|
|
33
|
+
/** Frozen blocks from MemoryStore (may be null if empty). */
|
|
34
|
+
memoryUserBlock: string | null;
|
|
35
|
+
memoryAgentBlock: string | null;
|
|
36
|
+
/** Discovered Agent Skills (name + description only until `load_skill` is called). */
|
|
37
|
+
skills?: SkillMetadata[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const BOOTSTRAP = `## Onboarding and workspace (internal — do not expose filenames to the user)
|
|
41
|
+
|
|
42
|
+
If persona/voice for this install is missing or vague, ask in plain language how they want you to sound; then persist it with the file editor (workspace persona file).
|
|
43
|
+
|
|
44
|
+
For durable facts and notes, use the \`memory\` tool: new items → \`add\` + \`content\`; corrections → \`replace\` with \`old_text\` + \`content\`. Offer reminders in human terms (\"Want me to remember that?\") — never say you're writing to a specific file.
|
|
45
|
+
|
|
46
|
+
For file edits: always \`view\` before \`str_replace\`; \`old_str\` must match exactly once (including whitespace).`;
|
|
47
|
+
|
|
48
|
+
const STATIC_TOOL_RULES = `## Tools overview
|
|
49
|
+
|
|
50
|
+
- **bash**: shell commands (cwd is the workspace when sandbox is on, or user home when sandbox is off).
|
|
51
|
+
- **file_editor**: view before str_replace; \`old_str\` must match exactly once; create, insert, delete_lines, undo_edit as needed.
|
|
52
|
+
- **list_dir**: list directories under the sandbox (including under loaded skill directories).
|
|
53
|
+
- **load_skill**: load full markdown instructions for a named skill; use \`skillDirectory\` from the result for bundled paths (\`references/\`, \`scripts/\`, etc.).
|
|
54
|
+
- **memory**: persistent facts (\`user\` / \`memory\` targets). \`add\`: \`content\`. \`replace\`: \`old_text\` + \`content\`. \`remove\`: \`old_text\`. (Live state in tool results; frozen snapshot in system prompt.) When discussing with the user, see \"How to talk with the user\" — do not name these mechanisms.`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Assembles the Skills section for the system prompt: short list and when to call `load_skill`,
|
|
58
|
+
* or onboarding text if nothing was discovered.
|
|
59
|
+
*/
|
|
60
|
+
function buildSkillsSection(skills: SkillMetadata[] | undefined): string {
|
|
61
|
+
if (skills?.length) {
|
|
62
|
+
const lines = skills.map((s) => `- ${s.name}: ${s.description}`).join("\n");
|
|
63
|
+
return `## Skills
|
|
64
|
+
|
|
65
|
+
Use the \`load_skill\` tool when the user's request matches a skill below. Full instructions load on demand.
|
|
66
|
+
|
|
67
|
+
Available skills:
|
|
68
|
+
${lines}`;
|
|
69
|
+
}
|
|
70
|
+
return `## Skills
|
|
71
|
+
|
|
72
|
+
No skills discovered. Optional: add skill folders containing \`SKILL.md\` under \`.agents/skills\` in the workspace (or \`~/.config/agent/skills\`).`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Assembles the dynamic system prompt: identity, guidance, platform, workspace files, frozen memory, optional project context.
|
|
77
|
+
*/
|
|
78
|
+
export async function buildSystemPrompt(options: BuildSystemPromptOptions): Promise<string> {
|
|
79
|
+
const {
|
|
80
|
+
workspacePath,
|
|
81
|
+
personality,
|
|
82
|
+
surface,
|
|
83
|
+
memoryUserBlock,
|
|
84
|
+
memoryAgentBlock,
|
|
85
|
+
skills,
|
|
86
|
+
} = options;
|
|
87
|
+
|
|
88
|
+
const agentsRaw = (await readUtf8(join(workspacePath, "agents.md"))).trim();
|
|
89
|
+
const soulRaw = (await readUtf8(join(workspacePath, "soul.md"))).trim();
|
|
90
|
+
|
|
91
|
+
const agents = truncateContextContent(
|
|
92
|
+
scanContextContent(agentsRaw || "(empty)", "agents.md"),
|
|
93
|
+
"agents.md",
|
|
94
|
+
);
|
|
95
|
+
const soul = truncateContextContent(
|
|
96
|
+
scanContextContent(soulRaw || "(empty — consider asking the user)", "soul.md"),
|
|
97
|
+
"soul.md",
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const platformBlock = PLATFORM_HINTS[surface];
|
|
101
|
+
|
|
102
|
+
let memoryInjection = "";
|
|
103
|
+
const mb = memoryAgentBlock
|
|
104
|
+
? truncateContextContent(scanContextContent(memoryAgentBlock, "MEMORY.md"), "MEMORY.md")
|
|
105
|
+
: "";
|
|
106
|
+
const ub = memoryUserBlock
|
|
107
|
+
? truncateContextContent(scanContextContent(memoryUserBlock, "USER.md"), "USER.md")
|
|
108
|
+
: "";
|
|
109
|
+
if (mb) {
|
|
110
|
+
memoryInjection += `\n## What you know already — agent notes (internal)\n\nUse naturally in replies; do not quote section titles or imply you \"read a file\".\n\n${mb}\n`;
|
|
111
|
+
}
|
|
112
|
+
if (ub) {
|
|
113
|
+
memoryInjection += `\n## What you know already — about the user (internal)\n\nUse naturally (\"You told me…\", \"You're in…\"); never say you saw this in a profile or markdown file.\n\n${ub}\n`;
|
|
114
|
+
}
|
|
115
|
+
if (!mb && !ub) {
|
|
116
|
+
memoryInjection = `\n## Durable facts\n\nNothing stored yet — use the \`memory\` tool when the user wants something remembered.\n`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const projectContext = loadProjectContextFromCwd(process.cwd());
|
|
120
|
+
const projectSection = projectContext
|
|
121
|
+
? `\n## Additional project context (from current working directory)\n\n${projectContext}\n`
|
|
122
|
+
: "";
|
|
123
|
+
|
|
124
|
+
const sections = [
|
|
125
|
+
`# Identity\n${OPENPAW_IDENTITY}`,
|
|
126
|
+
`# Personality\n${getPersonalityProse(personality)}`,
|
|
127
|
+
`# How to talk with the user\n${USER_FACING_VOICE}`,
|
|
128
|
+
`# Memory and persistence\n${MEMORY_GUIDANCE}\n\n${SESSION_NOTE}`,
|
|
129
|
+
`# Your environment\n${platformBlock}`,
|
|
130
|
+
`# Workspace (OpenPaw home)\n## Workspace rules\n\n${agents}\n\n## Persona / voice (how you should sound)\n\n${soul}\n`,
|
|
131
|
+
memoryInjection.trimEnd(),
|
|
132
|
+
projectSection.trimEnd(),
|
|
133
|
+
buildSkillsSection(skills),
|
|
134
|
+
BOOTSTRAP,
|
|
135
|
+
STATIC_TOOL_RULES,
|
|
136
|
+
].filter(Boolean);
|
|
137
|
+
|
|
138
|
+
return sections.join("\n\n");
|
|
139
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional project-context discovery from the process cwd (Hermes-style priority chain).
|
|
3
|
+
* OpenPaw workspace files under ~/.openpaw/workspace are handled separately in prompt-builder.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import { scanContextContent, truncateContextContent } from "./context-scan";
|
|
9
|
+
|
|
10
|
+
const OPENPAW_MD_NAMES = [".openpaw.md", "OPENPAW.md"] as const;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Walks from start through parents until a `.git` directory is found or filesystem root.
|
|
14
|
+
*/
|
|
15
|
+
export function findGitRoot(start: string): string | null {
|
|
16
|
+
let current = resolve(start);
|
|
17
|
+
for (;;) {
|
|
18
|
+
if (existsSync(join(current, ".git"))) {
|
|
19
|
+
return current;
|
|
20
|
+
}
|
|
21
|
+
const parent = resolve(current, "..");
|
|
22
|
+
if (parent === current) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
current = parent;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Finds the nearest `.openpaw.md` or `OPENPAW.md` from cwd up to (and including) git root.
|
|
31
|
+
*/
|
|
32
|
+
export function findOpenpawMd(cwd: string): string | null {
|
|
33
|
+
const stopAt = findGitRoot(cwd);
|
|
34
|
+
let current = resolve(cwd);
|
|
35
|
+
const seen = new Set<string>();
|
|
36
|
+
|
|
37
|
+
for (;;) {
|
|
38
|
+
if (seen.has(current)) {
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
seen.add(current);
|
|
42
|
+
|
|
43
|
+
for (const name of OPENPAW_MD_NAMES) {
|
|
44
|
+
const candidate = join(current, name);
|
|
45
|
+
if (existsSync(candidate)) {
|
|
46
|
+
return candidate;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (stopAt !== null && current === stopAt) {
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const parent = resolve(current, "..");
|
|
55
|
+
if (parent === current) {
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
current = parent;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Strips optional YAML frontmatter (`---` delimited) from markdown; returns body only.
|
|
66
|
+
*/
|
|
67
|
+
export function stripYamlFrontmatter(content: string): string {
|
|
68
|
+
if (!content.startsWith("---")) {
|
|
69
|
+
return content;
|
|
70
|
+
}
|
|
71
|
+
const end = content.indexOf("\n---", 3);
|
|
72
|
+
if (end === -1) {
|
|
73
|
+
return content;
|
|
74
|
+
}
|
|
75
|
+
const body = content.slice(end + 4).replace(/^\n+/, "");
|
|
76
|
+
return body || content;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readIfExists(path: string): string | null {
|
|
80
|
+
try {
|
|
81
|
+
if (!existsSync(path)) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return readFileSync(path, "utf-8").trim() || null;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Loads a single prioritized project-context block from cwd (not the OpenPaw home workspace).
|
|
92
|
+
* Priority: `.openpaw.md` / `OPENPAW.md` (walk to git root), else `AGENTS.md` / `agents.md` in cwd only,
|
|
93
|
+
* else `CLAUDE.md` / `claude.md`, else `.cursorrules` and `.cursor/rules/*.mdc` in cwd only.
|
|
94
|
+
*/
|
|
95
|
+
export function loadProjectContextFromCwd(cwd: string): string {
|
|
96
|
+
const openpawPath = findOpenpawMd(cwd);
|
|
97
|
+
if (openpawPath) {
|
|
98
|
+
const raw = readIfExists(openpawPath);
|
|
99
|
+
if (raw) {
|
|
100
|
+
const rel = openpawPath.includes("/") ? openpawPath.split("/").slice(-2).join("/") : openpawPath;
|
|
101
|
+
let body = stripYamlFrontmatter(raw);
|
|
102
|
+
body = scanContextContent(body, rel);
|
|
103
|
+
return truncateContextContent(`## ${rel}\n\n${body}`, rel);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const name of ["AGENTS.md", "agents.md"] as const) {
|
|
108
|
+
const p = join(cwd, name);
|
|
109
|
+
const raw = readIfExists(p);
|
|
110
|
+
if (raw) {
|
|
111
|
+
const body = scanContextContent(raw, name);
|
|
112
|
+
return truncateContextContent(`## ${name}\n\n${body}`, name);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const name of ["CLAUDE.md", "claude.md"] as const) {
|
|
117
|
+
const p = join(cwd, name);
|
|
118
|
+
const raw = readIfExists(p);
|
|
119
|
+
if (raw) {
|
|
120
|
+
const body = scanContextContent(raw, name);
|
|
121
|
+
return truncateContextContent(`## ${name}\n\n${body}`, name);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const cursorParts: string[] = [];
|
|
126
|
+
const cursorrules = join(cwd, ".cursorrules");
|
|
127
|
+
const cr = readIfExists(cursorrules);
|
|
128
|
+
if (cr) {
|
|
129
|
+
cursorParts.push(`## .cursorrules\n\n${scanContextContent(cr, ".cursorrules")}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const rulesDir = join(cwd, ".cursor", "rules");
|
|
133
|
+
if (existsSync(rulesDir)) {
|
|
134
|
+
const entries = readdirSync(rulesDir).filter((f) => f.endsWith(".mdc")).sort();
|
|
135
|
+
for (const f of entries) {
|
|
136
|
+
const p = join(rulesDir, f);
|
|
137
|
+
const raw = readIfExists(p);
|
|
138
|
+
if (raw) {
|
|
139
|
+
const label = `.cursor/rules/${f}`;
|
|
140
|
+
cursorParts.push(`## ${label}\n\n${scanContextContent(raw, label)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (cursorParts.length === 0) {
|
|
146
|
+
return "";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const combined = cursorParts.join("\n\n");
|
|
150
|
+
return truncateContextContent(combined, ".cursorrules");
|
|
151
|
+
}
|