@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.
@@ -44,6 +44,55 @@ function requireVault(paths: VaultPaths): { ok: true } | { ok: false; reason: st
44
44
  return { ok: true };
45
45
  }
46
46
 
47
+ type WikiToolResult = {
48
+ content: { type: "text"; text: string }[];
49
+ details: Record<string, unknown>;
50
+ isError?: boolean;
51
+ };
52
+
53
+ type ToolCtx = {
54
+ cwd?: string;
55
+ hasUI: boolean;
56
+ ui?: { notify: (message: string, type?: string) => void };
57
+ };
58
+
59
+ /**
60
+ * Dispatch a heavy mutating action to the background runtime and report its
61
+ * result (issue #77). The agent turn is never blocked: `work` runs off-thread
62
+ * and the returned one-line summary is surfaced to the user via
63
+ * `runtime.report()`. Returns an immediate, non-blocking tool result.
64
+ *
65
+ * When no runtime is available (unit tests / degraded mode), `work` runs
66
+ * synchronously and its summary is returned inline, preserving prior behavior.
67
+ * Retrieval tools (search/read/recall/status) never use this — the model needs
68
+ * their output inline.
69
+ */
70
+ async function dispatchReported(
71
+ runtime: Runtime | undefined,
72
+ ctx: ToolCtx,
73
+ opts: {
74
+ label: string;
75
+ /** Immediate, non-blocking acknowledgement shown while work runs. */
76
+ started: string;
77
+ /** Off-thread work; resolves to the human-readable completion summary. */
78
+ work: () => Promise<string>;
79
+ details?: Record<string, unknown>;
80
+ },
81
+ ): Promise<WikiToolResult> {
82
+ if (!runtime) {
83
+ const summary = await opts.work();
84
+ return {
85
+ content: [{ type: "text", text: summary }],
86
+ details: { background: false, ...opts.details },
87
+ };
88
+ }
89
+ runtime.launchReported({ hasUI: ctx.hasUI, ui: ctx.ui }, opts.label, opts.work);
90
+ return {
91
+ content: [{ type: "text", text: opts.started }],
92
+ details: { background: true, ...opts.details },
93
+ };
94
+ }
95
+
47
96
  // ─── 1. wiki_bootstrap ──────────────────────────────────
48
97
 
49
98
  export function registerWikiBootstrap(pi: ExtensionAPI): void {
@@ -381,14 +430,15 @@ export function registerWikiIngest(pi: ExtensionAPI, runtime?: Runtime): void {
381
430
  ];
382
431
  launchEmbedPages(runtime, launchCtx, paths, pageIds, `embed:ingest:${s.id}`);
383
432
  }
433
+ const summary = committed
434
+ ? `LLM Wiki: ingested ${s.id} → ${committed.entitiesCreated.length} entit${committed.entitiesCreated.length === 1 ? "y" : "ies"}, ${committed.conceptsCreated.length} concept${committed.conceptsCreated.length === 1 ? "" : "s"}`
435
+ : `LLM Wiki: ${s.id} produced no synthesis`;
384
436
  if (ctx.hasUI) {
385
- ctx.ui.notify(
386
- committed
387
- ? `LLM Wiki: ingested ${s.id} → ${committed.entitiesCreated.length} entit${committed.entitiesCreated.length === 1 ? "y" : "ies"}, ${committed.conceptsCreated.length} concept${committed.conceptsCreated.length === 1 ? "" : "s"}`
388
- : `LLM Wiki: ${s.id} produced no synthesis`,
389
- committed ? "info" : "warning",
390
- );
437
+ ctx.ui.notify(summary, committed ? "info" : "warning");
391
438
  }
439
+ // Persistent, user-visible completion report (issue #77) in
440
+ // addition to the transient toast above. Notices-gated.
441
+ runtime.report(committed ? `✅ ${summary}` : `⚠️ ${summary}`);
392
442
  });
393
443
  }
394
444
  return {
@@ -463,7 +513,8 @@ export function registerWikiEnsurePage(pi: ExtensionAPI, runtime?: Runtime): voi
463
513
  ],
