@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.
@@ -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
- MODEL_STATUS_KEY,
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 12 custom tools and installs guardrails:
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
- registerObservationReminder(pi, reminderState);
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
- ctx.ui.setStatus("llm-wiki", "🧠 LLM Wiki (13 tools, observe + recall active)");
148
-
149
- // Surface the active background task model (issue #69). Defaults to the
150
- // session model when no taskModel is configured.
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
- const modelLabel = formatActiveModelLabel(runtime.config, (ctx.model as { id?: string })?.id);
153
- ctx.ui.setStatus(MODEL_STATUS_KEY, `🧠 wiki model: ${modelLabel}`);
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, _ctx) => {
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, { linksOnly });
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: "source" | "entity" | "concept" | "synthesis" | "analysis";
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
- "**Wiki observation reminder:** If the work in this session produced non-trivial ",
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
- /** Relative path from wiki root */
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 (unchanged for small vaults).
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. The agent expands the
753
- * links it wants on demand via `read` (stage 2). Used above the vault-size
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` (or `wiki_read`) on the links you need to pull full content." +
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
- if (r.preview) {
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);