@zosmaai/pi-llm-wiki 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.de.md +2 -2
- package/README.es.md +2 -2
- package/README.fr.md +2 -2
- package/README.hi.md +2 -2
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +25 -2
- package/README.pt.md +2 -2
- package/README.ru.md +2 -2
- package/README.zh.md +2 -2
- package/docs/api.md +123 -10
- package/docs/architecture.md +38 -1
- package/docs/commands.md +21 -1
- package/extensions/llm-wiki/index.ts +80 -17
- package/extensions/llm-wiki/lib/metadata.ts +34 -1
- package/extensions/llm-wiki/lib/observation.ts +50 -10
- package/extensions/llm-wiki/lib/recall.ts +107 -8
- package/extensions/llm-wiki/lib/runtime.ts +49 -1
- package/extensions/llm-wiki/lib/task-config.ts +107 -13
- package/extensions/llm-wiki/lib/tools.ts +338 -183
- package/extensions/llm-wiki/lib/trajectories-command.ts +67 -0
- package/extensions/llm-wiki/lib/trajectory.ts +613 -0
- package/extensions/llm-wiki/lib/utils.ts +20 -3
- package/extensions/llm-wiki/lib/visible-status.ts +51 -0
- package/package.json +1 -1
- package/prompts/wiki-record.md +36 -0
- package/prompts/wiki-run.md +4 -2
- package/prompts/wiki-skills.md +26 -0
- package/skills/llm-wiki/SKILL.md +70 -4
|
@@ -2,12 +2,9 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
3
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
4
4
|
import { installGuardrails } from "./lib/guardrails.js";
|
|
5
|
+
import { registerWikiModelCommand } from "./lib/model-command.js";
|
|
5
6
|
import {
|
|
6
|
-
|
|
7
|
-
formatActiveModelLabel,
|
|
8
|
-
registerWikiModelCommand,
|
|
9
|
-
} from "./lib/model-command.js";
|
|
10
|
-
import {
|
|
7
|
+
buildSessionNotice,
|
|
11
8
|
createReminderState,
|
|
12
9
|
registerObservationReminder,
|
|
13
10
|
registerWikiObserve,
|
|
@@ -21,6 +18,7 @@ import {
|
|
|
21
18
|
} from "./lib/recall.js";
|
|
22
19
|
import { registerWikiRetro } from "./lib/retro.js";
|
|
23
20
|
import { registerBackgroundRuntime } from "./lib/runtime.js";
|
|
21
|
+
import { loadTaskConfig, noticesEnabled, trajectoriesEnabled } from "./lib/task-config.js";
|
|
24
22
|
import {
|
|
25
23
|
registerWikiBootstrap,
|
|
26
24
|
registerWikiCaptureSource,
|
|
@@ -34,6 +32,12 @@ import {
|
|
|
34
32
|
registerWikiStatus,
|
|
35
33
|
registerWikiWatch,
|
|
36
34
|
} from "./lib/tools.js";
|
|
35
|
+
import { registerWikiTrajectoriesCommand } from "./lib/trajectories-command.js";
|
|
36
|
+
import {
|
|
37
|
+
registerWikiCaptureTrajectory,
|
|
38
|
+
registerWikiDistillSkills,
|
|
39
|
+
registerWikiRecallSkill,
|
|
40
|
+
} from "./lib/trajectory.js";
|
|
37
41
|
import {
|
|
38
42
|
ensureVaultStructure,
|
|
39
43
|
fmtDate,
|
|
@@ -42,11 +46,13 @@ import {
|
|
|
42
46
|
resolveVaultPaths,
|
|
43
47
|
writeJson,
|
|
44
48
|
} from "./lib/utils.js";
|
|
49
|
+
import { applySessionStartStatus } from "./lib/visible-status.js";
|
|
45
50
|
|
|
46
51
|
/**
|
|
47
52
|
* @zosmaai/pi-llm-wiki — LLM Wiki extension for Pi
|
|
48
53
|
*
|
|
49
|
-
* Registers
|
|
54
|
+
* Registers 13 custom tools and installs guardrails (+3 agent-trajectory tools
|
|
55
|
+
* when `llm-wiki.trajectories` is enabled — opt-in, off by default, issue #80):
|
|
50
56
|
* - wiki_recall (layered: personal + project vaults)
|
|
51
57
|
* - wiki_retro (lightweight: single markdown file)
|
|
52
58
|
* - wiki_capture_source (full 4-layer pipeline)
|
|
@@ -71,21 +77,43 @@ export default function (pi: ExtensionAPI) {
|
|
|
71
77
|
registerWikiIngest(pi, runtime);
|
|
72
78
|
registerWikiEnsurePage(pi, runtime);
|
|
73
79
|
registerWikiSearch(pi);
|
|
74
|
-
registerWikiLint(pi);
|
|
80
|
+
registerWikiLint(pi, runtime);
|
|
75
81
|
registerWikiStatus(pi);
|
|
76
|
-
registerWikiRebuildMeta(pi);
|
|
82
|
+
registerWikiRebuildMeta(pi, runtime);
|
|
77
83
|
registerWikiReindexEmbeddings(pi, runtime);
|
|
78
84
|
registerWikiLogEvent(pi);
|
|
79
85
|
registerWikiWatch(pi);
|
|
80
86
|
registerWikiRecall(pi, runtime);
|
|
81
87
|
registerWikiRetro(pi, runtime);
|
|
88
|
+
// Agent working-memory (issue #80): capture what the agent *did* (its
|
|
89
|
+
// tool-call trajectory), distill it into reusable skills, and recall past
|
|
90
|
+
// skills/cases. OPT-IN, default OFF — registered ONLY when enabled so the 3
|
|
91
|
+
// tools cost nothing in the system prompt for users who don't opt in.
|
|
92
|
+
//
|
|
93
|
+
// Gate on loadTaskConfig(process.cwd()) at factory time, NOT runtime.config:
|
|
94
|
+
// runtime.config is empty ({}) until ensureConfig runs in a later hook, so a
|
|
95
|
+
// runtime.config gate here would read as permanently off. Toggling the flag
|
|
96
|
+
// via /wiki-trajectories reloads the extension, re-running this gate.
|
|
97
|
+
const trajectoriesOn = trajectoriesEnabled(loadTaskConfig(process.cwd()));
|
|
98
|
+
if (trajectoriesOn) {
|
|
99
|
+
registerWikiCaptureTrajectory(pi);
|
|
100
|
+
registerWikiDistillSkills(pi);
|
|
101
|
+
registerWikiRecallSkill(pi);
|
|
102
|
+
}
|
|
103
|
+
// Activation surface for the above (always available so users can turn it on).
|
|
104
|
+
registerWikiTrajectoriesCommand(pi);
|
|
82
105
|
// Model selection surface (issue #69): /wiki-model command to view/set the
|
|
83
106
|
// background task model. The taskModel config field + resolveModel already
|
|
84
107
|
// exist; this exposes them to the user (default stays the session model).
|
|
85
108
|
registerWikiModelCommand(pi, runtime);
|
|
86
109
|
const reminderState = createReminderState();
|
|
87
110
|
registerWikiObserve(pi, runtime, reminderState);
|
|
88
|
-
|
|
111
|
+
// Visible observe/retro reminder by default (issue #77); silenced when the
|
|
112
|
+
// user sets `llm-wiki.notices: false`. Resolver reads the live config so the
|
|
113
|
+
// setting takes effect without a restart.
|
|
114
|
+
registerObservationReminder(pi, reminderState, {
|
|
115
|
+
display: () => noticesEnabled(runtime.config),
|
|
116
|
+
});
|
|
89
117
|
|
|
90
118
|
installGuardrails(pi, runtime);
|
|
91
119
|
|
|
@@ -99,6 +127,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
99
127
|
try {
|
|
100
128
|
const migration = migrateDoubledPersonalVault();
|
|
101
129
|
if (migration && migration.moved.length > 0) {
|
|
130
|
+
// INTENTIONALLY NOT gated by `noticesEnabled` (issues #77, #84): this is
|
|
131
|
+
// a one-shot data-integrity recovery signal, not chat-noise. If the
|
|
132
|
+
// user has a broken doubled-dotdir layout we want them to see that it
|
|
133
|
+
// was fixed, even in quiet mode.
|
|
102
134
|
ctx.ui.setStatus(
|
|
103
135
|
"llm-wiki",
|
|
104
136
|
`🧠 Personal wiki layout fixed: flattened ${migration.moved.length} entries out of ${migration.from} (see CHANGELOG)`,
|
|
@@ -140,17 +172,35 @@ export default function (pi: ExtensionAPI) {
|
|
|
140
172
|
writeFileSync(join(vaultPaths.dotWiki, "WIKI_SCHEMA.md"), schema, "utf-8");
|
|
141
173
|
|
|
142
174
|
needsTopicInference = true;
|
|
175
|
+
// INTENTIONALLY NOT gated by `noticesEnabled` (issues #77, #84): one-shot
|
|
176
|
+
// first-run setup signal. The user needs to know the wiki was just
|
|
177
|
+
// auto-created, regardless of quiet mode.
|
|
143
178
|
ctx.ui.setStatus("llm-wiki", "🧠 Wiki created (inferring topic from first prompt…)");
|
|
144
179
|
return;
|
|
145
180
|
}
|
|
146
181
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
//
|
|
182
|
+
// Surface the "wiki active" badge and the active background task model
|
|
183
|
+
// (issue #69), both gated by `llm-wiki.notices` (issue #77, regression
|
|
184
|
+
// fixed in #83, helper extracted in #84). `ensureConfig` MUST run first so
|
|
185
|
+
// the gate sees the loaded project settings.
|
|
151
186
|
runtime.ensureConfig(process.cwd());
|
|
152
|
-
|
|
153
|
-
|
|
187
|
+
applySessionStartStatus({
|
|
188
|
+
ui: ctx.ui,
|
|
189
|
+
runtime,
|
|
190
|
+
trajectoriesOn,
|
|
191
|
+
sessionModelId: (ctx.model as { id?: string })?.id,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// One-time, user-visible session notice announcing the full wiki loop
|
|
195
|
+
// (issue #77). Without this, recall/observe/retro are invisible — they
|
|
196
|
+
// live only in the system prompt. Queued for the first prompt so it never
|
|
197
|
+
// interrupts; silenced when `llm-wiki.notices: false`.
|
|
198
|
+
if (noticesEnabled(runtime.config)) {
|
|
199
|
+
pi.sendMessage(
|
|
200
|
+
{ customType: "wiki-session-notice", content: buildSessionNotice(), display: true },
|
|
201
|
+
{ deliverAs: "nextTurn" },
|
|
202
|
+
);
|
|
203
|
+
}
|
|
154
204
|
});
|
|
155
205
|
|
|
156
206
|
// ─── Layered recall + topic inference hook ──────────
|
|
@@ -158,7 +208,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
158
208
|
// 1. If wiki was just auto-created, inject a directive to infer topic/mode
|
|
159
209
|
// from the user's first prompt and update config via wiki_bootstrap.
|
|
160
210
|
// 2. Search both personal + project vaults for relevant pages.
|
|
161
|
-
pi.on("before_agent_start", async (event,
|
|
211
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
162
212
|
const paths = resolveVaultPaths(process.cwd());
|
|
163
213
|
if (!existsSync(join(paths.dotWiki, "config.json"))) {
|
|
164
214
|
return;
|
|
@@ -223,10 +273,23 @@ Then call wiki_bootstrap with the inferred topic and mode to finalize the setup.
|
|
|
223
273
|
// large vault never floods the system prompt with inline previews.
|
|
224
274
|
// includePersonal=false here mirrors the auto-injection search scope.
|
|
225
275
|
const linksOnly = shouldUseLinksFirst(vaultPageCount(paths, false), runtime.config);
|
|
226
|
-
const recallContext = formatRecallContext(results, {
|
|
276
|
+
const recallContext = formatRecallContext(results, {
|
|
277
|
+
linksOnly,
|
|
278
|
+
skillInlineMax: runtime.config?.recallSkillInlineMax,
|
|
279
|
+
});
|
|
227
280
|
if (recallContext) {
|
|
228
281
|
injectedContext += `\n\n${recallContext}`;
|
|
229
282
|
}
|
|
283
|
+
// Recall-aware status line (issue #77): make it visible that recall
|
|
284
|
+
// actually fired and how many pages matched. Purely a UI signal — no
|
|
285
|
+
// added model context. Honors the `notices` opt-out.
|
|
286
|
+
if (ctx?.hasUI && noticesEnabled(runtime.config)) {
|
|
287
|
+
const n = results.length;
|
|
288
|
+
ctx.ui.setStatus(
|
|
289
|
+
"llm-wiki",
|
|
290
|
+
`\u{1F9E0} LLM Wiki — recalled ${n} page${n === 1 ? "" : "s"} for this task`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
230
293
|
}
|
|
231
294
|
}
|
|
232
295
|
|
|
@@ -19,7 +19,16 @@ import {
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
export interface RegistryEntry {
|
|
22
|
-
type:
|
|
22
|
+
type:
|
|
23
|
+
| "source"
|
|
24
|
+
| "entity"
|
|
25
|
+
| "concept"
|
|
26
|
+
| "synthesis"
|
|
27
|
+
| "analysis"
|
|
28
|
+
| "requirement"
|
|
29
|
+
| "trajectory"
|
|
30
|
+
| "skill"
|
|
31
|
+
| "case";
|
|
23
32
|
title: string;
|
|
24
33
|
created: string;
|
|
25
34
|
updated: string;
|
|
@@ -98,6 +107,30 @@ export function buildRegistry(paths: VaultPaths): Registry {
|
|
|
98
107
|
}
|
|
99
108
|
}
|
|
100
109
|
|
|
110
|
+
// Scan raw trajectory packets (agent working-memory). These are catalogued
|
|
111
|
+
// under the `trajectories/` namespace so distillation and recall can find
|
|
112
|
+
// them even before a canonical case/skill page has been written.
|
|
113
|
+
if (existsSync(paths.rawTrajectories)) {
|
|
114
|
+
for (const entry of readdirSync(paths.rawTrajectories)) {
|
|
115
|
+
const manifestPath = join(paths.rawTrajectories, entry, "manifest.json");
|
|
116
|
+
if (!existsSync(manifestPath)) continue;
|
|
117
|
+
|
|
118
|
+
const manifest = readJson<Record<string, unknown>>(manifestPath, {});
|
|
119
|
+
const id = String(manifest.id || entry);
|
|
120
|
+
const trajectoryPage = `trajectories/${id}`;
|
|
121
|
+
|
|
122
|
+
if (!pages[trajectoryPage]) {
|
|
123
|
+
pages[trajectoryPage] = {
|
|
124
|
+
type: "trajectory",
|
|
125
|
+
title: String(manifest.title || id),
|
|
126
|
+
created: String(manifest.captured || fmtDate()),
|
|
127
|
+
updated: String(manifest.captured || fmtDate()),
|
|
128
|
+
...manifest,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
101
134
|
return {
|
|
102
135
|
version: "1.0",
|
|
103
136
|
last_updated: new Date().toISOString(),
|
|
@@ -283,17 +283,64 @@ export function registerWikiObserve(
|
|
|
283
283
|
|
|
284
284
|
// ─── Turn-End Reminder ─────────────────────────────────
|
|
285
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Build the one-time, user-visible session notice (issue #77) that announces
|
|
288
|
+
* the full wiki loop so the user can SEE the wiki is active and what it offers:
|
|
289
|
+
*
|
|
290
|
+
* retrieval (sync, on the LLM's critical path): recall → search → read
|
|
291
|
+
* capture (background + reported): observe → retro
|
|
292
|
+
*
|
|
293
|
+
* Shown once per session when `notices` are enabled; silenced otherwise.
|
|
294
|
+
*/
|
|
295
|
+
export function buildSessionNotice(): string {
|
|
296
|
+
return [
|
|
297
|
+
"\u{1F9E0} **LLM Wiki active.**",
|
|
298
|
+
"Retrieval (inline): recall runs automatically each turn — use `wiki_search` to query",
|
|
299
|
+
"and `read` to open pages.",
|
|
300
|
+
"Capture (background + reported): `wiki_observe` for timestamped notes,",
|
|
301
|
+
"`wiki_retro` for durable insights. All other wiki actions run in the background and",
|
|
302
|
+
"report when done. Silence these notices with `llm-wiki.notices: false`.",
|
|
303
|
+
].join(" ");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Build the periodic observe/retro reminder text. Mentions BOTH capture tools
|
|
308
|
+
* (issue #77): `wiki_observe` for timestamped session observations and
|
|
309
|
+
* `wiki_retro` for distilled, durable insights at task end.
|
|
310
|
+
*/
|
|
311
|
+
export function buildReminderText(): string {
|
|
312
|
+
return [
|
|
313
|
+
"**Wiki capture reminder:** If the work in this session produced non-trivial",
|
|
314
|
+
"decisions, findings, constraints, or completions worth preserving across sessions,",
|
|
315
|
+
"record them now: call `wiki_observe` for timestamped observations, or `wiki_retro`",
|
|
316
|
+
"to save a distilled insight. Both are searchable via `wiki_recall` and compound",
|
|
317
|
+
"your wiki's knowledge over time.",
|
|
318
|
+
"",
|
|
319
|
+
"One item per call. Separate distinct findings into multiple calls.",
|
|
320
|
+
].join(" ");
|
|
321
|
+
}
|
|
322
|
+
|
|
286
323
|
/**
|
|
287
324
|
* Track observation cadence and send turn-end reminders.
|
|
288
325
|
* After every N significant turns, reminds the model to call wiki_observe
|
|
289
326
|
* for non-trivial findings (same pattern as memex-retro reminders).
|
|
327
|
+
*
|
|
328
|
+
* `options.display` (issue #77) controls whether the reminder is shown to the
|
|
329
|
+
* user (`true`, the default) or injected silently into model context only
|
|
330
|
+
* (`false`). Pass a resolver so the live `notices` config is read at send time.
|
|
290
331
|
*/
|
|
291
332
|
export function registerObservationReminder(
|
|
292
333
|
pi: ExtensionAPI,
|
|
293
334
|
reminderState: ReminderState,
|
|
294
|
-
options?: { turnsBetweenReminders?: number },
|
|
335
|
+
options?: { turnsBetweenReminders?: number; display?: boolean | (() => boolean) },
|
|
295
336
|
): void {
|
|
296
337
|
const REMINDER_INTERVAL = options?.turnsBetweenReminders ?? 5;
|
|
338
|
+
const resolveDisplay = (): boolean => {
|
|
339
|
+
const d = options?.display;
|
|
340
|
+
if (typeof d === "function") return d();
|
|
341
|
+
if (typeof d === "boolean") return d;
|
|
342
|
+
return true;
|
|
343
|
+
};
|
|
297
344
|
let turnsSinceLastReminder = 0;
|
|
298
345
|
|
|
299
346
|
pi.on("session_start", async () => {
|
|
@@ -315,15 +362,8 @@ export function registerObservationReminder(
|
|
|
315
362
|
pi.sendMessage(
|
|
316
363
|
{
|
|
317
364
|
customType: "wiki-observe-reminder",
|
|
318
|
-
content:
|
|
319
|
-
|
|
320
|
-
"decisions, findings, constraints, or completions worth preserving across sessions,",
|
|
321
|
-
"call `wiki_observe` to record them. Observations are searchable via `wiki_recall`",
|
|
322
|
-
"and compound your wiki's knowledge over time.",
|
|
323
|
-
"",
|
|
324
|
-
"One observation per call. Separate distinct findings into multiple calls.",
|
|
325
|
-
].join(" "),
|
|
326
|
-
display: false,
|
|
365
|
+
content: buildReminderText(),
|
|
366
|
+
display: resolveDisplay(),
|
|
327
367
|
},
|
|
328
368
|
{
|
|
329
369
|
deliverAs: "nextTurn",
|
|
@@ -33,7 +33,7 @@ export interface RecallResult {
|
|
|
33
33
|
type: string;
|
|
34
34
|
/** First N chars of page content for context */
|
|
35
35
|
preview: string;
|
|
36
|
-
/**
|
|
36
|
+
/** Absolute filesystem path to the page (resolvable by the `read` tool). */
|
|
37
37
|
path: string;
|
|
38
38
|
/** Vault source label for dual-vault results */
|
|
39
39
|
vaultLabel?: string;
|
|
@@ -743,24 +743,105 @@ function linkSnippet(preview: string): string {
|
|
|
743
743
|
return oneLine.length > LINKS_SNIPPET_MAX ? `${oneLine.slice(0, LINKS_SNIPPET_MAX)}…` : oneLine;
|
|
744
744
|
}
|
|
745
745
|
|
|
746
|
+
/**
|
|
747
|
+
* Default cap on chars of a skill/case body inlined directly into a recall
|
|
748
|
+
* block. Overridable per-vault via `recallSkillInlineMax` (0 disables inlining).
|
|
749
|
+
* Mirrors `DEFAULT_RECALL_LINKS_THRESHOLD` — the sibling context-window lever.
|
|
750
|
+
*/
|
|
751
|
+
export const DEFAULT_RECALL_SKILL_INLINE_MAX = 1600;
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Skills/working-memory carve-out from links-first: short, high-value
|
|
755
|
+
* procedural pages (`skill`/`case`) are meant to be APPLIED immediately, so we
|
|
756
|
+
* inline their body directly rather than make the agent expand a link it often
|
|
757
|
+
* skips (adherence > context-economy for these page types). Returns null for
|
|
758
|
+
* non-skill pages or when the body can't be read.
|
|
759
|
+
*/
|
|
760
|
+
function isSkillOrCase(r: RecallResult): boolean {
|
|
761
|
+
return (
|
|
762
|
+
r.type === "skill" ||
|
|
763
|
+
r.type === "case" ||
|
|
764
|
+
r.id.startsWith("skills/") ||
|
|
765
|
+
r.id.startsWith("cases/")
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Inlined body for a skill/case page, or null. `max <= 0` disables inlining and
|
|
771
|
+
* short-circuits BEFORE any filesystem access, so a vault that opts out keeps
|
|
772
|
+
* recall page-body-I/O-free (issue #68's cheap-recall invariant). Otherwise the
|
|
773
|
+
* read is bounded: it fires only for skill/case results (which exist only when
|
|
774
|
+
* the trajectories feature is on) and only the top-N ranked hits.
|
|
775
|
+
*/
|
|
776
|
+
function inlineSkillBody(r: RecallResult, max = DEFAULT_RECALL_SKILL_INLINE_MAX): string | null {
|
|
777
|
+
if (max <= 0) return null;
|
|
778
|
+
if (!isSkillOrCase(r)) return null;
|
|
779
|
+
if (!r.path || !existsSync(r.path)) return null;
|
|
780
|
+
// Normalize CRLF first so the LF-anchored frontmatter strip below works on
|
|
781
|
+
// Windows-authored / git-autocrlf'd vaults (otherwise the raw YAML leaks in).
|
|
782
|
+
let body = readFileSync(r.path, "utf-8").replace(/\r\n/g, "\n");
|
|
783
|
+
body = body.replace(/^---\n[\s\S]*?\n---\n/, "").trim(); // strip YAML frontmatter
|
|
784
|
+
if (!body) return null;
|
|
785
|
+
if (body.length > max) {
|
|
786
|
+
body = `${body.slice(0, max)}\n…(truncated — \`read\` the path above for the full page)`;
|
|
787
|
+
}
|
|
788
|
+
return body;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* A backtick fence guaranteed longer than any backtick run inside `body`.
|
|
793
|
+
* Skill/case pages routinely embed their own fenced code blocks; CommonMark
|
|
794
|
+
* closes a fenced block only on a fence of length >= the opener, so opening
|
|
795
|
+
* with (longest inner run + 1, min 3) keeps an inlined body from terminating
|
|
796
|
+
* the wrapper early — and stays safe even when truncation cuts mid-fence.
|
|
797
|
+
*/
|
|
798
|
+
function codeFenceFor(body: string): string {
|
|
799
|
+
let longest = 0;
|
|
800
|
+
for (const run of body.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
|
|
801
|
+
return "`".repeat(Math.max(3, longest + 1));
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Indented, fence-safe lines wrapping an inlined skill/case body. */
|
|
805
|
+
function inlineBlockLines(body: string, indent: string): string[] {
|
|
806
|
+
const fence = codeFenceFor(body);
|
|
807
|
+
return [
|
|
808
|
+
"",
|
|
809
|
+
`${indent}${fence}`,
|
|
810
|
+
...body.split("\n").map((line) => `${indent}${line}`),
|
|
811
|
+
`${indent}${fence}`,
|
|
812
|
+
];
|
|
813
|
+
}
|
|
814
|
+
|
|
746
815
|
/**
|
|
747
816
|
* Format recall results as a compact system-prompt section.
|
|
748
817
|
*
|
|
749
818
|
* Two render modes (issue #68):
|
|
750
|
-
* - Default / `linksOnly: false` — preview-inline
|
|
819
|
+
* - Default / `linksOnly: false` — preview-inline. For ordinary pages this is
|
|
820
|
+
* byte-for-byte the pre-fix small-vault rendering (no regression); the
|
|
821
|
+
* resolvable read-path + new footer copy are confined to links-first, where
|
|
822
|
+
* there is no inline content and the agent MUST resolve a link.
|
|
751
823
|
* - `linksOnly: true` — stage-1 "links-first": a ranked list of links carrying
|
|
752
|
-
* id, title, type, score, and a single short snippet
|
|
753
|
-
* links it wants on demand
|
|
754
|
-
* threshold to keep large vaults from flooding context.
|
|
824
|
+
* id, title, type, score, and a single short snippet, each with a resolvable
|
|
825
|
+
* `read <path>`. The agent expands the links it wants on demand (stage 2).
|
|
826
|
+
* Used above the vault-size threshold to keep large vaults from flooding context.
|
|
827
|
+
*
|
|
828
|
+
* `skillInlineMax` (default `DEFAULT_RECALL_SKILL_INLINE_MAX`) caps how much of a
|
|
829
|
+
* skill/case body is inlined; 0 disables inlining (pure links-first for those too).
|
|
755
830
|
*/
|
|
756
831
|
export function formatRecallContext(
|
|
757
832
|
results: RecallResult[],
|
|
758
|
-
opts: { linksOnly?: boolean } = {},
|
|
833
|
+
opts: { linksOnly?: boolean; skillInlineMax?: number } = {},
|
|
759
834
|
): string {
|
|
760
835
|
if (results.length === 0) return "";
|
|
761
836
|
|
|
837
|
+
const skillInlineMax = opts.skillInlineMax ?? DEFAULT_RECALL_SKILL_INLINE_MAX;
|
|
762
838
|
const hasLayered = results.some((r) => r.vaultLabel);
|
|
763
839
|
const label = hasLayered ? " (personal + project)" : "";
|
|
840
|
+
// Salience nudge: when a distilled skill/case matches, tell the agent to
|
|
841
|
+
// apply it BEFORE experimenting (the dominant cost is recall non-adherence).
|
|
842
|
+
const hasSkill = results.some(isSkillOrCase);
|
|
843
|
+
const skillNudge =
|
|
844
|
+
"⚠\ufe0f A distilled skill/case below matches this task — read and APPLY it BEFORE experimenting on your own.";
|
|
764
845
|
|
|
765
846
|
if (opts.linksOnly) {
|
|
766
847
|
const lines: string[] = [
|
|
@@ -770,6 +851,7 @@ export function formatRecallContext(
|
|
|
770
851
|
"",
|
|
771
852
|
];
|
|
772
853
|
|
|
854
|
+
if (hasSkill) lines.splice(1, 0, "", skillNudge);
|
|
773
855
|
results.forEach((r, i) => {
|
|
774
856
|
const vaultTag = r.vaultLabel ? ` ${r.vaultLabel}` : "";
|
|
775
857
|
const snippet = linkSnippet(r.preview);
|
|
@@ -777,11 +859,18 @@ export function formatRecallContext(
|
|
|
777
859
|
lines.push(
|
|
778
860
|
`${i + 1}. **[[${r.id}]]** — *${r.type}* — score ${r.score.toFixed(1)}${vaultTag} — ${r.title}${tail}`,
|
|
779
861
|
);
|
|
862
|
+
// Surface a read-resolvable path so expansion is a single, first-try
|
|
863
|
+
// `read` (issue: wikilink ids aren't resolvable by the file read tool).
|
|
864
|
+
if (r.path) lines.push(` ↳ \`read ${r.path}\``);
|
|
865
|
+
// Skills/case carve-out: inline the body so the agent doesn't have to
|
|
866
|
+
// (and often won't) expand the link before acting.
|
|
867
|
+
const inl = inlineSkillBody(r, skillInlineMax);
|
|
868
|
+
if (inl) lines.push(...inlineBlockLines(inl, " "));
|
|
780
869
|
});
|
|
781
870
|
|
|
782
871
|
lines.push(
|
|
783
872
|
"",
|
|
784
|
-
"Call `read`
|
|
873
|
+
"Call `read` on the exact path shown under each link to pull its full content." +
|
|
785
874
|
" Add new findings via wiki_ensure_page or wiki_retro.",
|
|
786
875
|
"",
|
|
787
876
|
);
|
|
@@ -796,10 +885,20 @@ export function formatRecallContext(
|
|
|
796
885
|
"",
|
|
797
886
|
];
|
|
798
887
|
|
|
888
|
+
if (hasSkill) lines.splice(1, 0, "", skillNudge);
|
|
799
889
|
for (const r of results) {
|
|
800
890
|
const vaultTag = r.vaultLabel ? ` ${r.vaultLabel}` : "";
|
|
801
891
|
lines.push(`- **[[${r.id}]]** — *${r.type}* — ${r.title}${vaultTag}`);
|
|
802
|
-
|
|
892
|
+
// Skills/case carve-out: inline the body (adherence > context-economy). Only
|
|
893
|
+
// here does the default path deviate from the pre-fix small-vault render —
|
|
894
|
+
// and only when a skill/case matched (i.e. the trajectories feature is on),
|
|
895
|
+
// so ordinary pages stay byte-for-byte unchanged (#68 no-regression promise).
|
|
896
|
+
const inl = inlineSkillBody(r, skillInlineMax);
|
|
897
|
+
if (inl) {
|
|
898
|
+
// Resolvable path so a truncated inline body is one `read` away.
|
|
899
|
+
if (r.path) lines.push(` ↳ \`read ${r.path}\``);
|
|
900
|
+
lines.push(...inlineBlockLines(inl, " "));
|
|
901
|
+
} else if (r.preview) {
|
|
803
902
|
// Truncate preview to one line
|
|
804
903
|
const preview = r.preview.length > 120 ? `${r.preview.slice(0, 120)}…` : r.preview;
|
|
805
904
|
lines.push(` ${preview}`);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import { TASK_DEFAULTS, type TaskConfig, loadTaskConfig } from "./task-config.js";
|
|
2
|
+
import { TASK_DEFAULTS, type TaskConfig, loadTaskConfig, noticesEnabled } from "./task-config.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Background-task runtime for the LLM Wiki (issue #64, part of #63).
|
|
@@ -53,6 +53,13 @@ export class Runtime {
|
|
|
53
53
|
config: TaskConfig = { ...TASK_DEFAULTS };
|
|
54
54
|
configLoaded = false;
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Extension API handle, attached at registration. Used by `report()` to emit
|
|
58
|
+
* visible completion messages for background actions (issue #77). Optional so
|
|
59
|
+
* the Runtime stays unit-testable without a live `pi`.
|
|
60
|
+
*/
|
|
61
|
+
pi?: ExtensionAPI;
|
|
62
|
+
|
|
56
63
|
/** Labels of tasks currently in flight (single-flight guard per label). */
|
|
57
64
|
private inFlightLabels = new Set<string>();
|
|
58
65
|
/** All in-flight task promises, keyed for await-at-exit and dedupe. */
|
|
@@ -176,6 +183,44 @@ export class Runtime {
|
|
|
176
183
|
return promise;
|
|
177
184
|
}
|
|
178
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Report a completed background action to the user (issue #77).
|
|
188
|
+
*
|
|
189
|
+
* Every mutating wiki action runs off the agent's critical path; this is how
|
|
190
|
+
* the work becomes visible. Emits a `wiki-action-report` custom message,
|
|
191
|
+
* shown in the UI when notices are enabled (the `notices` config, default
|
|
192
|
+
* on) and otherwise injected silently. Delivered as `nextTurn` so it never
|
|
193
|
+
* interrupts or triggers a turn. Never throws — reporting must not crash the
|
|
194
|
+
* background task that called it.
|
|
195
|
+
*/
|
|
196
|
+
report(summary: string, opts?: { display?: boolean }): void {
|
|
197
|
+
if (!this.pi || !summary) return;
|
|
198
|
+
const display = opts?.display ?? noticesEnabled(this.config);
|
|
199
|
+
try {
|
|
200
|
+
this.pi.sendMessage(
|
|
201
|
+
{ customType: "wiki-action-report", content: summary, display },
|
|
202
|
+
{ deliverAs: "nextTurn" },
|
|
203
|
+
);
|
|
204
|
+
} catch {
|
|
205
|
+
// Reporting is best-effort; a stale/torn-down session must not propagate.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Run a mutating action in the background and report its result (issue #77).
|
|
211
|
+
*
|
|
212
|
+
* Thin wrapper over `launchTask`: `work` performs the off-thread mutation and
|
|
213
|
+
* returns a one-line human summary (or null to stay silent). On success the
|
|
214
|
+
* summary is surfaced via `report()`. Single-flight, error-isolated, and
|
|
215
|
+
* awaited-at-exit exactly like `launchTask`.
|
|
216
|
+
*/
|
|
217
|
+
launchReported(ctx: LaunchCtx, label: string, work: () => Promise<string | null>): Promise<void> {
|
|
218
|
+
return this.launchTask(ctx, label, async () => {
|
|
219
|
+
const summary = await work();
|
|
220
|
+
if (summary) this.report(summary);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
179
224
|
/**
|
|
180
225
|
* Await all in-flight background tasks. Call at compaction / session exit so
|
|
181
226
|
* background work is not lost. Never rejects — task errors are already
|
|
@@ -198,6 +243,9 @@ export class Runtime {
|
|
|
198
243
|
*/
|
|
199
244
|
export function registerBackgroundRuntime(pi: ExtensionAPI): Runtime {
|
|
200
245
|
const runtime = new Runtime();
|
|
246
|
+
// Attach the API so background tasks can emit visible completion reports
|
|
247
|
+
// (issue #77). Done here (not in the constructor) to keep Runtime testable.
|
|
248
|
+
runtime.pi = pi;
|
|
201
249
|
|
|
202
250
|
pi.on("turn_start", (_event, ctx) => {
|
|
203
251
|
runtime.ensureConfig(ctx.cwd);
|