pi-para 0.8.3 → 0.8.5
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 +17 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +4 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +22 -1
- package/dist/context.js.map +1 -1
- package/dist/lint.d.ts +6 -0
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +62 -0
- package/dist/lint.js.map +1 -1
- package/dist/questions.d.ts +99 -0
- package/dist/questions.d.ts.map +1 -0
- package/dist/questions.js +200 -0
- package/dist/questions.js.map +1 -0
- package/dist/templates/prompts.d.ts +3 -3
- package/dist/templates/prompts.d.ts.map +1 -1
- package/dist/templates/prompts.js +8 -2
- package/dist/templates/prompts.js.map +1 -1
- package/dist/tools/edit-read.d.ts +2 -0
- package/dist/tools/edit-read.d.ts.map +1 -1
- package/dist/tools/edit-read.js +19 -4
- package/dist/tools/edit-read.js.map +1 -1
- package/dist/tools/lint.d.ts +1 -0
- package/dist/tools/lint.d.ts.map +1 -1
- package/dist/tools/lint.js +2 -0
- package/dist/tools/lint.js.map +1 -1
- package/dist/tools/query.d.ts +2 -2
- package/dist/tools/query.d.ts.map +1 -1
- package/dist/tools/query.js +12 -1
- package/dist/tools/query.js.map +1 -1
- package/dist/tools/write.d.ts.map +1 -1
- package/dist/tools/write.js +10 -1
- package/dist/tools/write.js.map +1 -1
- package/dist/tools.d.ts +1 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js.map +1 -1
- package/dist/wiki.d.ts.map +1 -1
- package/dist/wiki.js +4 -1
- package/dist/wiki.js.map +1 -1
- package/package.json +6 -2
- package/skills/para/SKILL.md +9 -0
- package/src/config.ts +3 -0
- package/src/context.ts +27 -1
- package/src/lint.ts +77 -0
- package/src/questions.ts +287 -0
- package/src/templates/prompts.ts +8 -2
- package/src/templates/schema.md +4 -1
- package/src/tools/edit-read.ts +23 -4
- package/src/tools/lint.ts +3 -1
- package/src/tools/query.ts +14 -2
- package/src/tools/write.ts +10 -1
- package/src/tools.ts +1 -1
- package/src/wiki.ts +4 -1
package/src/questions.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open questions: the "## Open Questions" section of a page, as tracked items.
|
|
3
|
+
*
|
|
4
|
+
* A question is one bullet line. Written by an agent it carries the date it was asked and, when the host has one, the
|
|
5
|
+
* session that asked it:
|
|
6
|
+
*
|
|
7
|
+
* - [ ] (2026-10-18, #993) Does the picker need a "key no longer works" state?
|
|
8
|
+
*
|
|
9
|
+
* When it is answered, the line moves to "## Resolved Questions", ticked, with the date and session that answered it,
|
|
10
|
+
* and the answer after an arrow:
|
|
11
|
+
*
|
|
12
|
+
* - [x] (2026-10-19, #1204) Does the picker need a "key no longer works" state? → yes: machine health (#72)
|
|
13
|
+
*
|
|
14
|
+
* Older pages hold plain bullets (`- question`); they count as open, asked when the page was last updated. A ticked or
|
|
15
|
+
* struck-through line left in the open section counts as resolved.
|
|
16
|
+
*
|
|
17
|
+
* The rest of pi-para uses this to show a page's open questions when it is read or found, to list the wiki's open
|
|
18
|
+
* questions for a session's prompt, to point out questions a page just written may answer, and to flag stale ones.
|
|
19
|
+
*/
|
|
20
|
+
import { matchesScope, type ProjectScope } from "./scope.js";
|
|
21
|
+
import { stat } from "node:fs/promises";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { listPages, type ParaCategory, readPage } from "./wiki.js";
|
|
24
|
+
|
|
25
|
+
export const OPEN_SECTION = "Open Questions";
|
|
26
|
+
export const RESOLVED_SECTION = "Resolved Questions";
|
|
27
|
+
|
|
28
|
+
/** One question line, as written. */
|
|
29
|
+
export interface QuestionLine {
|
|
30
|
+
/** The line itself, as it is in the page. */
|
|
31
|
+
line: string;
|
|
32
|
+
/** The question's words, without its mark, date, and session. */
|
|
33
|
+
text: string;
|
|
34
|
+
/** When it was asked (YYYY-MM-DD), if written. */
|
|
35
|
+
date?: string;
|
|
36
|
+
/** The session that asked it ("#993"), if written. */
|
|
37
|
+
session?: string;
|
|
38
|
+
/** Ticked, or struck through. */
|
|
39
|
+
done: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** An open question on a page. */
|
|
43
|
+
export interface OpenQuestion extends QuestionLine {
|
|
44
|
+
/** `category/slug`. */
|
|
45
|
+
page: string;
|
|
46
|
+
title: string;
|
|
47
|
+
scope: string[];
|
|
48
|
+
/** When it was asked: its own date, else the page's last update (YYYY-MM-DD). */
|
|
49
|
+
asked: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const LINE = /^\s*[-*]\s+(?:\[( |x|X)\]\s+)?(?:\((\d{4}-\d{2}-\d{2})(?:,\s*(#?[\w.:/-]+))?\)\s+)?(.+?)\s*$/;
|
|
53
|
+
|
|
54
|
+
/** The lines of a `## <name>` section of a page's body, up to the next heading of level 1 or 2. */
|
|
55
|
+
export function sectionLines(body: string, name: string): string[] {
|
|
56
|
+
const lines = body.split("\n");
|
|
57
|
+
const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${name.toLowerCase()}`);
|
|
58
|
+
|
|
59
|
+
if (start === -1) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const rest = lines.slice(start + 1);
|
|
64
|
+
const end = rest.findIndex((line) => /^#{1,2}\s/.test(line));
|
|
65
|
+
|
|
66
|
+
return end === -1 ? rest : rest.slice(0, end);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The question lines of a section: its bullets, each read as a question. Placeholder bullets ("none") are left out. */
|
|
70
|
+
export function parseQuestions(body: string, name = OPEN_SECTION): QuestionLine[] {
|
|
71
|
+
return sectionLines(body, name).flatMap((line) => {
|
|
72
|
+
const match = LINE.exec(line);
|
|
73
|
+
|
|
74
|
+
if (match === null) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const [, mark, date, session, words] = match;
|
|
79
|
+
const struck = /^~~.*~~$/.test(words!.trim());
|
|
80
|
+
const text = words!.replace(/^~~|~~$/g, "").trim();
|
|
81
|
+
|
|
82
|
+
if (/^(none|n\/a|-|—|\(none\)|nothing yet\.?)$/i.test(text) || /^\[.*gaps in knowledge.*\]$/i.test(text)) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return [
|
|
87
|
+
{
|
|
88
|
+
line,
|
|
89
|
+
text,
|
|
90
|
+
...(date === undefined ? {} : { date }),
|
|
91
|
+
...(session === undefined ? {} : { session: session.startsWith("#") ? session : `#${session}` }),
|
|
92
|
+
done: (mark !== undefined && mark !== " ") || struck,
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A page's open questions: the section's lines that are not ticked. */
|
|
99
|
+
export function openQuestionsOf(body: string): QuestionLine[] {
|
|
100
|
+
return parseQuestions(body).filter((question) => !question.done);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A question line as an agent writes it: `- [ ] (date, #session) text`. */
|
|
104
|
+
export function questionLine(text: string, { date, session }: { date: string; session?: string }): string {
|
|
105
|
+
return `- [ ] (${date}${session === undefined ? "" : `, ${session.startsWith("#") ? session : `#${session}`}`}) ${text.trim()}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A resolved line: `- [x] (date, #session) text → answer`. */
|
|
109
|
+
export function resolvedLine(text: string, answer: string, { date, session }: { date: string; session?: string }): string {
|
|
110
|
+
return `- [x] (${date}${session === undefined ? "" : `, ${session.startsWith("#") ? session : `#${session}`}`}) ${text.trim()} → ${answer.trim()}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whole days from `date` (YYYY-MM-DD or ISO) to `now`. */
|
|
114
|
+
export function daysSince(date: string, now = Date.now()): number {
|
|
115
|
+
const at = Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date);
|
|
116
|
+
|
|
117
|
+
return Number.isNaN(at) ? 0 : Math.max(0, Math.floor((now - at) / 86_400_000));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Each page's open questions as last read, by file, with the file's mtime and size: a page reads again only when it changed. */
|
|
121
|
+
const pageCache = new Map<string, { mtimeMs: number; size: number; questions: Omit<OpenQuestion, "page">[] }>();
|
|
122
|
+
|
|
123
|
+
/** A page's open questions, from the cache while its file is unchanged. */
|
|
124
|
+
async function pageQuestions(wikiDir: string, category: ParaCategory, slug: string): Promise<Omit<OpenQuestion, "page">[]> {
|
|
125
|
+
const file = join(wikiDir, category, `${slug}.md`);
|
|
126
|
+
let info;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
info = await stat(file);
|
|
130
|
+
} catch {
|
|
131
|
+
pageCache.delete(file);
|
|
132
|
+
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const cached = pageCache.get(file);
|
|
137
|
+
|
|
138
|
+
if (cached !== undefined && cached.mtimeMs === info.mtimeMs && cached.size === info.size) {
|
|
139
|
+
return cached.questions;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const page = await readPage(wikiDir, category, slug);
|
|
143
|
+
const updated = page === null ? "" : (page.frontmatter.updated || page.frontmatter.created || "").slice(0, 10);
|
|
144
|
+
const questions =
|
|
145
|
+
page === null
|
|
146
|
+
? []
|
|
147
|
+
: openQuestionsOf(page.body).map((question) => ({
|
|
148
|
+
...question,
|
|
149
|
+
title: page.frontmatter.title,
|
|
150
|
+
scope: page.frontmatter.scope,
|
|
151
|
+
asked: question.date ?? updated,
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
pageCache.set(file, { mtimeMs: info.mtimeMs, size: info.size, questions });
|
|
155
|
+
|
|
156
|
+
return questions;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Every open question in the wiki, oldest first. Archives are left out unless asked for: a finished project's
|
|
161
|
+
* questions are history. With `scope`, the ones on pages in that scope come first (`inScope`). Pages are read again
|
|
162
|
+
* only when their file changed.
|
|
163
|
+
*/
|
|
164
|
+
export async function collectOpenQuestions(
|
|
165
|
+
wikiDir: string,
|
|
166
|
+
{ scope, includeArchives = false }: { scope?: ProjectScope; includeArchives?: boolean } = {},
|
|
167
|
+
): Promise<(OpenQuestion & { inScope: boolean })[]> {
|
|
168
|
+
const refs = await listPages(wikiDir);
|
|
169
|
+
const found: (OpenQuestion & { inScope: boolean })[] = [];
|
|
170
|
+
|
|
171
|
+
for (const ref of refs) {
|
|
172
|
+
if (ref.category === "archives" && !includeArchives) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const question of await pageQuestions(wikiDir, ref.category, ref.slug)) {
|
|
177
|
+
found.push({
|
|
178
|
+
...question,
|
|
179
|
+
page: `${ref.category}/${ref.slug}`,
|
|
180
|
+
inScope: scope === undefined ? true : matchesScope(question.scope, scope),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return found.sort((a, b) => Number(b.inScope) - Number(a.inScope) || a.asked.localeCompare(b.asked));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Open questions as short Markdown lines: `- «text» on [[slug]] (asked date, #session)`. */
|
|
189
|
+
export function formatOpenQuestions(questions: readonly OpenQuestion[], { max = 10, chars = 180 } = {}): string {
|
|
190
|
+
const shown = questions.slice(0, max).map((question) => {
|
|
191
|
+
const text = question.text.length > chars ? `${question.text.slice(0, chars - 1)}…` : question.text;
|
|
192
|
+
const slug = question.page.split("/").pop();
|
|
193
|
+
|
|
194
|
+
return `- ${text} — on [[${slug}]] (${question.page}), asked ${question.asked}${question.session ? `, ${question.session}` : ""}`;
|
|
195
|
+
});
|
|
196
|
+
const more = questions.length - shown.length;
|
|
197
|
+
|
|
198
|
+
return [...shown, ...(more > 0 ? [`- …and ${more} more (wiki_lint lists them all)`] : [])].join("\n");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** How to resolve one, in the words the tools and prompts use. */
|
|
202
|
+
export const RESOLVE_HOW =
|
|
203
|
+
'To resolve one, wiki_edit its page: remove its line from "## Open Questions" and add under "## Resolved Questions" `- [x] (YYYY-MM-DD, #session) <question> → <answer>` (today, and your session if you know it).';
|
|
204
|
+
|
|
205
|
+
// -- Questions a page just written may answer ---------------------------------
|
|
206
|
+
|
|
207
|
+
const STOP = new Set(
|
|
208
|
+
"a an and are as at be been but by can could did do does for from had has have how if in into is it its of on or should so than that the their then there these this those to was were what when where whether which who why will with would yet not no any all our we you your".split(
|
|
209
|
+
" ",
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
/** The meaningful words of a text: lower case, without stop words or very short ones. */
|
|
214
|
+
function words(text: string): Set<string> {
|
|
215
|
+
return new Set(
|
|
216
|
+
text
|
|
217
|
+
.toLowerCase()
|
|
218
|
+
.replace(/[`*_[\]()<>"'“”‘’.,;:!?/\\|{}=+]/g, " ")
|
|
219
|
+
.split(/\s+/)
|
|
220
|
+
.filter((word) => word.length > 2 && !STOP.has(word)),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Of a question's meaningful words, how many the text has, and the share. */
|
|
225
|
+
function overlap(question: string, text: Set<string>): { shared: number; share: number } {
|
|
226
|
+
const own = [...words(question)];
|
|
227
|
+
const shared = own.filter((word) => text.has(word)).length;
|
|
228
|
+
|
|
229
|
+
return { shared, share: own.length === 0 ? 0 : shared / own.length };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** A page's body without its open questions section: what it says, not what it asks. */
|
|
233
|
+
function withoutOpenSection(body: string): string {
|
|
234
|
+
const lines = body.split("\n");
|
|
235
|
+
const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${OPEN_SECTION.toLowerCase()}`);
|
|
236
|
+
|
|
237
|
+
if (start === -1) {
|
|
238
|
+
return body;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const end = lines.findIndex((line, index) => index > start && /^#{1,2}\s/.test(line));
|
|
242
|
+
|
|
243
|
+
return [...lines.slice(0, start), ...(end === -1 ? [] : lines.slice(end))].join("\n");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Open questions anywhere in the wiki that `written` (pages just written, by `category/slug`, with their new body) may
|
|
248
|
+
* answer, best first: the new text, without its own open questions, has at least half of a question's meaningful
|
|
249
|
+
* words, and two or more of them. Questions on the written pages count too: a page that answers its own question should
|
|
250
|
+
* resolve it. Fast and local, for every write; lint finds more by meaning once the search index has the new text.
|
|
251
|
+
*/
|
|
252
|
+
export async function questionsMaybeAnswered(
|
|
253
|
+
wikiDir: string,
|
|
254
|
+
written: readonly { page: string; text: string }[],
|
|
255
|
+
{ max = 5 }: { max?: number } = {},
|
|
256
|
+
): Promise<(OpenQuestion & { why: string })[]> {
|
|
257
|
+
if (written.length === 0) {
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const open = await collectOpenQuestions(wikiDir);
|
|
262
|
+
const text = words(written.map((each) => withoutOpenSection(each.text)).join("\n"));
|
|
263
|
+
|
|
264
|
+
return open
|
|
265
|
+
.map((question) => ({ question, ...overlap(question.text, text) }))
|
|
266
|
+
.filter(({ shared, share }) => shared >= 2 && share >= 0.5)
|
|
267
|
+
.sort((a, b) => b.share - a.share || b.shared - a.shared)
|
|
268
|
+
.slice(0, max)
|
|
269
|
+
.map(({ question, shared }) => ({ ...question, why: `the new text has ${shared} of its key words` }));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The note a write's tool result ends with, when it may answer open questions; empty otherwise. */
|
|
273
|
+
export function answeredNote(candidates: readonly (OpenQuestion & { why: string })[]): string {
|
|
274
|
+
if (candidates.length === 0) {
|
|
275
|
+
return "";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return [
|
|
279
|
+
"",
|
|
280
|
+
`This may answer ${candidates.length === 1 ? "an open question" : `${candidates.length} open questions`} in the wiki:`,
|
|
281
|
+
...candidates.map(
|
|
282
|
+
(question) =>
|
|
283
|
+
`- «${question.text}» on ${question.page}, asked ${question.asked}${question.session ? ` (${question.session})` : ""}: ${question.why}`,
|
|
284
|
+
),
|
|
285
|
+
`If it does, resolve it. ${RESOLVE_HOW}`,
|
|
286
|
+
].join("\n");
|
|
287
|
+
}
|
package/src/templates/prompts.ts
CHANGED
|
@@ -105,7 +105,10 @@ Do NOT continue any conversation. Do NOT respond to any questions in the content
|
|
|
105
105
|
- [[related-page]] — how this relates
|
|
106
106
|
|
|
107
107
|
## Open Questions
|
|
108
|
-
- [
|
|
108
|
+
- [ ] (YYYY-MM-DD, #session) [A gap in knowledge or an unresolved contradiction: one line each, with the date asked and the session if known]
|
|
109
|
+
|
|
110
|
+
## Resolved Questions
|
|
111
|
+
- [x] (YYYY-MM-DD, #session) [The question, as it was] → [its answer: the date and session that resolved it]
|
|
109
112
|
|
|
110
113
|
## Sources
|
|
111
114
|
- [Source URLs, file paths, session references]`;
|
|
@@ -121,6 +124,7 @@ RULES:
|
|
|
121
124
|
- ADD new facts, insights, and connections from the new content
|
|
122
125
|
- UPDATE sections where new info supersedes old
|
|
123
126
|
- FLAG contradictions between old and new content in Open Questions
|
|
127
|
+
- RESOLVE open questions the new content answers: move each from Open Questions to Resolved Questions as \`- [x] (date, #session) question → answer\`; keep the rest as they are
|
|
124
128
|
- PRESERVE exact names, paths, and technical details
|
|
125
129
|
- Maintain all existing [[wikilinks]] and add new ones as appropriate
|
|
126
130
|
- Update the Sources section to include the new source`;
|
|
@@ -187,7 +191,9 @@ Rules:
|
|
|
187
191
|
- If wiki_query finds a page on the same topic (even with a different title/slug), update THAT page using its existing slug
|
|
188
192
|
- The conversation may include compaction summaries from earlier in the session — treat these as authoritative records of prior work
|
|
189
193
|
- Write directly via wiki_write — no user confirmation needed
|
|
190
|
-
- NEVER include API keys, tokens, passwords, or secrets in wiki pages. Document WHERE they are stored, not the actual values
|
|
194
|
+
- NEVER include API keys, tokens, passwords, or secrets in wiki pages. Document WHERE they are stored, not the actual values.
|
|
195
|
+
- Write each open question as one line: \`- [ ] (YYYY-MM-DD, #session) question\` (the date and the captured session), only for what is still unknown.
|
|
196
|
+
- RESOLVE open questions the session answered, on any page (the message lists the wiki's open questions): wiki_edit the page, remove the line from "## Open Questions", and add under "## Resolved Questions" \`- [x] (YYYY-MM-DD, #session) question → answer\` with the captured session. Only when the session really answers it; leave the rest.`;
|
|
191
197
|
|
|
192
198
|
/**
|
|
193
199
|
* Prompt template for auto-capture at session_shutdown.
|
package/src/templates/schema.md
CHANGED
|
@@ -86,7 +86,10 @@ The `links` frontmatter field is auto-synced from body `[[wikilinks]]` — don't
|
|
|
86
86
|
- [[related-page]] — how this relates
|
|
87
87
|
|
|
88
88
|
## Open Questions
|
|
89
|
-
- [
|
|
89
|
+
- [ ] (YYYY-MM-DD, #session) [A gap in knowledge or an unresolved contradiction: one line each, with the date asked and the session if known]
|
|
90
|
+
|
|
91
|
+
## Resolved Questions
|
|
92
|
+
- [x] (YYYY-MM-DD, #session) [The question, as it was] → [its answer: the date and session that resolved it]
|
|
90
93
|
|
|
91
94
|
## Sources
|
|
92
95
|
- [Source URLs, file paths, session references]
|
package/src/tools/edit-read.ts
CHANGED
|
@@ -7,6 +7,22 @@ import { redactSecrets } from "../redact.js";
|
|
|
7
7
|
import { appendLog, gitCommit, listPages, readIndex, readPage, writePage, type WikiPage } from "../wiki.js";
|
|
8
8
|
import type { WikiReadDetails, WikiWriteDetails } from "./schemas.js";
|
|
9
9
|
import { parsePagePath, scheduleWikiMaintenance } from "./shared.js";
|
|
10
|
+
import { answeredNote, openQuestionsOf, questionsMaybeAnswered, RESOLVE_HOW } from "../questions.js";
|
|
11
|
+
|
|
12
|
+
/** A read page's open questions, first, so whoever reads it sees what is still unknown and can resolve what it knows. */
|
|
13
|
+
export function openQuestionsNote(body: string): string {
|
|
14
|
+
const open = openQuestionsOf(body);
|
|
15
|
+
|
|
16
|
+
if (open.length === 0) {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return [
|
|
21
|
+
"",
|
|
22
|
+
`Open questions on this page (${open.length}): resolve any you can answer. ${RESOLVE_HOW}`,
|
|
23
|
+
...open.map((question) => `- ${question.text}${question.date ? ` (asked ${question.date}${question.session ? `, ${question.session}` : ""})` : ""}`),
|
|
24
|
+
].join("\n");
|
|
25
|
+
}
|
|
10
26
|
|
|
11
27
|
// -- Factory: wiki_read execute ----------------------------------------------
|
|
12
28
|
|
|
@@ -69,7 +85,8 @@ export function createEditExecute(
|
|
|
69
85
|
|
|
70
86
|
let editedBody = existing.body;
|
|
71
87
|
for (const edit of params.edits) {
|
|
72
|
-
|
|
88
|
+
// A function, so "$&", "$`", "$'" in the new text stay as written (a string replacement reads them as patterns).
|
|
89
|
+
editedBody = editedBody.replace(edit.oldText, () => edit.newText);
|
|
73
90
|
}
|
|
74
91
|
|
|
75
92
|
// Surgical edit path: do not scan/autolink/rebuild/reindex the whole wiki.
|
|
@@ -107,8 +124,10 @@ export function createEditExecute(
|
|
|
107
124
|
await gitCommit(wikiDir, params.logSummary ?? `wiki_edit: ${pagePath}`);
|
|
108
125
|
scheduleWikiMaintenance(wikiDir, store, markDirty);
|
|
109
126
|
|
|
127
|
+
const maybe = answeredNote(await questionsMaybeAnswered(wikiDir, [{ page: pagePath, text: linkedBody }]));
|
|
128
|
+
|
|
110
129
|
return {
|
|
111
|
-
content: [{ type: "text", text: `Edited ${pagePath}. Scheduled background index/search refresh
|
|
130
|
+
content: [{ type: "text", text: `Edited ${pagePath}. Scheduled background index/search refresh.${maybe}` }],
|
|
112
131
|
details: {
|
|
113
132
|
pagesWritten: [pagePath],
|
|
114
133
|
pagesSkipped: [],
|
|
@@ -152,7 +171,7 @@ export function createReadExecute(wikiDir: string) {
|
|
|
152
171
|
.join("\n");
|
|
153
172
|
|
|
154
173
|
return {
|
|
155
|
-
content: [{ type: "text", text: `${header}\n\n${page.body}` }],
|
|
174
|
+
content: [{ type: "text", text: `${header}${openQuestionsNote(page.body)}\n\n${page.body}` }],
|
|
156
175
|
details: { found: true, path: `${parsed.category}/${parsed.slug}` },
|
|
157
176
|
};
|
|
158
177
|
}
|
|
@@ -178,7 +197,7 @@ export function createReadExecute(wikiDir: string) {
|
|
|
178
197
|
.join("\n");
|
|
179
198
|
|
|
180
199
|
return {
|
|
181
|
-
content: [{ type: "text", text: `${header}\n\n${page.body}` }],
|
|
200
|
+
content: [{ type: "text", text: `${header}${openQuestionsNote(page.body)}\n\n${page.body}` }],
|
|
182
201
|
details: { found: true, path: `${ref.category}/${ref.slug}` },
|
|
183
202
|
};
|
|
184
203
|
}
|
package/src/tools/lint.ts
CHANGED
|
@@ -12,7 +12,7 @@ export function createLintExecute(
|
|
|
12
12
|
wikiDir: string,
|
|
13
13
|
store: QMDStore,
|
|
14
14
|
markDirty: () => void,
|
|
15
|
-
getLintConfig?: () => { autoFix: boolean; staleDays: number },
|
|
15
|
+
getLintConfig?: () => { autoFix: boolean; staleDays: number; questionDays?: number },
|
|
16
16
|
) {
|
|
17
17
|
return async (
|
|
18
18
|
params: { autoFix?: boolean },
|
|
@@ -21,6 +21,8 @@ export function createLintExecute(
|
|
|
21
21
|
const report = await lintWiki(wikiDir, {
|
|
22
22
|
autoFix: params.autoFix ?? configured?.autoFix ?? true,
|
|
23
23
|
staleDays: configured?.staleDays ?? 90,
|
|
24
|
+
questionDays: configured?.questionDays ?? 7,
|
|
25
|
+
store,
|
|
24
26
|
});
|
|
25
27
|
if (report.fixed.length > 0) {
|
|
26
28
|
scheduleWikiMaintenance(wikiDir, store, markDirty);
|
package/src/tools/query.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import type { QMDStore } from "qmd-engine";
|
|
3
3
|
import type { ProjectScope } from "../scope.js";
|
|
4
|
-
import type
|
|
4
|
+
import { readPage, type ParaCategory } from "../wiki.js";
|
|
5
5
|
import { queryWiki as queryWikiLib, formatQueryResults } from "../query.js";
|
|
6
6
|
import { QUERY_PROMPT } from "../templates/prompts.js";
|
|
7
7
|
import type { WikiQueryDetails } from "./schemas.js";
|
|
8
|
+
import { openQuestionsOf, RESOLVE_HOW } from "../questions.js";
|
|
8
9
|
|
|
9
10
|
// -- Factory: wiki_query execute ---------------------------------------------
|
|
10
11
|
|
|
11
12
|
export function createQueryExecute(
|
|
12
|
-
|
|
13
|
+
wikiDir: string,
|
|
13
14
|
store: QMDStore,
|
|
14
15
|
getScope: () => ProjectScope,
|
|
15
16
|
getGraphBoost?: () => boolean,
|
|
@@ -42,6 +43,16 @@ export function createQueryExecute(
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
const formatted = formatQueryResults(result.results);
|
|
46
|
+
// The found pages' open questions: what is still unknown about this topic, and may be answered now.
|
|
47
|
+
const open: string[] = [];
|
|
48
|
+
|
|
49
|
+
for (const found of result.results) {
|
|
50
|
+
const page = await readPage(wikiDir, found.page.category, found.page.slug);
|
|
51
|
+
|
|
52
|
+
for (const question of page === null ? [] : openQuestionsOf(page.body)) {
|
|
53
|
+
open.push(`- ${question.text} — on ${found.page.category}/${found.page.slug}${question.date ? `, asked ${question.date}${question.session ? ` (${question.session})` : ""}` : ""}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
45
56
|
const toolResultText = [
|
|
46
57
|
QUERY_PROMPT,
|
|
47
58
|
"",
|
|
@@ -49,6 +60,7 @@ export function createQueryExecute(
|
|
|
49
60
|
`Scope: ${result.wasGlobal ? "global (all scopes)" : scope.name}`,
|
|
50
61
|
`Results: ${result.results.length}`,
|
|
51
62
|
"",
|
|
63
|
+
...(open.length === 0 ? [] : [`## Open questions on these pages (${open.length})`, "If what you know or find answers one, resolve it. " + RESOLVE_HOW, ...open, ""]),
|
|
52
64
|
formatted,
|
|
53
65
|
].join("\n");
|
|
54
66
|
|
package/src/tools/write.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { normalizeTags, normalizeScopes } from "../tag-registry.js";
|
|
|
5
5
|
import { redactSecrets } from "../redact.js";
|
|
6
6
|
import { autoLinkSlugs, syncFrontmatterLinks } from "../link-utils.js";
|
|
7
7
|
import { validateFrontmatter } from "../frontmatter.js";
|
|
8
|
+
import { answeredNote, questionsMaybeAnswered } from "../questions.js";
|
|
8
9
|
import { appendLog, gitCommit, listPageSlugs, readPage, rebuildIndex, writePage, type ParaCategory, type WikiPage } from "../wiki.js";
|
|
9
10
|
import type { WikiWriteDetails } from "./schemas.js";
|
|
10
11
|
import { scheduleWikiMaintenance } from "./shared.js";
|
|
@@ -35,6 +36,8 @@ export function createWriteExecute(
|
|
|
35
36
|
): Promise<AgentToolResult<WikiWriteDetails>> => {
|
|
36
37
|
const pagesWritten: string[] = [];
|
|
37
38
|
const pagesSkipped: string[] = [];
|
|
39
|
+
/** What was written, for the open questions it may answer. */
|
|
40
|
+
const writtenBodies: { page: string; text: string }[] = [];
|
|
38
41
|
const now = new Date().toISOString();
|
|
39
42
|
const scope = getScope();
|
|
40
43
|
|
|
@@ -92,7 +95,8 @@ export function createWriteExecute(
|
|
|
92
95
|
}
|
|
93
96
|
let editedBody = existing.body;
|
|
94
97
|
for (const edit of edits) {
|
|
95
|
-
|
|
98
|
+
// A function, so "$&", "$`", "$'" in the new text stay as written (a string replacement reads them as patterns).
|
|
99
|
+
editedBody = editedBody.replace(edit.oldText, () => edit.newText);
|
|
96
100
|
}
|
|
97
101
|
const linkedBody = redactSecrets(autoLinkSlugs(editedBody, allSlugs, slug)).text;
|
|
98
102
|
const rawScope = pageSpec.scope.length > 0 ? pageSpec.scope : existing.frontmatter.scope;
|
|
@@ -112,6 +116,7 @@ export function createWriteExecute(
|
|
|
112
116
|
body: linkedBody,
|
|
113
117
|
};
|
|
114
118
|
await writePage(wikiDir, updatedPage);
|
|
119
|
+
writtenBodies.push({ page: pagePath, text: updatedPage.body });
|
|
115
120
|
pagesWritten.push(pagePath + (errors.length > 0 ? ` (${errors.length} edit(s) failed)` : ""));
|
|
116
121
|
} else if (pageSpec.mode === "append" && existing) {
|
|
117
122
|
// Append to existing page body
|
|
@@ -131,6 +136,7 @@ export function createWriteExecute(
|
|
|
131
136
|
},
|
|
132
137
|
};
|
|
133
138
|
await writePage(wikiDir, updatedPage);
|
|
139
|
+
writtenBodies.push({ page: pagePath, text: updatedPage.body });
|
|
134
140
|
pagesWritten.push(pagePath);
|
|
135
141
|
} else if (pageSpec.mode === "replace" && existing) {
|
|
136
142
|
// Replace existing page body, update frontmatter
|
|
@@ -153,6 +159,7 @@ export function createWriteExecute(
|
|
|
153
159
|
body: linkedBody,
|
|
154
160
|
};
|
|
155
161
|
await writePage(wikiDir, updatedPage);
|
|
162
|
+
writtenBodies.push({ page: pagePath, text: updatedPage.body });
|
|
156
163
|
pagesWritten.push(pagePath);
|
|
157
164
|
} else {
|
|
158
165
|
// Create new page (or mode=create/replace on non-existing)
|
|
@@ -177,6 +184,7 @@ export function createWriteExecute(
|
|
|
177
184
|
body: linkedBody,
|
|
178
185
|
};
|
|
179
186
|
await writePage(wikiDir, newPage);
|
|
187
|
+
writtenBodies.push({ page: pagePath, text: newPage.body });
|
|
180
188
|
pagesWritten.push(pagePath);
|
|
181
189
|
}
|
|
182
190
|
}
|
|
@@ -236,6 +244,7 @@ export function createWriteExecute(
|
|
|
236
244
|
summary,
|
|
237
245
|
indexUpdated ? "Updated index.md." : "",
|
|
238
246
|
logAppended ? `Logged: ${params.logSummary}` : "",
|
|
247
|
+
answeredNote(await questionsMaybeAnswered(wikiDir, writtenBodies)).trim(),
|
|
239
248
|
]
|
|
240
249
|
.filter(Boolean)
|
|
241
250
|
.join("\n"),
|
package/src/tools.ts
CHANGED
|
@@ -39,7 +39,7 @@ export function registerTools(
|
|
|
39
39
|
getScope: () => ProjectScope,
|
|
40
40
|
markDirty: () => void,
|
|
41
41
|
getGraphBoost?: () => boolean,
|
|
42
|
-
getLintConfig?: () => { autoFix: boolean; staleDays: number },
|
|
42
|
+
getLintConfig?: () => { autoFix: boolean; staleDays: number; questionDays?: number },
|
|
43
43
|
): void {
|
|
44
44
|
const ingestExec = createIngestExecute(wikiDir, store, getScope);
|
|
45
45
|
const queryExec = createQueryExecute(wikiDir, store, getScope, getGraphBoost);
|
package/src/wiki.ts
CHANGED
|
@@ -121,7 +121,10 @@ links:
|
|
|
121
121
|
- [[related-page]] — how this relates
|
|
122
122
|
|
|
123
123
|
## Open Questions
|
|
124
|
-
- [
|
|
124
|
+
- [ ] (YYYY-MM-DD, #session) [A gap in knowledge or an unresolved contradiction: one line each, with the date asked and the session if known]
|
|
125
|
+
|
|
126
|
+
## Resolved Questions
|
|
127
|
+
- [x] (YYYY-MM-DD, #session) [The question, as it was] → [its answer: the date and session that resolved it]
|
|
125
128
|
|
|
126
129
|
## Sources
|
|
127
130
|
- [Source URLs, file paths, session references]
|