pi-para 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +214 -0
- package/README.md +305 -0
- package/SETUP.md +171 -0
- package/dist/capture.d.ts +34 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +461 -0
- package/dist/capture.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +405 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands.d.ts +15 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +786 -0
- package/dist/commands.js.map +1 -0
- package/dist/context.d.ts +61 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +289 -0
- package/dist/context.js.map +1 -0
- package/dist/daemon.d.ts +49 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +226 -0
- package/dist/daemon.js.map +1 -0
- package/dist/frontmatter.d.ts +59 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +170 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/gepa/dataset.d.ts +36 -0
- package/dist/gepa/dataset.d.ts.map +1 -0
- package/dist/gepa/dataset.js +148 -0
- package/dist/gepa/dataset.js.map +1 -0
- package/dist/gepa/evaluator.d.ts +51 -0
- package/dist/gepa/evaluator.d.ts.map +1 -0
- package/dist/gepa/evaluator.js +317 -0
- package/dist/gepa/evaluator.js.map +1 -0
- package/dist/gepa/index.d.ts +66 -0
- package/dist/gepa/index.d.ts.map +1 -0
- package/dist/gepa/index.js +240 -0
- package/dist/gepa/index.js.map +1 -0
- package/dist/gepa/llm.d.ts +35 -0
- package/dist/gepa/llm.d.ts.map +1 -0
- package/dist/gepa/llm.js +268 -0
- package/dist/gepa/llm.js.map +1 -0
- package/dist/gepa/optimized/capture-prompt.txt +65 -0
- package/dist/gepa/optimizer.d.ts +29 -0
- package/dist/gepa/optimizer.d.ts.map +1 -0
- package/dist/gepa/optimizer.js +249 -0
- package/dist/gepa/optimizer.js.map +1 -0
- package/dist/gepa/targets.d.ts +39 -0
- package/dist/gepa/targets.d.ts.map +1 -0
- package/dist/gepa/targets.js +326 -0
- package/dist/gepa/targets.js.map +1 -0
- package/dist/gepa/types.d.ts +168 -0
- package/dist/gepa/types.d.ts.map +1 -0
- package/dist/gepa/types.js +124 -0
- package/dist/gepa/types.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +294 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest.d.ts +88 -0
- package/dist/ingest.d.ts.map +1 -0
- package/dist/ingest.js +105 -0
- package/dist/ingest.js.map +1 -0
- package/dist/link-discovery.d.ts +40 -0
- package/dist/link-discovery.d.ts.map +1 -0
- package/dist/link-discovery.js +122 -0
- package/dist/link-discovery.js.map +1 -0
- package/dist/link-utils.d.ts +30 -0
- package/dist/link-utils.d.ts.map +1 -0
- package/dist/link-utils.js +145 -0
- package/dist/link-utils.js.map +1 -0
- package/dist/lint.d.ts +39 -0
- package/dist/lint.d.ts.map +1 -0
- package/dist/lint.js +827 -0
- package/dist/lint.js.map +1 -0
- package/dist/maintainer.d.ts +24 -0
- package/dist/maintainer.d.ts.map +1 -0
- package/dist/maintainer.js +358 -0
- package/dist/maintainer.js.map +1 -0
- package/dist/processor.d.ts +14 -0
- package/dist/processor.d.ts.map +1 -0
- package/dist/processor.js +361 -0
- package/dist/processor.js.map +1 -0
- package/dist/query.d.ts +69 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +107 -0
- package/dist/query.js.map +1 -0
- package/dist/raw.d.ts +57 -0
- package/dist/raw.d.ts.map +1 -0
- package/dist/raw.js +219 -0
- package/dist/raw.js.map +1 -0
- package/dist/redact.d.ts +21 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +143 -0
- package/dist/redact.js.map +1 -0
- package/dist/scope.d.ts +54 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +161 -0
- package/dist/scope.js.map +1 -0
- package/dist/scripts/bulk-link-fix.d.ts +9 -0
- package/dist/scripts/bulk-link-fix.d.ts.map +1 -0
- package/dist/scripts/bulk-link-fix.js +106 -0
- package/dist/scripts/bulk-link-fix.js.map +1 -0
- package/dist/session-tools.d.ts +33 -0
- package/dist/session-tools.d.ts.map +1 -0
- package/dist/session-tools.js +211 -0
- package/dist/session-tools.js.map +1 -0
- package/dist/state.d.ts +54 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +176 -0
- package/dist/state.js.map +1 -0
- package/dist/store.d.ts +56 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +227 -0
- package/dist/store.js.map +1 -0
- package/dist/summarize.d.ts +56 -0
- package/dist/summarize.d.ts.map +1 -0
- package/dist/summarize.js +231 -0
- package/dist/summarize.js.map +1 -0
- package/dist/tag-registry.d.ts +56 -0
- package/dist/tag-registry.d.ts.map +1 -0
- package/dist/tag-registry.js +146 -0
- package/dist/tag-registry.js.map +1 -0
- package/dist/templates/prompts.d.ts +63 -0
- package/dist/templates/prompts.d.ts.map +1 -0
- package/dist/templates/prompts.js +260 -0
- package/dist/templates/prompts.js.map +1 -0
- package/dist/tools.d.ts +26 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +1033 -0
- package/dist/tools.js.map +1 -0
- package/dist/watcher.d.ts +25 -0
- package/dist/watcher.d.ts.map +1 -0
- package/dist/watcher.js +84 -0
- package/dist/watcher.js.map +1 -0
- package/dist/webui/client/dist/index.html +1885 -0
- package/dist/webui/server.d.ts +31 -0
- package/dist/webui/server.d.ts.map +1 -0
- package/dist/webui/server.js +566 -0
- package/dist/webui/server.js.map +1 -0
- package/dist/wiki.d.ts +103 -0
- package/dist/wiki.d.ts.map +1 -0
- package/dist/wiki.js +428 -0
- package/dist/wiki.js.map +1 -0
- package/package.json +142 -0
- package/scripts/gepa/__pycache__/dataset.cpython-313.pyc +0 -0
- package/scripts/gepa/__pycache__/lm_providers.cpython-313.pyc +0 -0
- package/scripts/gepa/__pycache__/metric.cpython-313.pyc +0 -0
- package/scripts/gepa/__pycache__/program.cpython-313.pyc +0 -0
- package/scripts/gepa/__pycache__/wiki_reader.cpython-313.pyc +0 -0
- package/scripts/gepa/dataset.py +192 -0
- package/scripts/gepa/lm_providers.py +472 -0
- package/scripts/gepa/metric.py +279 -0
- package/scripts/gepa/optimize.py +367 -0
- package/scripts/gepa/program.py +99 -0
- package/scripts/gepa/pyproject.toml +19 -0
- package/scripts/gepa/run-all.sh +53 -0
- package/scripts/gepa/uv.lock +2241 -0
- package/scripts/gepa/wiki_reader.py +113 -0
- package/skills/para/SKILL.md +77 -0
- package/skills/setup/SKILL.md +20 -0
- package/src/capture.ts +563 -0
- package/src/cli.ts +416 -0
- package/src/commands.ts +878 -0
- package/src/context.ts +390 -0
- package/src/daemon.ts +288 -0
- package/src/frontmatter.ts +219 -0
- package/src/gepa/index.ts +287 -0
- package/src/gepa/optimized/capture-prompt.txt +65 -0
- package/src/index.ts +384 -0
- package/src/ingest.ts +177 -0
- package/src/link-discovery.ts +170 -0
- package/src/link-utils.ts +169 -0
- package/src/lint.ts +1018 -0
- package/src/maintainer.ts +404 -0
- package/src/processor.ts +415 -0
- package/src/python/__pycache__/minimax_lm.cpython-312.pyc +0 -0
- package/src/python/minimax_lm.py +198 -0
- package/src/python/rlm_capture.py +215 -0
- package/src/query.ts +148 -0
- package/src/raw.ts +276 -0
- package/src/redact.ts +159 -0
- package/src/scope.ts +214 -0
- package/src/scripts/bulk-link-fix.ts +126 -0
- package/src/session-tools.ts +260 -0
- package/src/state.ts +271 -0
- package/src/store.ts +288 -0
- package/src/summarize.ts +273 -0
- package/src/tag-registry.ts +157 -0
- package/src/templates/index.md +17 -0
- package/src/templates/prompts.ts +274 -0
- package/src/templates/schema.md +116 -0
- package/src/tools.ts +1293 -0
- package/src/watcher.ts +101 -0
- package/src/webui/client/dist/index.html +1885 -0
- package/src/webui/server.ts +765 -0
- package/src/wiki.ts +550 -0
package/src/context.ts
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session context injection — injects relevant wiki knowledge into every agent turn.
|
|
3
|
+
*
|
|
4
|
+
* Builds a context string from schema.md, index.md, and scope-filtered page
|
|
5
|
+
* summaries, injected via the before_agent_start hook.
|
|
6
|
+
*
|
|
7
|
+
* Uses a dirty-flag caching strategy: the context string is rebuilt only when
|
|
8
|
+
* wiki_write or wiki_move modifies the wiki (or on session start). Cached
|
|
9
|
+
* context is returned on subsequent before_agent_start calls, adding <5ms.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { QMDStore } from "qmd-engine";
|
|
13
|
+
import type {
|
|
14
|
+
ExtensionAPI,
|
|
15
|
+
ExtensionContext,
|
|
16
|
+
BeforeAgentStartEventResult,
|
|
17
|
+
} from "@mariozechner/pi-coding-agent";
|
|
18
|
+
import type { ProjectScope } from "./scope.js";
|
|
19
|
+
import type { StateDB, PageSummary } from "./state.js";
|
|
20
|
+
import { readIndex, readSchema, listPages, readPage } from "./wiki.js";
|
|
21
|
+
import { matchesScope } from "./scope.js";
|
|
22
|
+
import { formatFreshness } from "./query.js";
|
|
23
|
+
|
|
24
|
+
// -- Types ------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface ContextOptions {
|
|
27
|
+
maxTokens?: number; // max tokens for wiki context (default 4000)
|
|
28
|
+
includeSchema?: boolean; // include schema.md (default true)
|
|
29
|
+
includeIndex?: boolean; // include index.md (default true)
|
|
30
|
+
includeSummaries?: boolean; // include page summaries (default true)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Config subset needed by context injection. */
|
|
34
|
+
export interface ContextConfig {
|
|
35
|
+
contextMaxTokens?: number;
|
|
36
|
+
contextIncludeSchema?: boolean;
|
|
37
|
+
contextIncludeIndex?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// -- Constants ---------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const DEFAULT_MAX_TOKENS = 4000;
|
|
43
|
+
const CHARS_PER_TOKEN = 4;
|
|
44
|
+
|
|
45
|
+
/** Maximum number of pages to include in context injection. */
|
|
46
|
+
export const MAX_CONTEXT_PAGES = 40;
|
|
47
|
+
|
|
48
|
+
// -- Schema summary ----------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A compact conventions summary extracted from schema.md, much smaller than
|
|
52
|
+
* the full schema. Used to give the LLM essential rules without blowing the
|
|
53
|
+
* token budget.
|
|
54
|
+
*/
|
|
55
|
+
function buildSchemaSummary(fullSchema: string): string {
|
|
56
|
+
// If the schema is small enough, include it verbatim
|
|
57
|
+
if (fullSchema.length / CHARS_PER_TOKEN < 500) {
|
|
58
|
+
return fullSchema;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Otherwise return a compact summary of key conventions
|
|
62
|
+
return [
|
|
63
|
+
"## Wiki Conventions (summary)",
|
|
64
|
+
"",
|
|
65
|
+
"- Pages: markdown + YAML frontmatter (title, para, scope, tags, sources, created, updated, links)",
|
|
66
|
+
"- PARA categories: **resources/** for almost everything (architecture docs, how-tos, patterns, debugging). **areas/** for ongoing responsibilities. **projects/** ONLY for actual goals with end dates. **archives/** for completed items.",
|
|
67
|
+
"- Scope: must be a kebab-case project name (e.g. `pi-para`, `qmd`). NOT topic descriptions.",
|
|
68
|
+
"- Tags: kebab-case, no spaces. Don't duplicate scope values as tags.",
|
|
69
|
+
"- Slugs: lowercase, hyphens (e.g. ssl-cert-gotchas)",
|
|
70
|
+
"- Cross-references: use [[slug]] wikilinks. Add a ## Connections section linking related pages.",
|
|
71
|
+
"- Wiki summary format sections: Topic, Key Facts, Insights, Connections, Open Questions, Sources",
|
|
72
|
+
"- Update existing pages for same concept, create new pages for distinct concepts",
|
|
73
|
+
"- Tone: technical, concise, factual",
|
|
74
|
+
].join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// -- Public API --------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build a context string from wiki state for injection into the system prompt.
|
|
81
|
+
*
|
|
82
|
+
* Strategy:
|
|
83
|
+
* 1. Always include schema conventions summary + index.md
|
|
84
|
+
* 2. Conditionally include first paragraph / summary of pages matching scope
|
|
85
|
+
* 3. If budget exceeded, fall back to index + page titles only
|
|
86
|
+
*/
|
|
87
|
+
export async function buildContext(
|
|
88
|
+
wikiDir: string,
|
|
89
|
+
_store: QMDStore,
|
|
90
|
+
scope: ProjectScope,
|
|
91
|
+
options?: ContextOptions,
|
|
92
|
+
stateDb?: StateDB,
|
|
93
|
+
): Promise<string> {
|
|
94
|
+
const maxTokens = options?.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
95
|
+
const includeSchema = options?.includeSchema ?? true;
|
|
96
|
+
const includeIndex = options?.includeIndex ?? true;
|
|
97
|
+
const includeSummaries = options?.includeSummaries ?? true;
|
|
98
|
+
|
|
99
|
+
const maxChars = maxTokens * CHARS_PER_TOKEN;
|
|
100
|
+
const parts: string[] = [];
|
|
101
|
+
let usedChars = 0;
|
|
102
|
+
|
|
103
|
+
// Header
|
|
104
|
+
const header = `<wiki-context scope="${scope.name}">`;
|
|
105
|
+
const footer = "</wiki-context>";
|
|
106
|
+
usedChars += header.length + footer.length + 2; // +2 for newlines
|
|
107
|
+
|
|
108
|
+
// 1. Schema conventions summary
|
|
109
|
+
if (includeSchema) {
|
|
110
|
+
let schemaContent: string;
|
|
111
|
+
try {
|
|
112
|
+
schemaContent = await readSchema(wikiDir);
|
|
113
|
+
} catch {
|
|
114
|
+
schemaContent = "";
|
|
115
|
+
}
|
|
116
|
+
if (schemaContent) {
|
|
117
|
+
const summary = buildSchemaSummary(schemaContent);
|
|
118
|
+
if (usedChars + summary.length + 2 <= maxChars) {
|
|
119
|
+
parts.push(summary);
|
|
120
|
+
usedChars += summary.length + 2;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 2. Index.md
|
|
126
|
+
let indexContent = "";
|
|
127
|
+
if (includeIndex) {
|
|
128
|
+
try {
|
|
129
|
+
indexContent = await readIndex(wikiDir);
|
|
130
|
+
} catch {
|
|
131
|
+
indexContent = "";
|
|
132
|
+
}
|
|
133
|
+
if (indexContent) {
|
|
134
|
+
if (usedChars + indexContent.length + 2 <= maxChars) {
|
|
135
|
+
parts.push(indexContent);
|
|
136
|
+
usedChars += indexContent.length + 2;
|
|
137
|
+
} else {
|
|
138
|
+
// Truncate index to fit
|
|
139
|
+
const available = maxChars - usedChars - 2;
|
|
140
|
+
if (available > 100) {
|
|
141
|
+
parts.push(indexContent.slice(0, available) + "\n...(truncated)");
|
|
142
|
+
usedChars = maxChars;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 3. Scope-filtered page summaries
|
|
149
|
+
if (includeSummaries && usedChars < maxChars) {
|
|
150
|
+
// Use stateDb cache if available, otherwise fall back to disk reads
|
|
151
|
+
const scopeEntries = stateDb
|
|
152
|
+
? await buildScopeEntriesFromCache(stateDb, scope)
|
|
153
|
+
: await buildScopeEntriesFromDisk(wikiDir, scope);
|
|
154
|
+
|
|
155
|
+
// Sort by updated date descending (most recent first)
|
|
156
|
+
scopeEntries.sort((a, b) => {
|
|
157
|
+
const dateA = new Date(a.updatedAt).getTime() || 0;
|
|
158
|
+
const dateB = new Date(b.updatedAt).getTime() || 0;
|
|
159
|
+
return dateB - dateA;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const totalScopePages = scopeEntries.length;
|
|
163
|
+
|
|
164
|
+
// Tier: only include top MAX_CONTEXT_PAGES
|
|
165
|
+
const tieredEntries = scopeEntries.slice(0, MAX_CONTEXT_PAGES);
|
|
166
|
+
const remaining = totalScopePages - tieredEntries.length;
|
|
167
|
+
|
|
168
|
+
const summaryParts: string[] = [];
|
|
169
|
+
const titleFallbacks: string[] = [];
|
|
170
|
+
|
|
171
|
+
for (const entry of tieredEntries) {
|
|
172
|
+
const summaryLine = `- **[[${entry.slug}]]** (${entry.category}): ${entry.firstParagraph}`;
|
|
173
|
+
const titleLine = `- [[${entry.slug}]] (${entry.category})`;
|
|
174
|
+
|
|
175
|
+
titleFallbacks.push(titleLine);
|
|
176
|
+
|
|
177
|
+
if (usedChars + summaryLine.length + 2 <= maxChars) {
|
|
178
|
+
summaryParts.push(summaryLine);
|
|
179
|
+
usedChars += summaryLine.length + 1;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (summaryParts.length > 0) {
|
|
184
|
+
let summaryBlock =
|
|
185
|
+
"## Relevant Pages\n\n" + summaryParts.join("\n");
|
|
186
|
+
if (remaining > 0) {
|
|
187
|
+
summaryBlock += `\n\n*${remaining} more pages available via wiki_query*`;
|
|
188
|
+
}
|
|
189
|
+
parts.push(summaryBlock);
|
|
190
|
+
} else if (titleFallbacks.length > 0) {
|
|
191
|
+
// Budget exceeded for summaries — try titles only
|
|
192
|
+
let titlesBlock =
|
|
193
|
+
"## Relevant Pages (titles only)\n\n" + titleFallbacks.join("\n");
|
|
194
|
+
if (remaining > 0) {
|
|
195
|
+
titlesBlock += `\n\n*${remaining} more pages available via wiki_query*`;
|
|
196
|
+
}
|
|
197
|
+
const availableChars = maxChars - usedChars;
|
|
198
|
+
if (titlesBlock.length <= availableChars) {
|
|
199
|
+
parts.push(titlesBlock);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (parts.length === 0) {
|
|
205
|
+
return "";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Reminder to use wiki tools proactively — this is injected into every turn
|
|
209
|
+
// so the LLM doesn't forget between tool calls.
|
|
210
|
+
parts.push(
|
|
211
|
+
"After making significant architectural decisions, solving debugging problems, establishing project conventions, or completing substantial implementation work, use wiki_write to persist the knowledge without being asked."
|
|
212
|
+
);
|
|
213
|
+
parts.push(
|
|
214
|
+
"When using wiki_write: use resources/ category for almost everything (architecture docs, debugging, patterns). scope must be a kebab-case project name. tags must be kebab-case, no spaces. Always add [[wikilinks]] in a ## Connections section. NEVER include API keys, tokens, or secrets — document WHERE they are stored, not the values."
|
|
215
|
+
);
|
|
216
|
+
parts.push(
|
|
217
|
+
"Wiki pages may be stale. When wiki_query or wiki_read returns a page with an AGING/STALE/VERY STALE freshness indicator, verify its claims against actual code or configs before trusting it. If you find incorrect information, fix it immediately with wiki_write(mode: 'edit'). This self-healing loop keeps the wiki accurate."
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return header + "\n" + parts.join("\n\n") + "\n" + footer;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// -- Caching and lifecycle ---------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/** Module-level dirty flag, exported for tools.ts to call after mutations. */
|
|
226
|
+
let contextDirty = true;
|
|
227
|
+
let cachedContext: string | null = null;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Mark the cached context as dirty. Call this after wiki_write or wiki_move
|
|
231
|
+
* so the next before_agent_start rebuilds the context.
|
|
232
|
+
*/
|
|
233
|
+
export function markContextDirty(): void {
|
|
234
|
+
contextDirty = true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Wire up context injection on the extension API.
|
|
239
|
+
*
|
|
240
|
+
* - On session_start: mark dirty so context is rebuilt for the new scope
|
|
241
|
+
* - On before_agent_start: return cached context if clean, rebuild if dirty
|
|
242
|
+
*
|
|
243
|
+
* @param pi - The extension API
|
|
244
|
+
* @param wikiDir - Path to the wiki directory
|
|
245
|
+
* @param store - qmd store instance (or getter returning one)
|
|
246
|
+
* @param getScope - Returns the current ProjectScope
|
|
247
|
+
* @param getConfig - Returns the current config values for context
|
|
248
|
+
*/
|
|
249
|
+
export function setupContextInjection(
|
|
250
|
+
pi: ExtensionAPI,
|
|
251
|
+
wikiDir: string,
|
|
252
|
+
store: QMDStore,
|
|
253
|
+
getScope: () => ProjectScope | null,
|
|
254
|
+
getConfig: () => ContextConfig,
|
|
255
|
+
): void {
|
|
256
|
+
// Reset cache state for a fresh setup
|
|
257
|
+
contextDirty = true;
|
|
258
|
+
cachedContext = null;
|
|
259
|
+
|
|
260
|
+
pi.on("session_start", async () => {
|
|
261
|
+
contextDirty = true;
|
|
262
|
+
cachedContext = null;
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
pi.on(
|
|
266
|
+
"before_agent_start",
|
|
267
|
+
async (
|
|
268
|
+
event,
|
|
269
|
+
_ctx: ExtensionContext,
|
|
270
|
+
): Promise<BeforeAgentStartEventResult | void> => {
|
|
271
|
+
const scope = getScope();
|
|
272
|
+
if (!scope) return;
|
|
273
|
+
|
|
274
|
+
if (!contextDirty && cachedContext !== null) {
|
|
275
|
+
// Return cached context
|
|
276
|
+
if (cachedContext) {
|
|
277
|
+
return {
|
|
278
|
+
systemPrompt: event.systemPrompt + "\n\n" + cachedContext,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Rebuild context
|
|
285
|
+
const config = getConfig();
|
|
286
|
+
const options: ContextOptions = {
|
|
287
|
+
maxTokens: config.contextMaxTokens ?? DEFAULT_MAX_TOKENS,
|
|
288
|
+
includeSchema: config.contextIncludeSchema ?? true,
|
|
289
|
+
includeIndex: true,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
cachedContext = await buildContext(wikiDir, store, scope, options);
|
|
294
|
+
contextDirty = false;
|
|
295
|
+
} catch {
|
|
296
|
+
// On error, clear cache and don't inject anything
|
|
297
|
+
cachedContext = null;
|
|
298
|
+
contextDirty = false;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (cachedContext) {
|
|
303
|
+
return {
|
|
304
|
+
systemPrompt: event.systemPrompt + "\n\n" + cachedContext,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// -- Helpers -----------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
interface ScopeEntry {
|
|
314
|
+
slug: string;
|
|
315
|
+
category: string;
|
|
316
|
+
firstParagraph: string;
|
|
317
|
+
updatedAt: string;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Build scope entries from the StateDB page summary cache. */
|
|
321
|
+
async function buildScopeEntriesFromCache(
|
|
322
|
+
stateDb: StateDB,
|
|
323
|
+
scope: ProjectScope,
|
|
324
|
+
): Promise<ScopeEntry[]> {
|
|
325
|
+
const summaries = stateDb.getPageSummaries(scope);
|
|
326
|
+
return summaries.map((s) => ({
|
|
327
|
+
slug: s.slug,
|
|
328
|
+
category: s.category,
|
|
329
|
+
firstParagraph: s.firstParagraph || "(no summary)",
|
|
330
|
+
updatedAt: s.updatedAt,
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Build scope entries by reading all pages from disk. */
|
|
335
|
+
async function buildScopeEntriesFromDisk(
|
|
336
|
+
wikiDir: string,
|
|
337
|
+
scope: ProjectScope,
|
|
338
|
+
): Promise<ScopeEntry[]> {
|
|
339
|
+
const pages = await listPages(wikiDir);
|
|
340
|
+
const entries: ScopeEntry[] = [];
|
|
341
|
+
|
|
342
|
+
for (const ref of pages) {
|
|
343
|
+
const page = await readPage(wikiDir, ref.category, ref.slug);
|
|
344
|
+
if (!page) continue;
|
|
345
|
+
if (!matchesScope(page.frontmatter.scope, scope)) continue;
|
|
346
|
+
|
|
347
|
+
entries.push({
|
|
348
|
+
slug: ref.slug,
|
|
349
|
+
category: ref.category,
|
|
350
|
+
firstParagraph: extractFirstParagraph(page.body),
|
|
351
|
+
updatedAt: page.frontmatter.updated,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return entries;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Extract the first non-empty paragraph from a markdown body.
|
|
360
|
+
* Skips headings and blank lines. Returns a single line.
|
|
361
|
+
*/
|
|
362
|
+
export function extractFirstParagraph(body: string): string {
|
|
363
|
+
const lines = body.split("\n");
|
|
364
|
+
const paragraphLines: string[] = [];
|
|
365
|
+
let inParagraph = false;
|
|
366
|
+
|
|
367
|
+
for (const line of lines) {
|
|
368
|
+
const trimmed = line.trim();
|
|
369
|
+
|
|
370
|
+
// Skip headings and empty lines before paragraph starts
|
|
371
|
+
if (!inParagraph) {
|
|
372
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
373
|
+
inParagraph = true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// End paragraph on blank line or heading
|
|
377
|
+
if (inParagraph && (trimmed === "" || trimmed.startsWith("#"))) {
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
paragraphLines.push(trimmed);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const result = paragraphLines.join(" ");
|
|
385
|
+
// Truncate long paragraphs
|
|
386
|
+
if (result.length > 200) {
|
|
387
|
+
return result.slice(0, 197) + "...";
|
|
388
|
+
}
|
|
389
|
+
return result || "(no summary)";
|
|
390
|
+
}
|
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main daemon loop — watches for completed sessions and processes them.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
|
|
9
|
+
import type { Model } from "@mariozechner/pi-ai";
|
|
10
|
+
import { openStore, closeStore } from "./store.js";
|
|
11
|
+
import type { QMDStore } from "qmd-engine";
|
|
12
|
+
|
|
13
|
+
import { StateDB } from "./state.js";
|
|
14
|
+
import { RegistryWatcher } from "./watcher.js";
|
|
15
|
+
import type { CompletedSession } from "./watcher.js";
|
|
16
|
+
import { processSession } from "./processor.js";
|
|
17
|
+
import { runMaintenance } from "./maintainer.js";
|
|
18
|
+
import { startServer } from "./webui/server.js";
|
|
19
|
+
import type { WebWikiConfig } from "./webui/server.js";
|
|
20
|
+
|
|
21
|
+
export interface DaemonConfig {
|
|
22
|
+
wikiDir: string;
|
|
23
|
+
model: Model<any>;
|
|
24
|
+
getApiKey: (provider: string) => Promise<string | undefined>;
|
|
25
|
+
pollIntervalMs?: number;
|
|
26
|
+
/** How often to run link discovery + lint (ms). Default: 30 minutes */
|
|
27
|
+
maintenanceIntervalMs?: number;
|
|
28
|
+
/** Web wiki server config. If enabled, the daemon hosts the web UI. */
|
|
29
|
+
webWiki?: WebWikiConfig;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class Daemon {
|
|
33
|
+
private config: DaemonConfig;
|
|
34
|
+
private state: StateDB;
|
|
35
|
+
private watcher: RegistryWatcher;
|
|
36
|
+
private store: QMDStore | null = null;
|
|
37
|
+
private queue: CompletedSession[] = [];
|
|
38
|
+
private processing = false;
|
|
39
|
+
private running = false;
|
|
40
|
+
private maintenanceTimer: ReturnType<typeof setInterval> | null = null;
|
|
41
|
+
private maintenanceRunning = false;
|
|
42
|
+
private webServer: { close: () => Promise<void>; url: string } | null = null;
|
|
43
|
+
|
|
44
|
+
constructor(config: DaemonConfig) {
|
|
45
|
+
this.config = config;
|
|
46
|
+
this.state = new StateDB(config.wikiDir);
|
|
47
|
+
this.watcher = new RegistryWatcher(
|
|
48
|
+
config.wikiDir,
|
|
49
|
+
(entries) => this.onNewEntries(entries),
|
|
50
|
+
config.pollIntervalMs ?? 5000,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Start the daemon. */
|
|
55
|
+
async start(): Promise<void> {
|
|
56
|
+
console.log(`[daemon] Starting — wiki: ${this.config.wikiDir}`);
|
|
57
|
+
|
|
58
|
+
// Open qmd store
|
|
59
|
+
try {
|
|
60
|
+
this.store = await openStore(this.config.wikiDir);
|
|
61
|
+
console.log("[daemon] qmd store opened");
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error(`[daemon] Failed to open store: ${err instanceof Error ? err.message : err}`);
|
|
64
|
+
this.store = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.running = true;
|
|
68
|
+
this.state.setState("daemon_started_at", new Date().toISOString());
|
|
69
|
+
this.state.setState("daemon_pid", String(process.pid));
|
|
70
|
+
|
|
71
|
+
// Start watching
|
|
72
|
+
this.watcher.start();
|
|
73
|
+
|
|
74
|
+
// Process any unprocessed sessions from registry
|
|
75
|
+
const existing = this.watcher.getAllEntries();
|
|
76
|
+
const unprocessed = existing.filter((e) => !this.state.isProcessed(e.sessionPath));
|
|
77
|
+
if (unprocessed.length > 0) {
|
|
78
|
+
console.log(`[daemon] Found ${unprocessed.length} unprocessed session(s)`);
|
|
79
|
+
this.onNewEntries(unprocessed);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Start periodic maintenance (link discovery + lint)
|
|
83
|
+
const maintenanceMs = this.config.maintenanceIntervalMs ?? 30 * 60 * 1000;
|
|
84
|
+
this.maintenanceTimer = setInterval(() => this.runMaintenancePass(), maintenanceMs);
|
|
85
|
+
// Run first maintenance after a delay to let the store warm up
|
|
86
|
+
setTimeout(() => this.runMaintenancePass(), 60_000);
|
|
87
|
+
|
|
88
|
+
// Start web wiki server if enabled
|
|
89
|
+
if (this.config.webWiki?.enabled) {
|
|
90
|
+
try {
|
|
91
|
+
this.webServer = startServer(this.config.wikiDir, this.store, this.config.webWiki);
|
|
92
|
+
console.log(`[daemon] Web wiki: ${this.webServer.url}`);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.error(`[daemon] Web wiki failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log(`[daemon] Running (maintenance every ${Math.round(maintenanceMs / 60000)}m). Press Ctrl+C to stop.`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Stop the daemon. */
|
|
102
|
+
async stop(): Promise<void> {
|
|
103
|
+
console.log("[daemon] Stopping...");
|
|
104
|
+
this.running = false;
|
|
105
|
+
this.watcher.stop();
|
|
106
|
+
if (this.maintenanceTimer) {
|
|
107
|
+
clearInterval(this.maintenanceTimer);
|
|
108
|
+
this.maintenanceTimer = null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Stop web wiki server
|
|
112
|
+
if (this.webServer) {
|
|
113
|
+
try {
|
|
114
|
+
await this.webServer.close();
|
|
115
|
+
console.log("[daemon] Web wiki stopped");
|
|
116
|
+
} catch {}
|
|
117
|
+
this.webServer = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (this.store) {
|
|
121
|
+
try {
|
|
122
|
+
await closeStore(this.store);
|
|
123
|
+
} catch {}
|
|
124
|
+
this.store = null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
this.state.close();
|
|
128
|
+
console.log("[daemon] Stopped.");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Get the web server URL (if running). */
|
|
132
|
+
getWebUrl(): string | null {
|
|
133
|
+
return this.webServer?.url ?? null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Process a single session file (for CLI `process` command). */
|
|
137
|
+
async processOne(sessionPath: string): Promise<void> {
|
|
138
|
+
if (!existsSync(sessionPath)) {
|
|
139
|
+
console.error(`[daemon] Session file not found: ${sessionPath}`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!this.store) {
|
|
144
|
+
try {
|
|
145
|
+
this.store = await openStore(this.config.wikiDir);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.error(`[daemon] Failed to open store: ${err instanceof Error ? err.message : err}`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const scope = detectScopeFromPath(sessionPath);
|
|
153
|
+
console.log(`[daemon] Processing: ${sessionPath} (scope: ${scope})`);
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const result = await processSession(
|
|
157
|
+
sessionPath,
|
|
158
|
+
this.config.wikiDir,
|
|
159
|
+
this.store,
|
|
160
|
+
scope,
|
|
161
|
+
this.config.model,
|
|
162
|
+
this.config.getApiKey,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (result.skipped) {
|
|
166
|
+
console.log(`[daemon] Skipped: ${result.reason}`);
|
|
167
|
+
this.state.recordSuccess(sessionPath, scope, [], []);
|
|
168
|
+
} else {
|
|
169
|
+
console.log(`[daemon] Done: ${result.pagesCreated.length} created, ${result.pagesUpdated.length} updated`);
|
|
170
|
+
this.state.recordSuccess(sessionPath, scope, result.pagesCreated, result.pagesUpdated);
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
174
|
+
console.error(`[daemon] Failed: ${msg}`);
|
|
175
|
+
this.state.recordFailure(sessionPath, scope, msg);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Handle new entries from the registry watcher. */
|
|
180
|
+
private onNewEntries(entries: CompletedSession[]): void {
|
|
181
|
+
// Filter already-processed
|
|
182
|
+
const newEntries = entries.filter(
|
|
183
|
+
(e) => existsSync(e.sessionPath) && !this.state.isProcessed(e.sessionPath),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (newEntries.length === 0) return;
|
|
187
|
+
|
|
188
|
+
console.log(`[daemon] Queued ${newEntries.length} session(s) for processing`);
|
|
189
|
+
this.queue.push(...newEntries);
|
|
190
|
+
this.processQueue();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Process queued sessions sequentially. */
|
|
194
|
+
private async processQueue(): Promise<void> {
|
|
195
|
+
if (this.processing || !this.running) return;
|
|
196
|
+
this.processing = true;
|
|
197
|
+
|
|
198
|
+
while (this.queue.length > 0 && this.running) {
|
|
199
|
+
const entry = this.queue.shift()!;
|
|
200
|
+
await this.processOne(entry.sessionPath);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
this.processing = false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Run periodic maintenance via LLM agent. */
|
|
207
|
+
private async runMaintenancePass(): Promise<void> {
|
|
208
|
+
if (this.maintenanceRunning || !this.running || !this.store) return;
|
|
209
|
+
this.maintenanceRunning = true;
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
console.log("[daemon] Running maintenance agent...");
|
|
213
|
+
const result = await runMaintenance(
|
|
214
|
+
this.config.wikiDir,
|
|
215
|
+
this.store,
|
|
216
|
+
this.config.model,
|
|
217
|
+
this.config.getApiKey,
|
|
218
|
+
);
|
|
219
|
+
console.log(
|
|
220
|
+
`[daemon] Maintenance done: ${result.pagesUpdated} updated, ${result.pagesMerged} merged`,
|
|
221
|
+
);
|
|
222
|
+
if (result.summary) {
|
|
223
|
+
// Log first 200 chars of summary
|
|
224
|
+
console.log(`[daemon] Summary: ${result.summary.slice(0, 200)}`);
|
|
225
|
+
}
|
|
226
|
+
} catch (err) {
|
|
227
|
+
console.error(
|
|
228
|
+
`[daemon] Maintenance error: ${err instanceof Error ? err.message : String(err)}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
this.maintenanceRunning = false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Get processing history. */
|
|
236
|
+
getHistory(scope?: string, limit?: number) {
|
|
237
|
+
return this.state.getHistory(scope, limit);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Get failed sessions. */
|
|
241
|
+
getFailed() {
|
|
242
|
+
return this.state.getFailed();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Retry all failed sessions. */
|
|
246
|
+
async retryFailed(): Promise<void> {
|
|
247
|
+
const failed = this.state.getFailed();
|
|
248
|
+
if (failed.length === 0) {
|
|
249
|
+
console.log("[daemon] No failed sessions to retry.");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
console.log(`[daemon] Retrying ${failed.length} failed session(s)...`);
|
|
253
|
+
for (const session of failed) {
|
|
254
|
+
await this.processOne(session.sessionPath);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Extract project scope from session path or header. */
|
|
260
|
+
function detectScopeFromPath(sessionPath: string): string {
|
|
261
|
+
// 1. Try reading the session header — most reliable, has the real cwd
|
|
262
|
+
try {
|
|
263
|
+
const { readFileSync } = require("node:fs");
|
|
264
|
+
const firstLine = readFileSync(sessionPath, "utf-8").split("\n")[0];
|
|
265
|
+
const header = JSON.parse(firstLine);
|
|
266
|
+
if (header.cwd) {
|
|
267
|
+
const parts = header.cwd.split("/");
|
|
268
|
+
return parts[parts.length - 1] ?? "unknown";
|
|
269
|
+
}
|
|
270
|
+
} catch {}
|
|
271
|
+
|
|
272
|
+
// 2. Fallback: decode from directory name
|
|
273
|
+
// Path format: ~/.pi/agent/sessions/--home-ubuntu-projects-pi-mono--/file.jsonl
|
|
274
|
+
// The dir name encodes the cwd path with / replaced by -
|
|
275
|
+
// But project names can contain hyphens, so we can't just replace all - with /
|
|
276
|
+
// Instead, find the last segment after "projects-" which is the project name
|
|
277
|
+
const dirName = sessionPath.split("/").slice(-2, -1)[0] ?? "";
|
|
278
|
+
if (dirName.startsWith("--") && dirName.endsWith("--")) {
|
|
279
|
+
const inner = dirName.slice(2, -2); // e.g. "home-ubuntu-projects-agent-board"
|
|
280
|
+
// Look for "projects-" marker — everything after it is the project name
|
|
281
|
+
const projectsIdx = inner.indexOf("projects-");
|
|
282
|
+
if (projectsIdx >= 0) {
|
|
283
|
+
return inner.slice(projectsIdx + "projects-".length); // "agent-board"
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return "unknown";
|
|
288
|
+
}
|