464
514
  parameters: Type.Object({
465
515
  type: Type.String({
466
- description: "Page type: entity | concept | synthesis | analysis | requirement",
516
+ description:
517
+ "Page type: entity | concept | synthesis | analysis | requirement | skill | case",
467
518
  }),
468
519
  title: Type.String({ description: "Page title" }),
469
520
  content: Type.Optional(
@@ -481,7 +532,14 @@ export function registerWikiEnsurePage(pi: ExtensionAPI, runtime?: Runtime): voi
481
532
  };
482
533
  }
483
534
 
484
- const type = params.type as "entity" | "concept" | "synthesis" | "analysis";
535
+ const type = params.type as
536
+ | "entity"
537
+ | "concept"
538
+ | "synthesis"
539
+ | "analysis"
540
+ | "requirement"
541
+ | "skill"
542
+ | "case";
485
543
  const slug = params.title
486
544
  .toLowerCase()
487
545
  .replace(/[^a-z0-9\s-]/g, "")
@@ -495,6 +553,8 @@ export function registerWikiEnsurePage(pi: ExtensionAPI, runtime?: Runtime): voi
495
553
  synthesis: "syntheses",
496
554
  analysis: "analyses",
497
555
  requirement: "requirements",
556
+ skill: "skills",
557
+ case: "cases",
498
558
  };
499
559
  const folder = folderMap[type] || "concepts";
500
560
  const pagePath = join(paths.wiki, folder, `${slug}.md`);
@@ -572,6 +632,76 @@ function buildPageTemplate(
572
632
  "Durable answer from a query.\n\n## Question\n\n[Original question]",
573
633
  );
574
634
  }
