@zosmaai/pi-llm-wiki 0.7.2 → 0.7.4

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,310 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
4
+ import { Type } from "typebox";
5
+ import { appendEvent, rebuildMetadataLight } from "./metadata.js";
6
+ import { type VaultPaths, fmtDate, resolveVaultPaths } from "./utils.js";
7
+
8
+ // ─── Types ─────────────────────────────────────────────
9
+
10
+ export interface ObservationInput {
11
+ /** Short descriptive title (≤80 chars) */
12
+ title: string;
13
+ /** The observation content — what happened, was decided, or was learned */
14
+ content: string;
15
+ /** Relevance level for retention priority */
16
+ relevance: "low" | "medium" | "high" | "critical";
17
+ /** Optional space-separated tags for categorization */
18
+ tags?: string;
19
+ /** Context: what was being worked on when this was observed */
20
+ source_context?: string;
21
+ }
22
+
23
+ export interface ObservationResult {
24
+ slug: string;
25
+ pagePath: string;
26
+ }
27
+
28
+ // ─── Save Observation ──────────────────────────────────
29
+
30
+ const RELEVANCE_EMOJIS: Record<string, string> = {
31
+ low: "📝",
32
+ medium: "🔍",
33
+ high: "⭐",
34
+ critical: "🔴",
35
+ };
36
+
37
+ /**
38
+ * Save an observation as a wiki source page.
39
+ *
40
+ * Unlike wiki_retro (which saves atomic insights at task end),
41
+ * wiki_observe records timestamped observations during a session
42
+ * that can later be distilled into durable wiki pages.
43
+ *
44
+ * Observations are stored in wiki/sources/ with type: source and
45
+ * status: observation. They are searchable via wiki_recail.
46
+ */
47
+ export function saveObservation(paths: VaultPaths, input: ObservationInput): ObservationResult {
48
+ const today = fmtDate();
49
+ const timestamp = new Date().toISOString();
50
+
51
+ // Generate a slug from title
52
+ const slugBase = input.title
53
+ .toLowerCase()
54
+ .replace(/[^a-z0-9]+/g, "-")
55
+ .replace(/^-|-$/g, "")
56
+ .slice(0, 60);
57
+ const slug = `obs-${today}-${slugBase}`;
58
+
59
+ // Write to wiki/sources/{slug}.md
60
+ const sourcePageDir = join(paths.wiki, "sources");
61
+ mkdirSync(sourcePageDir, { recursive: true });
62
+ const pagePath = join(sourcePageDir, `${slug}.md`);
63
+
64
+ const relevanceEmoji = RELEVANCE_EMOJIS[input.relevance] ?? "📝";
65
+ const tags = input.tags ?? "";
66
+ const sourceContext = input.source_context ?? "";
67
+
68
+ const pageContent = [
69
+ "---",
70
+ "type: source",
71
+ `title: "Observation: ${input.title}"`,
72
+ `slug: ${slug}`,
73
+ "status: observation",
74
+ `created: ${today}`,
75
+ `updated: ${today}`,
76
+ `relevance: ${input.relevance}`,
77
+ `observed_at: ${timestamp}`,
78
+ tags
79
+ ? `tags: [${tags
80
+ .split(/\s+/)
81
+ .filter(Boolean)
82
+ .map((t) => `"${t}"`)
83
+ .join(", ")}]`
84
+ : "",
85
+ sourceContext ? `source_context: "${sourceContext}"` : "",
86
+ "---",
87
+ "",
88
+ `# ${relevanceEmoji} Observation: ${input.title}`,
89
+ "",
90
+ input.content,
91
+ "",
92
+ `*Relevance: ${input.relevance}*`,
93
+ sourceContext ? `\n*Context: ${sourceContext}*` : "",
94
+ tags ? `\n*Tags: ${tags}*` : "",
95
+ "",
96
+ "---",
97
+ `*Observed: ${timestamp}*`,
98
+ "",
99
+ ]
100
+ .filter((l) => l !== "")
101
+ .join("\n");
102
+ writeFileSync(pagePath, pageContent, "utf-8");
103
+
104
+ // Log event
105
+ appendEvent(paths, {
106
+ kind: "observe",
107
+ slug,
108
+ title: input.title,
109
+ relevance: input.relevance,
110
+ });
111
+
112
+ // Rebuild metadata so the observation is immediately searchable
113
+ rebuildMetadataLight(paths);
114
+
115
+ return { slug, pagePath };
116
+ }
117
+
118
+ // ─── Shared Reminder State ────────────────────────────
119
+
120
+ /**
121
+ * Mutable state shared between wiki_observe tool and the turn-end reminder.
122
+ * When the model calls wiki_observe, the tool sets observeDoneThisSession
123
+ * so the reminder stops nagging.
124
+ */
125
+ export interface ReminderState {
126
+ observeDoneThisSession: boolean;
127
+ }
128
+
129
+ export function createReminderState(): ReminderState {
130
+ return { observeDoneThisSession: false };
131
+ }
132
+
133
+ // ─── Tool Registration ─────────────────────────────────
134
+
135
+ /**
136
+ * Register the `wiki_observe` tool.
137
+ * The model calls this to record observations during a session.
138
+ * Observations are saved to the wiki and become searchable.
139
+ */
140
+ export function registerWikiObserve(pi: ExtensionAPI, reminderState?: ReminderState): void {
141
+ pi.registerTool({
142
+ name: "wiki_observe",
143
+ label: "Wiki Observe",
144
+ description:
145
+ "Record an atomic observation from the current session into the wiki. " +
146
+ "Observations are timestamped, relevance-rated facts about decisions made, " +
147
+ "findings discovered, constraints established, or work completed. " +
148
+ "Saved observations are searchable via wiki_recall and can later be " +
149
+ "distilled into durable wiki pages via wiki_ensure_page. " +
150
+ "Call this proactively after non-trivial work — every observation " +
151
+ "compounds the wiki's knowledge across sessions.",
152
+ promptSnippet: "Record an observation about the current work",
153
+ promptGuidelines: [
154
+ "Call wiki_observe after non-trivial decisions, discoveries, or completions.",
155
+ "One observation per call. Use multiple calls for multiple observations.",
156
+ "Rate relevance honestly — most observations are medium or low, not critical.",
157
+ "Observations compound across sessions via wiki_recail.",
158
+ ],
159
+ parameters: Type.Object({
160
+ title: Type.String({
161
+ description:
162
+ "Short descriptive title (≤80 chars). Noun phrase, not a sentence. " +
163
+ "Example: 'JWT auth middleware added' or 'Postgres migration constraint discovered'",
164
+ }),
165
+ content: Type.String({
166
+ description:
167
+ "The observation in plain prose. What happened, was decided, or was learned. " +
168
+ "Preserve specific details: file paths, function names, error messages, " +
169
+ "quantitative results. Example: 'User decided to use JWT with refresh tokens. " +
170
+ "Implementation at src/auth/jwt.ts. Tests passing.'",
171
+ }),
172
+ relevance: Type.Union(
173
+ [
174
+ Type.Literal("low"),
175
+ Type.Literal("medium"),
176
+ Type.Literal("high"),
177
+ Type.Literal("critical"),
178
+ ],
179
+ {
180
+ description:
181
+ "Relevance level: low (routine), medium (task context), " +
182
+ "high (non-trivial decisions/constraints), critical (user identity, " +
183
+ "persistent preferences, completed work that must not be redone). " +
184
+ "Default: medium. Be honest — most observations are medium or low.",
185
+ },
186
+ ),
187
+ tags: Type.Optional(
188
+ Type.String({
189
+ description:
190
+ "Optional space-separated tags for categorization. " +
191
+ "Example: 'auth backend migration'",
192
+ }),
193
+ ),
194
+ source_context: Type.Optional(
195
+ Type.String({
196
+ description:
197
+ "What was being worked on. Example: 'Adding authentication module' or 'Debugging login timeout'",
198
+ }),
199
+ ),
200
+ }),
201
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
202
+ const paths = resolveVaultPaths(ctx.cwd ?? process.cwd());
203
+
204
+ if (!existsSync(join(paths.dotWiki, "config.json"))) {
205
+ return {
206
+ content: [
207
+ {
208
+ type: "text",
209
+ text: "No wiki vault found at this location. Initialize one with wiki_bootstrap first.",
210
+ },
211
+ ],
212
+ details: { error: "no_vault" } as Record<string, unknown>,
213
+ isError: true,
214
+ };
215
+ }
216
+
217
+ const result = saveObservation(paths, {
218
+ title: params.title,
219
+ content: params.content,
220
+ relevance: params.relevance,
221
+ tags: params.tags,
222
+ source_context: params.source_context,
223
+ });
224
+
225
+ // Signal the reminder to stop nagging this session
226
+ if (reminderState) {
227
+ reminderState.observeDoneThisSession = true;
228
+ }
229
+
230
+ const relevanceEmoji = RELEVANCE_EMOJIS[params.relevance] ?? "📝";
231
+
232
+ return {
233
+ content: [
234
+ {
235
+ type: "text",
236
+ text: [
237
+ `${relevanceEmoji} **Observation saved**: ${params.title}`,
238
+ "",
239
+ `- Page: \`${result.pagePath}\``,
240
+ `- Relevance: ${params.relevance}`,
241
+ params.tags ? `- Tags: ${params.tags}` : "",
242
+ "",
243
+ "This observation is now searchable via wiki_recall. " +
244
+ "It will compound with future observations across sessions.",
245
+ ]
246
+ .filter((l) => l !== "")
247
+ .join("\n"),
248
+ },
249
+ ],
250
+ details: {
251
+ slug: result.slug,
252
+ title: params.title,
253
+ relevance: params.relevance,
254
+ tags: params.tags || null,
255
+ } as Record<string, unknown>,
256
+ };
257
+ },
258
+ });
259
+ }
260
+
261
+ // ─── Turn-End Reminder ─────────────────────────────────
262
+
263
+ /**
264
+ * Track observation cadence and send turn-end reminders.
265
+ * After every N significant turns, reminds the model to call wiki_observe
266
+ * for non-trivial findings (same pattern as memex-retro reminders).
267
+ */
268
+ export function registerObservationReminder(
269
+ pi: ExtensionAPI,
270
+ reminderState: ReminderState,
271
+ options?: { turnsBetweenReminders?: number },
272
+ ): void {
273
+ const REMINDER_INTERVAL = options?.turnsBetweenReminders ?? 5;
274
+ let turnsSinceLastReminder = 0;
275
+
276
+ pi.on("session_start", async () => {
277
+ turnsSinceLastReminder = 0;
278
+ reminderState.observeDoneThisSession = false;
279
+ });
280
+
281
+ // After compaction, reset the reminder state so reminders resume
282
+ pi.on("session_compact", async () => {
283
+ turnsSinceLastReminder = 0;
284
+ reminderState.observeDoneThisSession = false;
285
+ });
286
+
287
+ pi.on("agent_end", async (_event, _ctx) => {
288
+ turnsSinceLastReminder++;
289
+ if (turnsSinceLastReminder < REMINDER_INTERVAL) return;
290
+ if (reminderState.observeDoneThisSession) return;
291
+
292
+ pi.sendMessage(
293
+ {
294
+ customType: "wiki-observe-reminder",
295
+ content: [
296
+ "**Wiki observation reminder:** If the work in this session produced non-trivial ",
297
+ "decisions, findings, constraints, or completions worth preserving across sessions,",
298
+ "call `wiki_observe` to record them. Observations are searchable via `wiki_recall`",
299
+ "and compound your wiki's knowledge over time.",
300
+ "",
301
+ "One observation per call. Separate distinct findings into multiple calls.",
302
+ ].join(" "),
303
+ display: false,
304
+ },
305
+ {
306
+ deliverAs: "nextTurn",
307
+ },
308
+ );
309
+ });
310
+ }