pi-para 0.8.4 → 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 +12 -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 +17 -3
- 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 +8 -0
- 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 +21 -3
- package/src/tools/lint.ts +3 -1
- package/src/tools/query.ts +14 -2
- package/src/tools/write.ts +8 -0
- 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
|
|
|
@@ -108,8 +124,10 @@ export function createEditExecute(
|
|
|
108
124
|
await gitCommit(wikiDir, params.logSummary ?? `wiki_edit: ${pagePath}`);
|
|
109
125
|
scheduleWikiMaintenance(wikiDir, store, markDirty);
|
|
110
126
|
|
|
127
|
+
const maybe = answeredNote(await questionsMaybeAnswered(wikiDir, [{ page: pagePath, text: linkedBody }]));
|
|
128
|
+
|
|
111
129
|
return {
|
|
112
|
-
content: [{ type: "text", text: `Edited ${pagePath}. Scheduled background index/search refresh
|
|
130
|
+
content: [{ type: "text", text: `Edited ${pagePath}. Scheduled background index/search refresh.${maybe}` }],
|
|
113
131
|
details: {
|
|
114
132
|
pagesWritten: [pagePath],
|
|
115
133
|
pagesSkipped: [],
|
|
@@ -153,7 +171,7 @@ export function createReadExecute(wikiDir: string) {
|
|
|
153
171
|
.join("\n");
|
|
154
172
|
|
|
155
173
|
return {
|
|
156
|
-
content: [{ type: "text", text: `${header}\n\n${page.body}` }],
|
|
174
|
+
content: [{ type: "text", text: `${header}${openQuestionsNote(page.body)}\n\n${page.body}` }],
|
|
157
175
|
details: { found: true, path: `${parsed.category}/${parsed.slug}` },
|
|
158
176
|
};
|
|
159
177
|
}
|
|
@@ -179,7 +197,7 @@ export function createReadExecute(wikiDir: string) {
|
|
|
179
197
|
.join("\n");
|
|
180
198
|
|
|
181
199
|
return {
|
|
182
|
-
content: [{ type: "text", text: `${header}\n\n${page.body}` }],
|
|
200
|
+
content: [{ type: "text", text: `${header}${openQuestionsNote(page.body)}\n\n${page.body}` }],
|
|
183
201
|
details: { found: true, path: `${ref.category}/${ref.slug}` },
|
|
184
202
|
};
|
|
185
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
|
|
|
@@ -113,6 +116,7 @@ export function createWriteExecute(
|
|
|
113
116
|
body: linkedBody,
|
|
114
117
|
};
|
|
115
118
|
await writePage(wikiDir, updatedPage);
|
|
119
|
+
writtenBodies.push({ page: pagePath, text: updatedPage.body });
|
|
116
120
|
pagesWritten.push(pagePath + (errors.length > 0 ? ` (${errors.length} edit(s) failed)` : ""));
|
|
117
121
|
} else if (pageSpec.mode === "append" && existing) {
|
|
118
122
|
// Append to existing page body
|
|
@@ -132,6 +136,7 @@ export function createWriteExecute(
|
|
|
132
136
|
},
|
|
133
137
|
};
|
|
134
138
|
await writePage(wikiDir, updatedPage);
|
|
139
|
+
writtenBodies.push({ page: pagePath, text: updatedPage.body });
|
|
135
140
|
pagesWritten.push(pagePath);
|
|
136
141
|
} else if (pageSpec.mode === "replace" && existing) {
|
|
137
142
|
// Replace existing page body, update frontmatter
|
|
@@ -154,6 +159,7 @@ export function createWriteExecute(
|
|
|
154
159
|
body: linkedBody,
|
|
155
160
|
};
|
|
156
161
|
await writePage(wikiDir, updatedPage);
|
|
162
|
+
writtenBodies.push({ page: pagePath, text: updatedPage.body });
|
|
157
163
|
pagesWritten.push(pagePath);
|
|
158
164
|
} else {
|
|
159
165
|
// Create new page (or mode=create/replace on non-existing)
|
|
@@ -178,6 +184,7 @@ export function createWriteExecute(
|
|
|
178
184
|
body: linkedBody,
|
|
179
185
|
};
|
|
180
186
|
await writePage(wikiDir, newPage);
|
|
187
|
+
writtenBodies.push({ page: pagePath, text: newPage.body });
|
|
181
188
|
pagesWritten.push(pagePath);
|
|
182
189
|
}
|
|
183
190
|
}
|
|
@@ -237,6 +244,7 @@ export function createWriteExecute(
|
|
|
237
244
|
summary,
|
|
238
245
|
indexUpdated ? "Updated index.md." : "",
|
|
239
246
|
logAppended ? `Logged: ${params.logSummary}` : "",
|
|
247
|
+
answeredNote(await questionsMaybeAnswered(wikiDir, writtenBodies)).trim(),
|
|
240
248
|
]
|
|
241
249
|
.filter(Boolean)
|
|
242
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]
|