635
+ if (type === "skill") {
636
+ return [
637
+ "---",
638
+ "type: skill",
639
+ `created: ${date}`,
640
+ `updated: ${date}`,
641
+ "status: draft",
642
+ "trajectories: []",
643
+ "tags: []",
644
+ "---",
645
+ "",
646
+ `# ${title}`,
647
+ "",
648
+ "_One-line summary of the reusable pattern this skill captures._",
649
+ "",
650
+ "## When to Use",
651
+ "",
652
+ "[Trigger conditions — when this pattern applies]",
653
+ "",
654
+ "## Procedure",
655
+ "",
656
+ "1. [Step 1]",
657
+ "2. [Step 2]",
658
+ "",
659
+ "## Pitfalls",
660
+ "",
661
+ "- [Known failure mode or caveat]",
662
+ "",
663
+ "## Distilled From",
664
+ "",
665
+ "_Trajectories this skill was generalized from._",
666
+ "",
667
+ "- [[trajectories/TRJ-...]]",
668
+ "",
669
+ ].join("\n");
670
+ }
671
+ if (type === "case") {
672
+ return [
673
+ "---",
674
+ "type: case",
675
+ `created: ${date}`,
676
+ `updated: ${date}`,
677
+ "status: draft",
678
+ "outcome: success",
679
+ "trajectory_id: ",
680
+ "tags: []",
681
+ "---",
682
+ "",
683
+ `# ${title}`,
684
+ "",
685
+ "_One-line summary of the specific task this case records._",
686
+ "",
687
+ "## Task",
688
+ "",
689
+ "[What was requested]",
690
+ "",
691
+ "## Approach",
692
+ "",
693
+ "[How the agent solved it — key steps and decisions]",
694
+ "",
695
+ "## Outcome",
696
+ "",
697
+ "[Result, and anything worth reusing or avoiding next time]",
698
+ "",
699
+ "## Trajectory",
700
+ "",
701
+ "- [[trajectories/TRJ-...]] — captured tool-call run",
702
+ "",
703
+ ].join("\n");
704
+ }
575
705
  if (type === "requirement") {
576
706
  return [
577
707
  "---",
@@ -672,7 +802,7 @@ export function registerWikiSearch(pi: ExtensionAPI): void {
672
802
 
673
803
  // ─── 6. wiki_lint ───────────────────────────────────────
674
804
 
675
- export function registerWikiLint(pi: ExtensionAPI): void {
805
+ export function registerWikiLint(pi: ExtensionAPI, runtime?: Runtime): void {
676
806
  pi.registerTool({
677
807
  name: "wiki_lint",
678
808
  label: "Wiki Lint",
@@ -699,138 +829,143 @@ export function registerWikiLint(pi: ExtensionAPI): void {
699
829
  };
700
830
  }
701
831
 
702
- const pages = findWikiPages(paths.wiki);
703
- const registry = buildRegistry(paths);
704
- buildBacklinks(paths, registry); // ensures backlinks.json is current
705
-
706
- const findings: string[] = [];
707
- let orphans = 0;
708
- let missingPages = 0;
709
- let contradictions = 0;
710
- const gaps: Array<{ topic: string; mentionedBy: string[] }> = [];
711
-
712
- const allPageIds = new Set(pages.map((p) => p.relative));
713
- const linkCounts: Record<string, number> = {};
714
-
715
- for (const page of pages) {
716
- const links = extractWikilinks(page.content);
717
- for (const link of links) {
718
- if (!allPageIds.has(link)) {
719
- missingPages++;
720
- findings.push(`Missing page: [[${link}]] (in [[${page.relative}]])`);
721
- const existing = gaps.find((g) => g.topic === link);
722
- if (existing) {
723
- if (!existing.mentionedBy.includes(page.relative))
724
- existing.mentionedBy.push(page.relative);
725
- } else {
726
- gaps.push({ topic: link, mentionedBy: [page.relative] });
727
- }
728
- } else {
729
- linkCounts[link] = (linkCounts[link] || 0) + 1;
730
- }
731
- }
732
- }
832
+ // Full-vault scan (+ optional auto-fix writes + reindex) is O(pages):
833
+ // run it in the background and report the health summary (issue #77).
834
+ return dispatchReported(runtime, ctx as ToolCtx, {
835
+ label: `lint:${paths.root}`,
836
+ started:
837
+ "\u{1F9F9} LLM Wiki: lint started in the background — the health report will be posted when it completes.",
838
+ work: async () => runWikiLint(paths, params.auto_fix === true),
839
+ });
840
+ },
841
+ });
842
+ }
733
843
 
734
- for (const page of pages) {
735
- if (!linkCounts[page.relative] || linkCounts[page.relative] === 0) {
736
- orphans++;
737
- findings.push(`Orphan: [[${page.relative}]] has no inbound links`);
844
+ /**
845
+ * Run the wiki health scan (issue #77 extracted it from the tool body so it can
846
+ * run off-thread via `dispatchReported`). Returns the human-readable summary.
847
+ */
848
+ function runWikiLint(paths: VaultPaths, autoFix: boolean): string {
849
+ const pages = findWikiPages(paths.wiki);
850
+ const registry = buildRegistry(paths);
851
+ buildBacklinks(paths, registry); // ensures backlinks.json is current
852
+
853
+ const findings: string[] = [];
854
+ let orphans = 0;
855
+ let missingPages = 0;
856
+ let contradictions = 0;
857
+ const gaps: Array<{ topic: string; mentionedBy: string[] }> = [];
858
+
859
+ const allPageIds = new Set(pages.map((p) => p.relative));
860
+ const linkCounts: Record<string, number> = {};
861
+
862
+ for (const page of pages) {
863
+ const links = extractWikilinks(page.content);
864
+ for (const link of links) {
865
+ if (!allPageIds.has(link)) {
866
+ missingPages++;
867
+ findings.push(`Missing page: [[${link}]] (in [[${page.relative}]])`);
868
+ const existing = gaps.find((g) => g.topic === link);
869
+ if (existing) {
870
+ if (!existing.mentionedBy.includes(page.relative))
871
+ existing.mentionedBy.push(page.relative);
872
+ } else {
873
+ gaps.push({ topic: link, mentionedBy: [page.relative] });
738
874
  }
875
+ } else {
876
+ linkCounts[link] = (linkCounts[link] || 0) + 1;
739
877
  }
878
+ }
879
+ }
740
880
 
741
- for (const page of pages) {
742
- if (page.content.includes("⚠️ **Contradiction")) {
743
- contradictions++;
744
- findings.push(`Contradiction flagged in [[${page.relative}]]`);
745
- }
746
- }
881
+ for (const page of pages) {
882
+ if (!linkCounts[page.relative] || linkCounts[page.relative] === 0) {
883
+ orphans++;
884
+ findings.push(`Orphan: [[${page.relative}]] has no inbound links`);
885
+ }
886
+ }
747
887
 
748
- let fixesApplied = 0;
749
- if (params.auto_fix) {
750
- for (const gap of gaps) {
751
- if (gap.mentionedBy.length >= 2) {
752
- const folder = gap.topic.includes("/") ? gap.topic.split("/")[0] : "concepts";
753
- const name = gap.topic.includes("/") ? gap.topic.split("/").pop()! : gap.topic;
754
- const pagePath = join(paths.wiki, folder, `${name}.md`);
755
- if (!existsSync(pagePath)) {
756
- mkdirSync(join(paths.wiki, folder), { recursive: true });
757
- writeFileSync(
758
- pagePath,
759
- `---\ntype: concept\ncreated: ${fmtDate()}\nupdated: ${fmtDate()}\nsources: []\nstatus: stub\n---\n\n# ${name.replace(/-/g, " ")}\n\n_Stub auto-created by lint. Expand with content from: ${gap.mentionedBy.map((r) => `[[${r}]]`).join(", ")}_\n`,
760
- "utf-8",
761
- );
762
- fixesApplied++;
763
- }
764
- }
888
+ for (const page of pages) {
889
+ if (page.content.includes("⚠️ **Contradiction")) {
890
+ contradictions++;
891
+ findings.push(`Contradiction flagged in [[${page.relative}]]`);
892
+ }
893
+ }
894
+
895
+ let fixesApplied = 0;
896
+ if (autoFix) {
897
+ for (const gap of gaps) {
898
+ if (gap.mentionedBy.length >= 2) {
899
+ const folder = gap.topic.includes("/") ? gap.topic.split("/")[0] : "concepts";
900
+ const name = gap.topic.includes("/") ? gap.topic.split("/").pop()! : gap.topic;
901
+ const pagePath = join(paths.wiki, folder, `${name}.md`);
902
+ mkdirSync(join(paths.wiki, folder), { recursive: true });
903
+ try {
904
+ // Atomic create-if-absent: the `wx` flag fails with EEXIST instead of
905
+ // overwriting, avoiding the existsSync→write TOCTOU race (CodeQL).
906
+ writeFileSync(
907
+ pagePath,
908
+ `---\ntype: concept\ncreated: ${fmtDate()}\nupdated: ${fmtDate()}\nsources: []\nstatus: stub\n---\n\n# ${name.replace(/-/g, " ")}\n\n_Stub auto-created by lint. Expand with content from: ${gap.mentionedBy.map((r) => `[[${r}]]`).join(", ")}_\n`,
909
+ { encoding: "utf-8", flag: "wx" },
910
+ );
911
+ fixesApplied++;
912
+ } catch (err) {
913
+ // Page already exists — nothing to fix. Re-throw anything else.
914
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
765
915
  }
766
916
  }
917
+ }
918
+ }
767
919
 
768
- writeJson(join(paths.discoveries, "gaps.json"), {
769
- gaps,
770
- generated: new Date().toISOString(),
771
- });
772
-
773
- const reportLines = [
774
- "# Wiki Lint Report",
775
- `Generated: ${fmtDate()}`,
776
- "",
777
- "## Summary",
778
- `- Total pages: ${pages.length}`,
779
- `- Orphans: ${orphans}`,
780
- `- Missing pages: ${missingPages}`,
781
- `- Contradictions: ${contradictions}`,
782
- params.auto_fix ? `- Fixes applied: ${fixesApplied}` : "",
783
- "",
784
- "## Findings",
785
- findings.length > 0 ? findings.map((f) => `- ${f}`).join("\n") : "✅ No issues found!",
786
- "",
787
- ].filter(Boolean);
788
-
789
- const reportPath = join(paths.outputs, `lint-${fmtDate()}.md`);
790
- mkdirSync(paths.outputs, { recursive: true });
791
- writeFileSync(reportPath, `${reportLines.join("\n")}\n`, "utf-8");
792
-
793
- appendEvent(paths, {
794
- kind: "lint",
795
- orphans,
796
- missing_pages: missingPages,
797
- contradictions,
798
- auto_fix: params.auto_fix ?? false,
799
- });
800
-
801
- rebuildMetadataLight(paths);
920
+ writeJson(join(paths.discoveries, "gaps.json"), {
921
+ gaps,
922
+ generated: new Date().toISOString(),
923
+ });
802
924
 
803
- return {
804
- content: [
805
- {
806
- type: "text",
807
- text: [
808
- "🧹 **Lint complete**",
809
- "",
810
- `- Pages: ${pages.length}`,
811
- `- Orphans: ${orphans}`,
812
- `- Missing: ${missingPages}`,
813
- `- Contradictions: ${contradictions}`,
814
- params.auto_fix ? `- Auto-fixes: ${fixesApplied}` : "",
815
- "",
816
- `📄 Report: \`${reportPath}\``,
817
- gaps.length > 0 ? `💡 ${gaps.length} knowledge gap(s) tracked` : "",
818
- ]
819
- .filter(Boolean)
820
- .join("\n"),
821
- },
822
- ],
823
- details: {
824
- pages: pages.length,
825
- orphans,
826
- missingPages,
827
- contradictions,
828
- reportPath,
829
- gaps: gaps.length,
830
- } as Record<string, unknown>,
831
- };
832
- },
925
+ const reportLines = [
926
+ "# Wiki Lint Report",
927
+ `Generated: ${fmtDate()}`,
928
+ "",
929
+ "## Summary",
930
+ `- Total pages: ${pages.length}`,
931
+ `- Orphans: ${orphans}`,
932
+ `- Missing pages: ${missingPages}`,
933
+ `- Contradictions: ${contradictions}`,
934
+ autoFix ? `- Fixes applied: ${fixesApplied}` : "",
935
+ "",
936
+ "## Findings",
937
+ findings.length > 0 ? findings.map((f) => `- ${f}`).join("\n") : "✅ No issues found!",
938
+ "",
939
+ ].filter(Boolean);
940
+
941
+ const reportPath = join(paths.outputs, `lint-${fmtDate()}.md`);
942
+ mkdirSync(paths.outputs, { recursive: true });
943
+ writeFileSync(reportPath, `${reportLines.join("\n")}\n`, "utf-8");
944
+
945
+ appendEvent(paths, {
946
+ kind: "lint",
947
+ orphans,
948
+ missing_pages: missingPages,
949
+ contradictions,
950
+ auto_fix: autoFix,
833
951
  });
952
+
953
+ rebuildMetadataLight(paths);
954
+
955
+ return [
956
+ "🧹 **LLM Wiki lint complete**",
957
+ "",
958
+ `- Pages: ${pages.length}`,
959
+ `- Orphans: ${orphans}`,
960
+ `- Missing: ${missingPages}`,
961
+ `- Contradictions: ${contradictions}`,
962
+ autoFix ? `- Auto-fixes: ${fixesApplied}` : "",
963
+ "",
964
+ `📄 Report: \`${reportPath}\``,
965
+ gaps.length > 0 ? `💡 ${gaps.length} knowledge gap(s) tracked` : "",
966
+ ]
967
+ .filter(Boolean)
968
+ .join("\n");
834
969
  }
835
970
 
836
971
  // ─── 7. wiki_status ─────────────────────────────────────
@@ -912,7 +1047,7 @@ export function registerWikiStatus(pi: ExtensionAPI): void {
912
1047
 
913
1048
  // ─── 8. wiki_rebuild_meta ───────────────────────────────
914
1049
 
915
- export function registerWikiRebuildMeta(pi: ExtensionAPI): void {
1050
+ export function registerWikiRebuildMeta(pi: ExtensionAPI, runtime?: Runtime): void {
916
1051
  pi.registerTool({
917
1052
  name: "wiki_rebuild_meta",
918
1053
  label: "Wiki Rebuild Meta",
@@ -931,24 +1066,23 @@ export function registerWikiRebuildMeta(pi: ExtensionAPI): void {
931
1066
  };
932
1067
  }
933
1068
 
934
- rebuildMetadata(paths);
935
- appendEvent(paths, { kind: "rebuild_meta" });
936
-
937
- const registry = readJson<Registry>(join(paths.meta, "registry.json"), {
938
- version: "1.0",
939
- last_updated: "",
940
- pages: {},
1069
+ // Heavy O(pages) rebuild — dispatch off the agent's critical path and
1070
+ // report on completion (issue #77).
1071
+ return dispatchReported(runtime, ctx as ToolCtx, {
1072
+ label: `rebuild_meta:${paths.root}`,
1073
+ started:
1074
+ "\u{1F9E0} LLM Wiki: metadata rebuild started in the background — the result will be reported when it completes.",
1075
+ work: async () => {
1076
+ rebuildMetadata(paths);
1077
+ appendEvent(paths, { kind: "rebuild_meta" });
1078
+ const registry = readJson<Registry>(join(paths.meta, "registry.json"), {
1079
+ version: "1.0",
1080
+ last_updated: "",
1081
+ pages: {},
1082
+ });
1083
+ return `✅ LLM Wiki: metadata rebuilt — ${Object.keys(registry.pages).length} pages indexed.`;
1084
+ },
941
1085
  });
942
-
943
- return {
944
- content: [
945
- {
946
- type: "text",
947
- text: `✅ Metadata rebuilt. ${Object.keys(registry.pages).length} pages indexed.`,
948
- },
949
- ],
950
- details: { pageCount: Object.keys(registry.pages).length } as Record<string, unknown>,
951
- };
952
1086
  },
953
1087
  });
954
1088
  }
@@ -998,28 +1132,24 @@ export function registerWikiReindexEmbeddings(pi: ExtensionAPI, runtime?: Runtim
998
1132
  };
999
1133
  }
1000
1134
 
1001
- const stats = await reindexEmbeddings(paths, embedder, { force: params.force === true });
1002
- appendEvent(paths, {
1003
- kind: "reindex_embeddings",
1004
- embedded: stats.embedded,
1005
- skipped: stats.skipped,
1006
- pruned: stats.pruned,
1007
- model: embedder.model,
1135
+ // Embedding is network-bound and O(pages) run it in the background and
1136
+ // report the stats on completion (issue #77).
1137
+ return dispatchReported(runtime, ctx as ToolCtx, {
1138
+ label: `reindex_embeddings:${paths.root}`,
1139
+ started: `\u{1F9E0} LLM Wiki: embedding reindex started in the background (${embedder.model}) — stats will be reported when it completes.`,
1140
+ details: { enabled: true, model: embedder.model },
1141
+ work: async () => {
1142
+ const stats = await reindexEmbeddings(paths, embedder, { force: params.force === true });
1143
+ appendEvent(paths, {
1144
+ kind: "reindex_embeddings",
1145
+ embedded: stats.embedded,
1146
+ skipped: stats.skipped,
1147
+ pruned: stats.pruned,
1148
+ model: embedder.model,
1149
+ });
1150
+ return `✅ LLM Wiki: embeddings reindexed (${embedder.model}) — ${stats.embedded} embedded, ${stats.skipped} fresh, ${stats.pruned} pruned.`;
1151
+ },
1008
1152
  });
1009
-
1010
- return {
1011
- content: [
1012
- {
1013
- type: "text",
1014
- text: `✅ Embeddings reindexed (${embedder.model}): ${stats.embedded} embedded, ${stats.skipped} fresh, ${stats.pruned} pruned.`,
1015
- },
1016
- ],
1017
- details: {
1018
- enabled: true,
1019
- ...stats,
1020
- model: embedder.model,
1021
- } as Record<string, unknown>,
1022
- };
1023
1153
  },
1024
1154
  });
1025
1155
  }
@@ -1067,10 +1197,12 @@ export function registerWikiWatch(pi: ExtensionAPI): void {
1067
1197
  pi.registerTool({
1068
1198
  name: "wiki_watch",
1069
1199
  label: "Wiki Watch",
1070
- description: "Schedule automatic wiki updates (discover → ingest → lint) via pi's cron system.",
1200
+ description:
1201
+ "Print a ready-to-paste crontab line for scheduling automatic wiki updates (discover → ingest → lint). Does NOT schedule anything itself — it returns the command for the user to install.",
1071
1202
  promptSnippet: "Schedule auto-updates for the wiki",
1072
1203
  promptGuidelines: [
1073
1204
  "Use wiki_watch when the user wants the wiki to stay current automatically.",
1205
+ "wiki_watch only PRINTS a cron line — surface the output to the user verbatim so they can install it. Do not claim the schedule is active.",
1074
1206
  ],
1075
1207
  parameters: Type.Object({
1076
1208
  interval: Type.String({ description: "daily, weekly, hourly, or stop" }),
@@ -1082,15 +1214,16 @@ export function registerWikiWatch(pi: ExtensionAPI): void {
1082
1214
  {
1083
1215
  type: "text",
1084
1216
  text: [
1085
- "🛑 To stop wiki auto-updates:",
1217
+ "🛑 To stop wiki auto-updates, remove the cron line you installed earlier:",
1086
1218
  "",
1219
+ "```bash",
1220
+ "crontab -e # then delete the line tagged '# llm-wiki-autoupdate'",
1087
1221
  "```",
1088
- "schedule_prompt action=list",
1089
- "```",
1090
- "Find the wiki job IDs, then:",
1091
1222
  "",
1092
- "```",
1093
- "schedule_prompt action=remove jobId=<id>",
1223
+ "Or list current jobs to confirm:",
1224
+ "",
1225
+ "```bash",
1226
+ "crontab -l | grep llm-wiki-autoupdate",
1094
1227
  "```",
1095
1228
  ].join("\n"),
1096
1229
  },
@@ -1099,10 +1232,11 @@ export function registerWikiWatch(pi: ExtensionAPI): void {
1099
1232
  };
1100
1233
  }
1101
1234
 
1235
+ // 5-field POSIX crontab expressions (min hour dom month dow).
1102
1236
  const intervals: Record<string, { cron: string; label: string }> = {
1103
- daily: { cron: "0 0 8 * * *", label: "Daily at 8:00 AM" },
1104
- weekly: { cron: "0 0 9 * * 1", label: "Weekly on Monday at 9:00 AM" },
1105
- hourly: { cron: "0 0 * * * *", label: "Every hour" },
1237
+ daily: { cron: "0 8 * * *", label: "Daily at 8:00 AM" },
1238
+ weekly: { cron: "0 9 * * 1", label: "Weekly on Monday at 9:00 AM" },
1239
+ hourly: { cron: "0 * * * *", label: "Every hour" },
1106
1240
  };
1107
1241
 
1108
1242
  const config = intervals[params.interval];
@@ -1119,18 +1253,37 @@ export function registerWikiWatch(pi: ExtensionAPI): void {
1119
1253
  };
1120
1254
  }
1121
1255
 
1256
+ // Robustness for global crontab environments:
1257
+ // * `/bin/bash -lc` runs a LOGIN shell that sources /etc/profile +
1258
+ // ~/.profile / ~/.bash_profile, so npm-global / bun / nvm PATH
1259
+ // additions are imported — cron's default PATH is only
1260
+ // `/usr/bin:/bin` and would not find `pi`.
1261
+ // * `mkdir -p` makes the log dir self-healing for users with only
1262
+ // a project vault (no `~/.llm-wiki/` yet).
1263
+ // * All `$HOME` references are double-quoted to survive paths with spaces.
1264
+ // * `# llm-wiki-autoupdate` tags the line so the user can find and
1265
+ // remove it via `crontab -e` later (see `interval=stop`).
1266
+ const cronLine = `${config.cron} /bin/bash -lc 'mkdir -p "$HOME/.llm-wiki" && pi -p "/wiki-run" >> "$HOME/.llm-wiki/cron.log" 2>&1' # llm-wiki-autoupdate`;
1267
+
1122
1268
  return {
1123
1269
  content: [
1124
1270
  {
1125
1271
  type: "text",
1126
1272
  text: [
1127
- `⏰ To set up ${config.label} wiki updates, run:`,
1273
+ `⏰ To set up ${config.label} wiki updates, add this line to your crontab.`,
1274
+ "**This tool only prints the line — it does not install it.**",
1128
1275
  "",
1276
+ "```bash",
1277
+ "crontab -e",
1129
1278
  "```",
1130
- `schedule_prompt action=add schedule="${config.cron}" prompt="Run /wiki:run for the LLM Wiki" name="llm-wiki-autoupdate"`,
1279
+ "",
1280
+ "Then append:",
1281
+ "",
1282
+ "```cron",
1283
+ cronLine,
1131
1284
  "```",
1132
1285
  "",
1133
- "This will auto-discover, ingest, and lint on schedule.",
1286
+ `The line uses \`/bin/bash -lc\` so your shell profile (and the \`pi\` binary on npm-global / bun PATH) is loaded. Output goes to \`~/.llm-wiki/cron.log\`. If your system has no \`/bin/bash\`, replace with \`/bin/sh -c\` and ensure \`pi\` is in cron's PATH yourself.`,
1134
1287
  ].join("\n"),
1135
1288
  },
1136
1289
  ],
@@ -1138,6 +1291,8 @@ export function registerWikiWatch(pi: ExtensionAPI): void {
1138
1291
  interval: params.interval,
1139
1292
  cronSchedule: config.cron,
1140
1293
  label: config.label,
1294
+ cronLine,
1295
+ installed: false,
1141
1296
  } as Record<string, unknown>,
1142
1297
  };
1143
1298
  },