@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.
- package/CHANGELOG.md +1 -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 +51 -16
- package/extensions/llm-wiki/lib/inject.ts +27 -0
- package/extensions/llm-wiki/lib/metadata.ts +34 -1
- package/extensions/llm-wiki/lib/recall.ts +107 -8
- package/extensions/llm-wiki/lib/task-config.ts +85 -13
- package/extensions/llm-wiki/lib/tools.ts +120 -15
- 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,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
|
-
|
|
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
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
//
|
|
157
|
-
//
|
|
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
|
-
|
|
160
|
-
|
|
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, {
|
|
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:
|
|
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
|
-
/**
|
|
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` on the
|
|
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,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
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 {
|