@zosmaai/pi-llm-wiki 0.9.1 → 0.9.3

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,11 +2,8 @@ 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 {
6
- MODEL_STATUS_KEY,
7
- formatActiveModelLabel,
8
- registerWikiModelCommand,
9
- } from "./lib/model-command.js";
5
+ import { appendWikiStatus } from "./lib/inject.js";
6
+ import { registerWikiModelCommand } from "./lib/model-command.js";
10
7
  import {
11
8
  buildSessionNotice,
12
9
  createReminderState,
@@ -22,7 +19,7 @@ import {
22
19
  } from "./lib/recall.js";
23
20
  import { registerWikiRetro } from "./lib/retro.js";
24
21
  import { registerBackgroundRuntime } from "./lib/runtime.js";
25
- import { noticesEnabled } from "./lib/task-config.js";
22
+ import { loadTaskConfig, noticesEnabled, trajectoriesEnabled } from "./lib/task-config.js";
26
23
  import {
27
24
  registerWikiBootstrap,
28
25
  registerWikiCaptureSource,
@@ -36,6 +33,12 @@ import {
36
33
  registerWikiStatus,
37
34
  registerWikiWatch,
38
35
  } from "./lib/tools.js";
36
+ import { registerWikiTrajectoriesCommand } from "./lib/trajectories-command.js";
37
+ import {
38
+ registerWikiCaptureTrajectory,
39
+ registerWikiDistillSkills,
40
+ registerWikiRecallSkill,
41
+ } from "./lib/trajectory.js";
39
42
  import {
40
43
  ensureVaultStructure,
41
44
  fmtDate,
@@ -44,11 +47,13 @@ import {
44
47
  resolveVaultPaths,
45
48
  writeJson,
46
49
  } from "./lib/utils.js";
50
+ import { applySessionStartStatus } from "./lib/visible-status.js";
47
51
 
48
52
  /**
49
53
  * @zosmaai/pi-llm-wiki — LLM Wiki extension for Pi
50
54
  *
51
- * Registers 12 custom tools and installs guardrails:
55
+ * Registers 13 custom tools and installs guardrails (+3 agent-trajectory tools
56
+ * when `llm-wiki.trajectories` is enabled — opt-in, off by default, issue #80):
52
57
  * - wiki_recall (layered: personal + project vaults)
53
58
  * - wiki_retro (lightweight: single markdown file)
54
59
  * - wiki_capture_source (full 4-layer pipeline)
@@ -81,6 +86,23 @@ export default function (pi: ExtensionAPI) {
81
86
  registerWikiWatch(pi);
82
87
  registerWikiRecall(pi, runtime);
83
88
  registerWikiRetro(pi, runtime);
89
+ // Agent working-memory (issue #80): capture what the agent *did* (its
90
+ // tool-call trajectory), distill it into reusable skills, and recall past
91
+ // skills/cases. OPT-IN, default OFF — registered ONLY when enabled so the 3
92
+ // tools cost nothing in the system prompt for users who don't opt in.
93
+ //
94
+ // Gate on loadTaskConfig(process.cwd()) at factory time, NOT runtime.config:
95
+ // runtime.config is empty ({}) until ensureConfig runs in a later hook, so a
96
+ // runtime.config gate here would read as permanently off. Toggling the flag
97
+ // via /wiki-trajectories reloads the extension, re-running this gate.
98
+ const trajectoriesOn = trajectoriesEnabled(loadTaskConfig(process.cwd()));
99
+ if (trajectoriesOn) {
100
+ registerWikiCaptureTrajectory(pi);
101
+ registerWikiDistillSkills(pi);
102
+ registerWikiRecallSkill(pi);
103
+ }
104
+ // Activation surface for the above (always available so users can turn it on).
105
+ registerWikiTrajectoriesCommand(pi);
84
106
  // Model selection surface (issue #69): /wiki-model command to view/set the
85
107
  // background task model. The taskModel config field + resolveModel already
86
108
  // exist; this exposes them to the user (default stays the session model).
@@ -106,6 +128,10 @@ export default function (pi: ExtensionAPI) {
106
128
  try {
107
129
  const migration = migrateDoubledPersonalVault();
108
130
  if (migration && migration.moved.length > 0) {
131
+ // INTENTIONALLY NOT gated by `noticesEnabled` (issues #77, #84): this is
132
+ // a one-shot data-integrity recovery signal, not chat-noise. If the
133
+ // user has a broken doubled-dotdir layout we want them to see that it
134
+ // was fixed, even in quiet mode.
109
135
  ctx.ui.setStatus(
110
136
  "llm-wiki",
111
137
  `🧠 Personal wiki layout fixed: flattened ${migration.moved.length} entries out of ${migration.from} (see CHANGELOG)`,
@@ -147,17 +173,24 @@ export default function (pi: ExtensionAPI) {
147
173
  writeFileSync(join(vaultPaths.dotWiki, "WIKI_SCHEMA.md"), schema, "utf-8");
148
174
 
149
175
  needsTopicInference = true;
176
+ // INTENTIONALLY NOT gated by `noticesEnabled` (issues #77, #84): one-shot
177
+ // first-run setup signal. The user needs to know the wiki was just
178
+ // auto-created, regardless of quiet mode.
150
179
  ctx.ui.setStatus("llm-wiki", "🧠 Wiki created (inferring topic from first prompt…)");
151
180
  return;
152
181
  }
153
182
 
154
- ctx.ui.setStatus("llm-wiki", "🧠 LLM Wiki (13 tools, observe + recall active)");
155
-
156
- // Surface the active background task model (issue #69). Defaults to the
157
- // session model when no taskModel is configured.
183
+ // Surface the "wiki active" badge and the active background task model
184
+ // (issue #69), both gated by `llm-wiki.notices` (issue #77, regression
185
+ // fixed in #83, helper extracted in #84). `ensureConfig` MUST run first so
186
+ // the gate sees the loaded project settings.
158
187
  runtime.ensureConfig(process.cwd());
159
- const modelLabel = formatActiveModelLabel(runtime.config, (ctx.model as { id?: string })?.id);
160
- ctx.ui.setStatus(MODEL_STATUS_KEY, `🧠 wiki model: ${modelLabel}`);
188
+ applySessionStartStatus({
189
+ ui: ctx.ui,
190
+ runtime,
191
+ trajectoriesOn,
192
+ sessionModelId: (ctx.model as { id?: string })?.id,
193
+ });
161
194
 
162
195
  // One-time, user-visible session notice announcing the full wiki loop
163
196
  // (issue #77). Without this, recall/observe/retro are invisible — they
@@ -241,7 +274,10 @@ Then call wiki_bootstrap with the inferred topic and mode to finalize the setup.
241
274
  // large vault never floods the system prompt with inline previews.
242
275
  // includePersonal=false here mirrors the auto-injection search scope.
243
276
  const linksOnly = shouldUseLinksFirst(vaultPageCount(paths, false), runtime.config);
244
- const recallContext = formatRecallContext(results, { linksOnly });
277
+ const recallContext = formatRecallContext(results, {
278
+ linksOnly,
279
+ skillInlineMax: runtime.config?.recallSkillInlineMax,
280
+ });
245
281
  if (recallContext) {
246
282
  injectedContext += `\n\n${recallContext}`;
247
283
  }
@@ -260,8 +296,7 @@ Then call wiki_bootstrap with the inferred topic and mode to finalize the setup.
260
296
 
261
297
  // Always inject a visible wiki status footer, even when empty
262
298
  // This ensures the model knows the wiki is active and can use it
263
- injectedContext +=
264
- "\n\n<wiki_status>LLM Wiki active — use wiki_recall for deeper search, wiki_observe to record observations, wiki_retro to save insights.</wiki_status>";
299
+ injectedContext = appendWikiStatus(injectedContext);
265
300
 
266
301
  if (injectedContext === event.systemPrompt) return;
267
302
  return { systemPrompt: injectedContext };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * System-prompt context injection primitives (issue #87).
3
+ *
4
+ * The `before_agent_start` hook augments the chained system prompt with a
5
+ * visible wiki-status footer. This module isolates that append so it can be
6
+ * unit-tested for idempotency — a turn that aborts (network error / ESC) and is
7
+ * retried can carry the prior injection forward, and a naive append stacks the
8
+ * footer 2x, 3x, ...
9
+ */
10
+
11
+ /** The always-injected wiki-status footer (sans surrounding whitespace). */
12
+ export const WIKI_STATUS_BLOCK =
13
+ "<wiki_status>LLM Wiki active — use wiki_recall for deeper search, wiki_observe to record observations, wiki_retro to save insights.</wiki_status>";
14
+
15
+ /**
16
+ * Append the wiki-status footer to a system prompt — idempotently (issue #87).
17
+ *
18
+ * Strips any already-present footer (with its leading blank line) before
19
+ * appending exactly one. This makes the injection safe across aborted/retried
20
+ * agent starts that carry the prior injection forward in the chained system
21
+ * prompt, so the footer never stacks (2x, 3x, ...).
22
+ * See test/inject-idempotent.test.ts.
23
+ */
24
+ export function appendWikiStatus(systemPrompt: string): string {
25
+ const base = systemPrompt.split(`\n\n${WIKI_STATUS_BLOCK}`).join("");
26
+ return `${base}\n\n${WIKI_STATUS_BLOCK}`;
27
+ }
@@ -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(),
@@ -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` on the links you need to pull their 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,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
4
 
@@ -70,6 +70,19 @@ export interface TaskConfig {
70
70
  */
71
71
  recallLinksThreshold?: number;
72
72
 
73
+ /**
74
+ * Max characters of a distilled `skill`/`case` body inlined directly into a
75
+ * recall block before truncation (recall-adherence fix). Skills/cases are
76
+ * meant to be APPLIED immediately, so links-first recall inlines their short
77
+ * body instead of a bare link the agent often skips. Set to 0 to DISABLE
78
+ * inlining entirely — skills/cases then fall back to the normal link/preview
79
+ * path (pure links-first), and no page body is read at format time. Only
80
+ * relevant when the trajectories feature is on (skill/case pages exist only
81
+ * then). Default 1600. Clamped to a non-negative integer. Mirrors the
82
+ * `recallLinksThreshold` knob — the other context-window lever for recall.
83
+ */
84
+ recallSkillInlineMax?: number;
85
+
73
86
  /**
74
87
  * Surface wiki activity in the UI (issue #77). When enabled (the default),
75
88
  * the status line reflects recall hits and the periodic observe/retro
@@ -79,6 +92,14 @@ export interface TaskConfig {
79
92
  * not want any chat-level wiki notices.
80
93
  */
81
94
  notices?: boolean;
95
+
96
+ /**
97
+ * Agent-trajectory working-memory (capture → distill → recall), issue #80.
98
+ * OPT-IN, default OFF: only an explicit `trajectories: true` enables it.
99
+ * When off, the trajectory tools are never registered (see index.ts), so
100
+ * they cost nothing in the system prompt for the ~95% who don't use them.
101
+ */
102
+ trajectories?: boolean;
82
103
  }
83
104
 
84
105
  export const TASK_DEFAULTS: TaskConfig = {};
@@ -91,6 +112,15 @@ export function noticesEnabled(config: TaskConfig | undefined): boolean {
91
112
  return config?.notices !== false;
92
113
  }
93
114
 
115
+ /**
116
+ * Resolve whether agent-trajectory working-memory is enabled (issue #80).
117
+ * INVERSE polarity of `noticesEnabled`: defaults to `false`; only an explicit
118
+ * `trajectories: true` turns it on.
119
+ */
120
+ export function trajectoriesEnabled(config: TaskConfig | undefined): boolean {
121
+ return config?.trajectories === true;
122
+ }
123
+
94
124
  const SETTINGS_KEY = "llm-wiki";
95
125
 
96
126
  function readModelSpec(value: unknown): { provider: string; id: string } | undefined {
@@ -103,9 +133,8 @@ function readModelSpec(value: unknown): { provider: string; id: string } | undef
103
133
  }
104
134
 
105
135
  function readNamespacedConfig(path: string): Partial<TaskConfig> {
106
- if (!existsSync(path)) return {};
107
136
  try {
108
- const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
137
+ const raw = readSettingsObject(path);
109
138
  const nested = raw[SETTINGS_KEY];
110
139
  if (!nested || typeof nested !== "object") return {};
111
140
  const section = nested as Record<string, unknown>;
@@ -134,9 +163,18 @@ function readNamespacedConfig(path: string): Partial<TaskConfig> {
134
163
  out.recallLinksThreshold = Math.max(0, Math.floor(threshold));
135
164
  }
136
165
 
166
+ const inlineMax = section.recallSkillInlineMax;
167
+ if (typeof inlineMax === "number" && Number.isFinite(inlineMax)) {
168
+ out.recallSkillInlineMax = Math.max(0, Math.floor(inlineMax));
169
+ }
170
+
137
171
  if (typeof section.notices === "boolean") {
138
172
  out.notices = section.notices;
139
173
  }
174
+
175
+ if (typeof section.trajectories === "boolean") {
176
+ out.trajectories = section.trajectories;
177
+ }
140
178
  return out;
141
179
  } catch {
142
180
  return {};
@@ -160,6 +198,22 @@ export function parseModelRef(ref: string): { provider: string; id: string } | u
160
198
  return { provider, id };
161
199
  }
162
200
 
201
+ /**
202
+ * Read a settings JSON file as a plain object, or `{}` when it is absent or
203
+ * corrupt. Reads directly (no `existsSync` pre-check) so there is no
204
+ * check-then-use race: a missing file throws ENOENT, which the catch treats
205
+ * the same as an empty file.
206
+ */
207
+ function readSettingsObject(path: string): Record<string, unknown> {
208
+ try {
209
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
210
+ if (parsed && typeof parsed === "object") return parsed as Record<string, unknown>;
211
+ } catch {
212
+ // Missing or corrupt settings file: start from an empty object.
213
+ }
214
+ return {};
215
+ }
216
+
163
217
  /**
164
218
  * Persist (or clear) the wiki background `taskModel` in the PROJECT settings
165
219
  * file `<cwd>/.pi/settings.json` under the namespaced `llm-wiki` key (issue
@@ -173,16 +227,7 @@ export function persistTaskModel(
173
227
  model: { provider: string; id: string } | undefined,
174
228
  ): void {
175
229
  const settingsPath = join(cwd, ".pi", "settings.json");
176
- let raw: Record<string, unknown> = {};
177
- if (existsSync(settingsPath)) {
178
- try {
179
- const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
180
- if (parsed && typeof parsed === "object") raw = parsed as Record<string, unknown>;
181
- } catch {
182
- // Corrupt settings file: start from an empty object rather than throw.
183
- raw = {};
184
- }
185
- }
230
+ const raw = readSettingsObject(settingsPath);
186
231
 
187
232
  const existing = raw[SETTINGS_KEY];
188
233
  const section: Record<string, unknown> =
@@ -200,6 +245,33 @@ export function persistTaskModel(
200
245
  writeFileSync(settingsPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
201
246
  }
202
247
 
248
+ /**
249
+ * Persist the agent-trajectory flag in the PROJECT settings file
250
+ * `<cwd>/.pi/settings.json` under the namespaced `llm-wiki` key (issue #80).
251
+ * Mirrors `persistTaskModel`: project settings win in `loadTaskConfig`, other
252
+ * keys are preserved. `true` writes `trajectories: true`; `false` removes the
253
+ * key (reverting to the default-off behavior).
254
+ */
255
+ export function persistTrajectoriesEnabled(cwd: string, enabled: boolean): void {
256
+ const settingsPath = join(cwd, ".pi", "settings.json");
257
+ const raw = readSettingsObject(settingsPath);
258
+
259
+ const existing = raw[SETTINGS_KEY];
260
+ const section: Record<string, unknown> =
261
+ existing && typeof existing === "object" ? { ...(existing as Record<string, unknown>) } : {};
262
+
263
+ if (enabled) {
264
+ section.trajectories = true;
265
+ } else {
266
+ // biome-ignore lint/performance/noDelete: one-off settings rewrite, not a hot path; removing the key keeps the JSON clean (default is off)
267
+ delete section.trajectories;
268
+ }
269
+ raw[SETTINGS_KEY] = section;
270
+
271
+ mkdirSync(dirname(settingsPath), { recursive: true });
272
+ writeFileSync(settingsPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
273
+ }
274
+
203
275
  export function loadTaskConfig(cwd: string): TaskConfig {
204
276
  let globalPath: string;
205
277
  try {