clankie 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.
@@ -0,0 +1,564 @@
1
+ /**
2
+ * clankie persona extension
3
+ *
4
+ * Loads personality and instructions from ~/.clankie/personas/<name>/ flat files,
5
+ * and manages persistent memory via SQLite + FTS5.
6
+ *
7
+ * Flat files (user-editable persona config):
8
+ *
9
+ * ~/.clankie/personas/<name>/
10
+ * ├── identity.md — Who the persona is: name, voice, personality traits
11
+ * ├── instructions.md — Standing orders: how to behave, what to avoid
12
+ * ├── knowledge.md — Facts about the user: preferences, context, environment
13
+ * └── persona.json — Optional: { "model": "provider/model" }
14
+ *
15
+ * SQLite memory (auto-managed by tools):
16
+ *
17
+ * ~/.clankie/memory.db — FTS5-indexed persistent memory (shared across all personas)
18
+ * categories: core (permanent facts), daily (session notes), conversation (chat context)
19
+ *
20
+ * Memory model (inspired by nullclaw):
21
+ * - Per-message recall: before each agent turn, relevant memories are searched
22
+ * and injected into context. The agent gets memories without asking.
23
+ * - memory_store: persist facts with key/content/category (upsert semantics)
24
+ * - memory_recall: search memories by natural language query (FTS5)
25
+ * - memory_forget: delete a memory by key
26
+ * - Hygiene: old daily/conversation entries auto-pruned
27
+ *
28
+ * The extension also provides:
29
+ * - persona_info: inspect loaded persona files + memory stats
30
+ * - persona_edit: read/write persona flat files
31
+ */
32
+
33
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
34
+ import { homedir } from "node:os";
35
+ import { join } from "node:path";
36
+ import { StringEnum } from "@mariozechner/pi-ai";
37
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
38
+ import { Type } from "@sinclair/typebox";
39
+ import { type MemoryCategory, MemoryDB } from "./memory-db.ts";
40
+
41
+ // ─── Persona file definitions ──────────────────────────────────────────────────
42
+
43
+ interface PersonaFile {
44
+ filename: string;
45
+ title: string;
46
+ description: string;
47
+ }
48
+
49
+ const PERSONA_FILES: PersonaFile[] = [
50
+ {
51
+ filename: "identity.md",
52
+ title: "Identity",
53
+ description: "Who you are: name, voice, personality traits",
54
+ },
55
+ {
56
+ filename: "instructions.md",
57
+ title: "Standing Instructions",
58
+ description: "How to behave, what to do and avoid",
59
+ },
60
+ {
61
+ filename: "knowledge.md",
62
+ title: "User Knowledge",
63
+ description: "Facts about the user: preferences, context, environment",
64
+ },
65
+ ];
66
+
67
+ // ─── Extension factory ────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Create a persona extension configured for a specific persona.
71
+ * @param personaName Name of the persona to load (default: "default")
72
+ */
73
+ export function createPersonaExtension(personaName: string = "default") {
74
+ return function personaExtension(pi: ExtensionAPI) {
75
+ const personaDir = join(homedir(), ".clankie", "personas", personaName);
76
+ const memoryDbPath = join(homedir(), ".clankie", "memory.db");
77
+
78
+ /** Loaded persona file sections (refreshed on session_start) */
79
+ let sections: { title: string; content: string }[] = [];
80
+
81
+ /** Memory database (initialized once, reused across sessions) */
82
+ let memoryDb: MemoryDB | null = null;
83
+
84
+ function getMemoryDb(): MemoryDB {
85
+ if (!memoryDb) {
86
+ memoryDb = new MemoryDB(memoryDbPath);
87
+ }
88
+ return memoryDb;
89
+ }
90
+
91
+ // ─── Load persona files + run hygiene on session start ────────────────
92
+
93
+ pi.on("session_start", async (_event, _ctx) => {
94
+ sections = [];
95
+
96
+ if (existsSync(personaDir)) {
97
+ for (const pf of PERSONA_FILES) {
98
+ const filePath = join(personaDir, pf.filename);
99
+ if (existsSync(filePath)) {
100
+ try {
101
+ const content = readFileSync(filePath, "utf-8").trim();
102
+ if (content) {
103
+ sections.push({ title: pf.title, content });
104
+ }
105
+ } catch {
106
+ // Skip unreadable files
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ // Run memory hygiene (prune old daily/conversation entries)
113
+ try {
114
+ const db = getMemoryDb();
115
+ const pruned = db.runHygiene();
116
+ if (pruned > 0) {
117
+ console.log(`[persona] Memory hygiene: pruned ${pruned} old entries`);
118
+ }
119
+ } catch {
120
+ // Non-fatal
121
+ }
122
+
123
+ // Log loaded state
124
+ const parts: string[] = sections.map((s) => s.title);
125
+ try {
126
+ const stats = getMemoryDb().stats(personaName);
127
+ if (stats.total > 0) {
128
+ parts.push(`memory (${stats.total} entries)`);
129
+ }
130
+ } catch {
131
+ // Non-fatal
132
+ }
133
+ if (parts.length > 0) {
134
+ console.log(`[persona:${personaName}] Loaded: ${parts.join(", ")}`);
135
+ }
136
+ });
137
+
138
+ // ─── Inject persona + recalled memories into system prompt ────────────
139
+
140
+ pi.on("before_agent_start", async (event) => {
141
+ const parts: string[] = [];
142
+
143
+ // Persona flat files (identity, instructions, knowledge)
144
+ if (sections.length > 0) {
145
+ parts.push(sections.map((s) => `### ${s.title}\n\n${s.content}`).join("\n\n"));
146
+ }
147
+
148
+ // Core memories (both global and persona-specific)
149
+ try {
150
+ const db = getMemoryDb();
151
+ const coreMemories = db.list("core", personaName, 50);
152
+ if (coreMemories.length > 0) {
153
+ const lines = coreMemories.map((m) => {
154
+ const scope = m.persona ? ` [${m.persona}]` : " [global]";
155
+ return `- **${m.key}**${scope}: ${m.content}`;
156
+ });
157
+ parts.push(`### Core Memories\n\nImportant facts persisted across sessions:\n\n${lines.join("\n")}`);
158
+ }
159
+ } catch {
160
+ // Non-fatal
161
+ }
162
+
163
+ if (parts.length === 0) return undefined;
164
+
165
+ return {
166
+ systemPrompt:
167
+ event.systemPrompt +
168
+ `\n\n## Persona\n\nThe following defines who you are and how you should behave. ` +
169
+ `Follow these instructions closely — they come directly from your user.\n\n` +
170
+ parts.join("\n\n"),
171
+ };
172
+ });
173
+
174
+ // ─── memory_store — persist facts to SQLite ───────────────────────────
175
+
176
+ pi.registerTool({
177
+ name: "memory_store",
178
+ label: "Store Memory",
179
+ description:
180
+ "Save a fact, preference, or note to persistent memory. Uses upsert — " +
181
+ "if a memory with the same key already exists for this scope, it will be updated.\n\n" +
182
+ "**Scope:**\n" +
183
+ "- **persona** (default): Stored for this persona only. Use for context specific to this role.\n" +
184
+ "- **global**: Accessible to all personas. Use for universal facts (user's name, timezone, etc.).\n\n" +
185
+ "**Categories:**\n" +
186
+ "- **core**: Permanent facts (user preferences, important context). Always loaded.\n" +
187
+ "- **daily**: Session notes, what happened today. Auto-pruned after 30 days.\n" +
188
+ "- **conversation**: Chat context. Auto-pruned after 7 days.\n\n" +
189
+ "Use 'core' for things worth remembering forever, 'daily' for today's context.\n\n" +
190
+ "Examples:\n" +
191
+ ' key: "user_lang", content: "Prefers TypeScript", category: "core", scope: "persona"\n' +
192
+ ' key: "user_timezone", content: "GMT-3", category: "core", scope: "global"\n' +
193
+ ' key: "today_project", content: "Working on clankie memory", category: "daily", scope: "persona"',
194
+ parameters: Type.Object({
195
+ key: Type.String({
196
+ description:
197
+ "Unique key for this memory. Use snake_case, be descriptive. " +
198
+ 'Examples: "user_name", "preferred_stack", "project_lil_status"',
199
+ }),
200
+ content: Type.String({
201
+ description: "The information to remember. Be concise but complete.",
202
+ }),
203
+ category: Type.Optional(
204
+ StringEnum(["core", "daily", "conversation"] as const, {
205
+ description: "Memory category (default: core)",
206
+ }),
207
+ ),
208
+ scope: Type.Optional(
209
+ StringEnum(["persona", "global"] as const, {
210
+ description: "Memory scope: persona (default) or global",
211
+ }),
212
+ ),
213
+ }),
214
+ async execute(_toolCallId, params) {
215
+ try {
216
+ const db = getMemoryDb();
217
+ const category = (params.category ?? "core") as MemoryCategory;
218
+ const scope = params.scope ?? "persona";
219
+ const persona = scope === "global" ? null : personaName;
220
+
221
+ db.store(params.key, params.content, category, persona);
222
+
223
+ const scopeLabel = scope === "global" ? "global" : `persona:${personaName}`;
224
+ return {
225
+ content: [
226
+ {
227
+ type: "text" as const,
228
+ text: `Stored memory: **${params.key}** (${category}, ${scopeLabel})`,
229
+ },
230
+ ],
231
+ details: { key: params.key, category, scope: scopeLabel },
232
+ };
233
+ } catch (err) {
234
+ return {
235
+ content: [
236
+ {
237
+ type: "text" as const,
238
+ text: `Error storing memory: ${err instanceof Error ? err.message : String(err)}`,
239
+ },
240
+ ],
241
+ details: {},
242
+ isError: true,
243
+ };
244
+ }
245
+ },
246
+ });
247
+
248
+ // ─── memory_recall — search memories via FTS5 ─────────────────────────
249
+
250
+ pi.registerTool({
251
+ name: "memory_recall",
252
+ label: "Recall Memory",
253
+ description:
254
+ "Search persistent memory for relevant facts, preferences, or context. " +
255
+ "Uses full-text search (FTS5) with BM25 ranking — just describe what you're " +
256
+ "looking for in natural language.\n\n" +
257
+ "Use this when:\n" +
258
+ "- The user asks about something you might have stored before\n" +
259
+ "- You need to recall preferences, facts, or context\n" +
260
+ "- You want to check what you know about a topic\n\n" +
261
+ "Memories are also automatically recalled based on user messages, " +
262
+ "so you often don't need to call this explicitly.",
263
+ parameters: Type.Object({
264
+ query: Type.String({
265
+ description:
266
+ "What to search for. Natural language works — e.g. " +
267
+ '"user preferences", "TypeScript project", "meeting notes"',
268
+ }),
269
+ limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
270
+ category: Type.Optional(Type.String({ description: "Filter by category (core, daily, conversation)" })),
271
+ }),
272
+ async execute(_toolCallId, params) {
273
+ try {
274
+ const db = getMemoryDb();
275
+ // Search both persona-specific and global memories
276
+ let entries = db.recall(params.query, personaName, params.limit ?? 5);
277
+
278
+ // Filter by category if specified
279
+ if (params.category) {
280
+ entries = entries.filter((e) => e.category === params.category);
281
+ }
282
+
283
+ if (entries.length === 0) {
284
+ return {
285
+ content: [
286
+ {
287
+ type: "text" as const,
288
+ text: `No memories found matching: "${params.query}"`,
289
+ },
290
+ ],
291
+ details: { query: params.query, found: 0 },
292
+ };
293
+ }
294
+
295
+ const lines = entries.map((e, i) => {
296
+ const scope = e.persona ? ` [${e.persona}]` : " [global]";
297
+ return `${i + 1}. **${e.key}**${scope} (${e.category}): ${e.content}`;
298
+ });
299
+
300
+ return {
301
+ content: [
302
+ {
303
+ type: "text" as const,
304
+ text: `Found ${entries.length} memor${entries.length === 1 ? "y" : "ies"}:\n\n${lines.join("\n")}`,
305
+ },
306
+ ],
307
+ details: { query: params.query, found: entries.length },
308
+ };
309
+ } catch (err) {
310
+ return {
311
+ content: [
312
+ {
313
+ type: "text" as const,
314
+ text: `Error recalling memories: ${err instanceof Error ? err.message : String(err)}`,
315
+ },
316
+ ],
317
+ details: {},
318
+ isError: true,
319
+ };
320
+ }
321
+ },
322
+ });
323
+
324
+ // ─── memory_forget — delete a memory ──────────────────────────────────
325
+
326
+ pi.registerTool({
327
+ name: "memory_forget",
328
+ label: "Forget Memory",
329
+ description:
330
+ "Remove a memory by its key from this persona's memory. " +
331
+ "Use this to delete outdated facts, incorrect information, or sensitive data.\n\n" +
332
+ "Note: This only deletes persona-specific memories. Global memories are preserved.\n\n" +
333
+ "Use memory_recall first to find the exact key.",
334
+ parameters: Type.Object({
335
+ key: Type.String({ description: "The key of the memory to forget" }),
336
+ }),
337
+ async execute(_toolCallId, params) {
338
+ try {
339
+ const db = getMemoryDb();
340
+ // Delete persona-specific memory (not global)
341
+ const forgotten = db.forget(params.key, personaName);
342
+
343
+ if (forgotten) {
344
+ return {
345
+ content: [
346
+ { type: "text" as const, text: `Forgot memory: **${params.key}** from ${personaName} persona` },
347
+ ],
348
+ details: { key: params.key, persona: personaName, forgotten: true },
349
+ };
350
+ } else {
351
+ return {
352
+ content: [
353
+ {
354
+ type: "text" as const,
355
+ text: `No persona-specific memory found with key: "${params.key}"`,
356
+ },
357
+ ],
358
+ details: { key: params.key, persona: personaName, forgotten: false },
359
+ };
360
+ }
361
+ } catch (err) {
362
+ return {
363
+ content: [
364
+ {
365
+ type: "text" as const,
366
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
367
+ },
368
+ ],
369
+ details: {},
370
+ isError: true,
371
+ };
372
+ }
373
+ },
374
+ });
375
+
376
+ // ─── persona_info — inspect persona + memory stats ────────────────────
377
+
378
+ pi.registerTool({
379
+ name: "persona_info",
380
+ label: "Persona Info",
381
+ description:
382
+ "Show what persona files are loaded and memory statistics. " +
383
+ "Use this if the user asks about your personality, identity, instructions, or memory.",
384
+ parameters: Type.Object({}),
385
+ async execute() {
386
+ const parts: string[] = [];
387
+ const details: Record<string, unknown> = {
388
+ personaName,
389
+ personaDir,
390
+ memoryDbPath,
391
+ sections: sections.map((s) => s.title),
392
+ };
393
+
394
+ // Active persona
395
+ parts.push(`**Active Persona**: ${personaName}`);
396
+
397
+ // Persona files
398
+ if (sections.length > 0) {
399
+ parts.push(
400
+ `**Persona files** (${personaDir}/):\n\n` +
401
+ sections.map((s) => `### ${s.title}\n\n${s.content}`).join("\n\n---\n\n"),
402
+ );
403
+ } else {
404
+ parts.push(
405
+ `No persona files loaded. Create files in ${personaDir}/:\n\n` +
406
+ PERSONA_FILES.map((pf) => ` ${pf.filename} — ${pf.description}`).join("\n"),
407
+ );
408
+ }
409
+
410
+ // Memory stats (both persona-specific and global)
411
+ try {
412
+ const db = getMemoryDb();
413
+ const stats = db.stats(personaName);
414
+ details.memoryStats = stats;
415
+
416
+ const catLines = Object.entries(stats.byCategory)
417
+ .map(([cat, count]) => ` ${cat}: ${count}`)
418
+ .join("\n");
419
+
420
+ parts.push(
421
+ `**Memory** (${memoryDbPath}):\n` +
422
+ ` Persona: ${personaName} (includes global memories)\n` +
423
+ ` Total accessible entries: ${stats.total}\n` +
424
+ (catLines ? ` By category:\n${catLines}` : " (empty)"),
425
+ );
426
+ } catch (err) {
427
+ parts.push(`**Memory**: Error — ${err instanceof Error ? err.message : String(err)}`);
428
+ }
429
+
430
+ return {
431
+ content: [{ type: "text" as const, text: parts.join("\n\n---\n\n") }],
432
+ details,
433
+ };
434
+ },
435
+ });
436
+
437
+ // ─── persona_edit — read/write persona flat files ─────────────────────
438
+
439
+ const EDITABLE_FILES = ["identity.md", "instructions.md", "knowledge.md"] as const;
440
+
441
+ pi.registerTool({
442
+ name: "persona_edit",
443
+ label: "Edit Persona",
444
+ description:
445
+ "Read or replace the contents of a persona file. " +
446
+ "Use this when the user asks you to change your personality, update instructions, " +
447
+ "or modify knowledge. Files live in ~/.clankie/persona/.\n\n" +
448
+ "Available files:\n" +
449
+ "- identity.md — your name, voice, personality traits\n" +
450
+ "- instructions.md — standing orders, how to behave\n" +
451
+ "- knowledge.md — facts about the user\n\n" +
452
+ "For persistent memory (facts learned over time), use memory_store instead.",
453
+ parameters: Type.Object({
454
+ file: StringEnum(EDITABLE_FILES),
455
+ action: StringEnum(["read", "write"] as const, {
456
+ description: "read = show current contents, write = replace file with new content",
457
+ }),
458
+ content: Type.Optional(Type.String({ description: "New file content (required when action is 'write')" })),
459
+ }),
460
+ async execute(_toolCallId, params) {
461
+ interface Details {
462
+ action: string;
463
+ file: string;
464
+ success: boolean;
465
+ }
466
+ const filePath = join(personaDir, params.file);
467
+ const result = (text: string, details: Details, isError?: boolean) => ({
468
+ content: [{ type: "text" as const, text }],
469
+ details,
470
+ ...(isError ? { isError } : {}),
471
+ });
472
+
473
+ if (params.action === "read") {
474
+ if (!existsSync(filePath)) {
475
+ return result(`File does not exist: ${params.file}`, { action: "read", file: params.file, success: false });
476
+ }
477
+ const text = readFileSync(filePath, "utf-8");
478
+ return result(text, { action: "read", file: params.file, success: true });
479
+ }
480
+
481
+ if (!params.content) {
482
+ return result(
483
+ "Error: content is required for write action",
484
+ { action: "write", file: params.file, success: false },
485
+ true,
486
+ );
487
+ }
488
+
489
+ if (!existsSync(personaDir)) {
490
+ mkdirSync(personaDir, { recursive: true, mode: 0o700 });
491
+ }
492
+
493
+ writeFileSync(filePath, params.content, "utf-8");
494
+
495
+ // Update in-memory sections
496
+ const title = PERSONA_FILES.find((pf) => pf.filename === params.file)?.title ?? params.file;
497
+ const trimmed = params.content.trim();
498
+ const existing = sections.find((s) => s.title === title);
499
+ if (existing) {
500
+ existing.content = trimmed;
501
+ } else if (trimmed) {
502
+ sections.push({ title, content: trimmed });
503
+ }
504
+
505
+ return result(`Updated ${params.file}. Changes are live for this session and all future sessions.`, {
506
+ action: "write",
507
+ file: params.file,
508
+ success: true,
509
+ });
510
+ },
511
+ });
512
+
513
+ // ─── Backward compat: keep "remember" as an alias for memory_store ────
514
+
515
+ pi.registerTool({
516
+ name: "remember",
517
+ label: "Remember",
518
+ description:
519
+ "Quick shortcut to save a note to this persona's memory (equivalent to memory_store with scope=persona, category=core). " +
520
+ "Use this when the user tells you something worth remembering for future conversations in this persona context.",
521
+ parameters: Type.Object({
522
+ note: Type.String({
523
+ description: 'The thing to remember. Example: "User prefers TypeScript over JavaScript"',
524
+ }),
525
+ }),
526
+ async execute(_toolCallId, params) {
527
+ try {
528
+ const db = getMemoryDb();
529
+ // Auto-generate a key from the note (first few words, snake_cased)
530
+ const key = params.note
531
+ .toLowerCase()
532
+ .replace(/[^a-z0-9\s]/g, "")
533
+ .split(/\s+/)
534
+ .slice(0, 4)
535
+ .join("_");
536
+
537
+ // Store with persona scope
538
+ db.store(key, params.note, "core", personaName);
539
+
540
+ return {
541
+ content: [{ type: "text", text: `Remembered for ${personaName} persona: "${params.note}"` }],
542
+ details: { key, note: params.note, persona: personaName },
543
+ };
544
+ } catch (err) {
545
+ return {
546
+ content: [
547
+ {
548
+ type: "text" as const,
549
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
550
+ },
551
+ ],
552
+ details: {},
553
+ isError: true,
554
+ };
555
+ }
556
+ },
557
+ });
558
+ }; // end personaExtension
559
+ } // end createPersonaExtension
560
+
561
+ // ─── Backward compatibility ───────────────────────────────────────────────────
562
+
563
+ /** Default export for backward compatibility (uses "default" persona) */
564
+ export default createPersonaExtension("default");