pi-para 0.5.2 → 0.6.0

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.
Files changed (195) hide show
  1. package/ARCHITECTURE.md +160 -0
  2. package/CHANGELOG.md +51 -0
  3. package/CONFIGURATION.md +243 -0
  4. package/README.md +140 -237
  5. package/SETUP.md +196 -112
  6. package/STRUCTURE.md +140 -0
  7. package/package.json +86 -18
  8. package/scripts/build-assets.mjs +29 -0
  9. package/scripts/install.ps1 +55 -0
  10. package/scripts/install.sh +63 -0
  11. package/scripts/smoke-install.mjs +63 -0
  12. package/skills/para/SKILL.md +1 -1
  13. package/skills/setup/SKILL.md +1 -1
  14. package/src/atomic-write.ts +17 -0
  15. package/src/capture.ts +4 -4
  16. package/src/cli-legacy.ts +43 -0
  17. package/src/cli.ts +133 -28
  18. package/src/commands.ts +203 -243
  19. package/src/config.ts +355 -0
  20. package/src/context.ts +29 -14
  21. package/src/credentials.ts +100 -0
  22. package/src/daemon.ts +1 -1
  23. package/src/doctor.ts +209 -0
  24. package/src/gepa/index.ts +1 -1
  25. package/src/index.ts +119 -40
  26. package/src/maintainer.ts +4 -4
  27. package/src/model-resolver.ts +96 -0
  28. package/src/paths.ts +48 -0
  29. package/src/processor.ts +4 -4
  30. package/src/qmd-providers.ts +63 -0
  31. package/src/query.ts +15 -1
  32. package/src/repair.ts +49 -0
  33. package/src/scheduler/controls.ts +125 -0
  34. package/src/scheduler/index.ts +210 -0
  35. package/src/scheduler/leases.ts +98 -0
  36. package/src/scheduler/session-capture.ts +146 -0
  37. package/src/scheduler/state.ts +304 -0
  38. package/src/session-tools.ts +1 -1
  39. package/src/settings.ts +126 -0
  40. package/src/setup.ts +116 -0
  41. package/src/status.ts +115 -0
  42. package/src/store.ts +116 -31
  43. package/src/summarize.ts +72 -16
  44. package/src/templates/prompts.ts +1 -1
  45. package/src/tools.ts +280 -90
  46. package/src/wiki-tool-guidance.ts +84 -0
  47. package/src/wiki.ts +98 -26
  48. package/dist/capture.d.ts +0 -34
  49. package/dist/capture.d.ts.map +0 -1
  50. package/dist/capture.js +0 -461
  51. package/dist/capture.js.map +0 -1
  52. package/dist/cli.d.ts +0 -15
  53. package/dist/cli.d.ts.map +0 -1
  54. package/dist/cli.js +0 -405
  55. package/dist/cli.js.map +0 -1
  56. package/dist/commands.d.ts +0 -15
  57. package/dist/commands.d.ts.map +0 -1
  58. package/dist/commands.js +0 -786
  59. package/dist/commands.js.map +0 -1
  60. package/dist/context.d.ts +0 -61
  61. package/dist/context.d.ts.map +0 -1
  62. package/dist/context.js +0 -289
  63. package/dist/context.js.map +0 -1
  64. package/dist/daemon.d.ts +0 -49
  65. package/dist/daemon.d.ts.map +0 -1
  66. package/dist/daemon.js +0 -226
  67. package/dist/daemon.js.map +0 -1
  68. package/dist/frontmatter.d.ts +0 -59
  69. package/dist/frontmatter.d.ts.map +0 -1
  70. package/dist/frontmatter.js +0 -170
  71. package/dist/frontmatter.js.map +0 -1
  72. package/dist/gepa/dataset.d.ts +0 -36
  73. package/dist/gepa/dataset.d.ts.map +0 -1
  74. package/dist/gepa/dataset.js +0 -148
  75. package/dist/gepa/dataset.js.map +0 -1
  76. package/dist/gepa/evaluator.d.ts +0 -51
  77. package/dist/gepa/evaluator.d.ts.map +0 -1
  78. package/dist/gepa/evaluator.js +0 -317
  79. package/dist/gepa/evaluator.js.map +0 -1
  80. package/dist/gepa/index.d.ts +0 -66
  81. package/dist/gepa/index.d.ts.map +0 -1
  82. package/dist/gepa/index.js +0 -240
  83. package/dist/gepa/index.js.map +0 -1
  84. package/dist/gepa/llm.d.ts +0 -35
  85. package/dist/gepa/llm.d.ts.map +0 -1
  86. package/dist/gepa/llm.js +0 -268
  87. package/dist/gepa/llm.js.map +0 -1
  88. package/dist/gepa/optimized/capture-prompt.txt +0 -65
  89. package/dist/gepa/optimizer.d.ts +0 -29
  90. package/dist/gepa/optimizer.d.ts.map +0 -1
  91. package/dist/gepa/optimizer.js +0 -249
  92. package/dist/gepa/optimizer.js.map +0 -1
  93. package/dist/gepa/targets.d.ts +0 -39
  94. package/dist/gepa/targets.d.ts.map +0 -1
  95. package/dist/gepa/targets.js +0 -326
  96. package/dist/gepa/targets.js.map +0 -1
  97. package/dist/gepa/types.d.ts +0 -168
  98. package/dist/gepa/types.d.ts.map +0 -1
  99. package/dist/gepa/types.js +0 -124
  100. package/dist/gepa/types.js.map +0 -1
  101. package/dist/index.d.ts +0 -21
  102. package/dist/index.d.ts.map +0 -1
  103. package/dist/index.js +0 -294
  104. package/dist/index.js.map +0 -1
  105. package/dist/ingest.d.ts +0 -88
  106. package/dist/ingest.d.ts.map +0 -1
  107. package/dist/ingest.js +0 -105
  108. package/dist/ingest.js.map +0 -1
  109. package/dist/link-discovery.d.ts +0 -40
  110. package/dist/link-discovery.d.ts.map +0 -1
  111. package/dist/link-discovery.js +0 -122
  112. package/dist/link-discovery.js.map +0 -1
  113. package/dist/link-utils.d.ts +0 -30
  114. package/dist/link-utils.d.ts.map +0 -1
  115. package/dist/link-utils.js +0 -145
  116. package/dist/link-utils.js.map +0 -1
  117. package/dist/lint.d.ts +0 -39
  118. package/dist/lint.d.ts.map +0 -1
  119. package/dist/lint.js +0 -827
  120. package/dist/lint.js.map +0 -1
  121. package/dist/maintainer.d.ts +0 -24
  122. package/dist/maintainer.d.ts.map +0 -1
  123. package/dist/maintainer.js +0 -358
  124. package/dist/maintainer.js.map +0 -1
  125. package/dist/processor.d.ts +0 -14
  126. package/dist/processor.d.ts.map +0 -1
  127. package/dist/processor.js +0 -361
  128. package/dist/processor.js.map +0 -1
  129. package/dist/query.d.ts +0 -69
  130. package/dist/query.d.ts.map +0 -1
  131. package/dist/query.js +0 -107
  132. package/dist/query.js.map +0 -1
  133. package/dist/raw.d.ts +0 -57
  134. package/dist/raw.d.ts.map +0 -1
  135. package/dist/raw.js +0 -219
  136. package/dist/raw.js.map +0 -1
  137. package/dist/redact.d.ts +0 -21
  138. package/dist/redact.d.ts.map +0 -1
  139. package/dist/redact.js +0 -143
  140. package/dist/redact.js.map +0 -1
  141. package/dist/scope.d.ts +0 -54
  142. package/dist/scope.d.ts.map +0 -1
  143. package/dist/scope.js +0 -161
  144. package/dist/scope.js.map +0 -1
  145. package/dist/scripts/bulk-link-fix.d.ts +0 -9
  146. package/dist/scripts/bulk-link-fix.d.ts.map +0 -1
  147. package/dist/scripts/bulk-link-fix.js +0 -106
  148. package/dist/scripts/bulk-link-fix.js.map +0 -1
  149. package/dist/session-tools.d.ts +0 -33
  150. package/dist/session-tools.d.ts.map +0 -1
  151. package/dist/session-tools.js +0 -211
  152. package/dist/session-tools.js.map +0 -1
  153. package/dist/state.d.ts +0 -54
  154. package/dist/state.d.ts.map +0 -1
  155. package/dist/state.js +0 -176
  156. package/dist/state.js.map +0 -1
  157. package/dist/store.d.ts +0 -56
  158. package/dist/store.d.ts.map +0 -1
  159. package/dist/store.js +0 -227
  160. package/dist/store.js.map +0 -1
  161. package/dist/summarize.d.ts +0 -56
  162. package/dist/summarize.d.ts.map +0 -1
  163. package/dist/summarize.js +0 -231
  164. package/dist/summarize.js.map +0 -1
  165. package/dist/tag-registry.d.ts +0 -56
  166. package/dist/tag-registry.d.ts.map +0 -1
  167. package/dist/tag-registry.js +0 -146
  168. package/dist/tag-registry.js.map +0 -1
  169. package/dist/templates/prompts.d.ts +0 -63
  170. package/dist/templates/prompts.d.ts.map +0 -1
  171. package/dist/templates/prompts.js +0 -260
  172. package/dist/templates/prompts.js.map +0 -1
  173. package/dist/tools.d.ts +0 -26
  174. package/dist/tools.d.ts.map +0 -1
  175. package/dist/tools.js +0 -1033
  176. package/dist/tools.js.map +0 -1
  177. package/dist/watcher.d.ts +0 -25
  178. package/dist/watcher.d.ts.map +0 -1
  179. package/dist/watcher.js +0 -84
  180. package/dist/watcher.js.map +0 -1
  181. package/dist/webui/client/dist/index.html +0 -1885
  182. package/dist/webui/server.d.ts +0 -31
  183. package/dist/webui/server.d.ts.map +0 -1
  184. package/dist/webui/server.js +0 -566
  185. package/dist/webui/server.js.map +0 -1
  186. package/dist/wiki.d.ts +0 -103
  187. package/dist/wiki.d.ts.map +0 -1
  188. package/dist/wiki.js +0 -428
  189. package/dist/wiki.js.map +0 -1
  190. package/scripts/gepa/__pycache__/dataset.cpython-313.pyc +0 -0
  191. package/scripts/gepa/__pycache__/lm_providers.cpython-313.pyc +0 -0
  192. package/scripts/gepa/__pycache__/metric.cpython-313.pyc +0 -0
  193. package/scripts/gepa/__pycache__/program.cpython-313.pyc +0 -0
  194. package/scripts/gepa/__pycache__/wiki_reader.cpython-313.pyc +0 -0
  195. package/scripts/gepa/uv.lock +0 -2241
@@ -0,0 +1,146 @@
1
+ import { appendFile, readFile } from "node:fs/promises";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import type { Model } from "@earendil-works/pi-ai";
5
+ import type { QMDStore } from "qmd-engine";
6
+ import { StateDB } from "../state.js";
7
+ import { processSession, type ProcessResult } from "../processor.js";
8
+ import type { QueueItem } from "./state.js";
9
+ import type { WikiScheduler, SchedulerTaskHandler } from "./index.js";
10
+
11
+ export interface CompletedSessionEntry {
12
+ timestamp: string;
13
+ sessionPath: string;
14
+ }
15
+
16
+ export interface CaptureSessionPayload {
17
+ sessionPath: string;
18
+ scope?: string;
19
+ }
20
+
21
+ export interface CaptureSessionDeps {
22
+ wikiDir: string;
23
+ storeProvider: () => QMDStore | null;
24
+ model?: Model<any>;
25
+ getApiKey?: (provider: string) => Promise<string | undefined>;
26
+ stateDb?: StateDB;
27
+ processor?: (
28
+ sessionPath: string,
29
+ wikiDir: string,
30
+ store: QMDStore,
31
+ scope: string,
32
+ model: Model<any>,
33
+ getApiKey: (provider: string) => Promise<string | undefined>,
34
+ ) => Promise<ProcessResult>;
35
+ }
36
+
37
+ export function parseCompletedSessionLine(line: string): CompletedSessionEntry | null {
38
+ const trimmed = line.trim();
39
+ if (!trimmed) return null;
40
+ const parts = trimmed.split("|", 2);
41
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
42
+ return { timestamp: parts[0], sessionPath: parts[1] };
43
+ }
44
+
45
+ export async function readCompletedSessionRegistry(wikiDir: string): Promise<CompletedSessionEntry[]> {
46
+ const registryPath = join(wikiDir, ".completed-sessions");
47
+ if (!existsSync(registryPath)) return [];
48
+ const content = await readFile(registryPath, "utf-8");
49
+ return content
50
+ .split(/\r?\n/)
51
+ .map(parseCompletedSessionLine)
52
+ .filter((entry): entry is CompletedSessionEntry => entry !== null);
53
+ }
54
+
55
+ export async function appendCompletedSession(
56
+ wikiDir: string,
57
+ sessionPath: string,
58
+ timestamp = new Date().toISOString(),
59
+ ): Promise<CompletedSessionEntry> {
60
+ const registryPath = join(wikiDir, ".completed-sessions");
61
+ await appendFile(registryPath, `${timestamp}|${sessionPath}\n`, "utf-8");
62
+ return { timestamp, sessionPath };
63
+ }
64
+
65
+ export async function enqueueCompletedSessionsFromRegistry(
66
+ scheduler: WikiScheduler,
67
+ wikiDir: string,
68
+ opts: { stateDb?: StateDB } = {},
69
+ ): Promise<number[]> {
70
+ const entries = await readCompletedSessionRegistry(wikiDir);
71
+ return enqueueCompletedSessions(scheduler, entries, opts);
72
+ }
73
+
74
+ export function enqueueCompletedSessions(
75
+ scheduler: WikiScheduler,
76
+ entries: CompletedSessionEntry[],
77
+ opts: { stateDb?: StateDB } = {},
78
+ ): number[] {
79
+ const ids: number[] = [];
80
+ for (const entry of entries) {
81
+ if (!existsSync(entry.sessionPath)) continue;
82
+ if (opts.stateDb?.isProcessed(entry.sessionPath)) continue;
83
+ ids.push(scheduler.enqueue(
84
+ "capture-session",
85
+ { sessionPath: entry.sessionPath, scope: detectScopeFromSessionPath(entry.sessionPath) } satisfies CaptureSessionPayload,
86
+ { dedupeKey: `capture-session:${entry.sessionPath}`, priority: 20 },
87
+ ));
88
+ }
89
+ return ids;
90
+ }
91
+
92
+ export function createCaptureSessionHandler(deps: CaptureSessionDeps): SchedulerTaskHandler {
93
+ return async (item: QueueItem) => {
94
+ const payload = item.payload as CaptureSessionPayload;
95
+ if (!payload.sessionPath) throw new Error("capture-session payload missing sessionPath");
96
+ if (!existsSync(payload.sessionPath)) throw new Error(`Session file not found: ${payload.sessionPath}`);
97
+
98
+ const store = deps.storeProvider();
99
+ if (!store) throw new Error("QMD store unavailable for capture-session");
100
+ if (!deps.model) throw new Error("Capture model unavailable for capture-session");
101
+ if (!deps.getApiKey) throw new Error("API key resolver unavailable for capture-session");
102
+
103
+ const stateDb = deps.stateDb ?? new StateDB(deps.wikiDir);
104
+ const ownsState = !deps.stateDb;
105
+ const scope = payload.scope ?? detectScopeFromSessionPath(payload.sessionPath);
106
+ try {
107
+ if (stateDb.isProcessed(payload.sessionPath)) return;
108
+ const run = deps.processor ?? processSession;
109
+ const result = await run(payload.sessionPath, deps.wikiDir, store, scope, deps.model, deps.getApiKey);
110
+ if (result.skipped) {
111
+ stateDb.recordSuccess(payload.sessionPath, scope, [], []);
112
+ } else {
113
+ stateDb.recordSuccess(payload.sessionPath, scope, result.pagesCreated, result.pagesUpdated);
114
+ }
115
+ } catch (err) {
116
+ const message = err instanceof Error ? err.message : String(err);
117
+ stateDb.recordFailure(payload.sessionPath, scope, message);
118
+ throw err;
119
+ } finally {
120
+ if (ownsState) stateDb.close();
121
+ }
122
+ };
123
+ }
124
+
125
+ export function detectScopeFromSessionPath(sessionPath: string): string {
126
+ try {
127
+ const firstLine = requireReadFirstLine(sessionPath);
128
+ const header = JSON.parse(firstLine) as { cwd?: string };
129
+ if (header.cwd) return basename(header.cwd) || "unknown";
130
+ } catch {
131
+ // fall through to path decoding
132
+ }
133
+
134
+ const dirName = basename(dirname(sessionPath));
135
+ if (dirName.startsWith("--") && dirName.endsWith("--")) {
136
+ const inner = dirName.slice(2, -2);
137
+ const projectsIdx = inner.indexOf("projects-");
138
+ if (projectsIdx >= 0) return inner.slice(projectsIdx + "projects-".length);
139
+ }
140
+ return "unknown";
141
+ }
142
+
143
+ function requireReadFirstLine(path: string): string {
144
+ const content = readFileSync(path, "utf-8");
145
+ return content.split("\n")[0] ?? "";
146
+ }
@@ -0,0 +1,304 @@
1
+ import Database from "better-sqlite3";
2
+ import { mkdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { getParaPaths } from "../paths.js";
5
+ import { ensureLeaseSchema } from "./leases.js";
6
+
7
+ export type QueueStatus = "queued" | "running" | "done" | "failed";
8
+
9
+ export interface QueueItem<T = unknown> {
10
+ id: number;
11
+ taskName: string;
12
+ payload: T;
13
+ priority: number;
14
+ availableAt: string;
15
+ dedupeKey: string | null;
16
+ status: QueueStatus;
17
+ attempts: number;
18
+ createdAt: string;
19
+ updatedAt: string;
20
+ }
21
+
22
+ export interface EnqueueOptions {
23
+ priority?: number;
24
+ availableAt?: Date;
25
+ dedupeKey?: string;
26
+ now?: Date;
27
+ }
28
+
29
+ export interface SchedulerHistoryItem<T = unknown> {
30
+ id: number;
31
+ taskName: string;
32
+ payload: T | null;
33
+ status: string;
34
+ startedAt: string;
35
+ finishedAt: string | null;
36
+ durationMs: number | null;
37
+ error: string | null;
38
+ }
39
+
40
+ export class SchedulerStateDB {
41
+ readonly db: Database.Database;
42
+
43
+ constructor(dbPath = getParaPaths().schedulerDbPath) {
44
+ mkdirSync(dirname(dbPath), { recursive: true });
45
+ this.db = new Database(dbPath);
46
+ this.db.pragma("journal_mode = WAL");
47
+ this.migrate();
48
+ }
49
+
50
+ migrate(): void {
51
+ ensureLeaseSchema(this.db);
52
+ this.db.exec(`
53
+ CREATE TABLE IF NOT EXISTS scheduler_tasks (
54
+ task_key TEXT PRIMARY KEY,
55
+ task_name TEXT NOT NULL,
56
+ scope TEXT,
57
+ schedule TEXT,
58
+ next_due_at TEXT,
59
+ last_run_at TEXT,
60
+ last_status TEXT,
61
+ retry_count INTEGER NOT NULL DEFAULT 0,
62
+ updated_at TEXT NOT NULL
63
+ );
64
+
65
+ CREATE TABLE IF NOT EXISTS scheduler_queue (
66
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
67
+ task_name TEXT NOT NULL,
68
+ payload_json TEXT NOT NULL,
69
+ priority INTEGER NOT NULL DEFAULT 0,
70
+ available_at TEXT NOT NULL,
71
+ dedupe_key TEXT,
72
+ status TEXT NOT NULL DEFAULT 'queued',
73
+ attempts INTEGER NOT NULL DEFAULT 0,
74
+ created_at TEXT NOT NULL,
75
+ updated_at TEXT NOT NULL
76
+ );
77
+
78
+ CREATE UNIQUE INDEX IF NOT EXISTS scheduler_queue_dedupe
79
+ ON scheduler_queue(dedupe_key)
80
+ WHERE dedupe_key IS NOT NULL AND status IN ('queued', 'running');
81
+
82
+ CREATE TABLE IF NOT EXISTS scheduler_history (
83
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
84
+ task_name TEXT NOT NULL,
85
+ payload_json TEXT,
86
+ status TEXT NOT NULL,
87
+ started_at TEXT NOT NULL,
88
+ finished_at TEXT,
89
+ duration_ms INTEGER,
90
+ error TEXT
91
+ );
92
+ `);
93
+ }
94
+
95
+ enqueue(taskName: string, payload: unknown, opts: EnqueueOptions = {}): number {
96
+ const now = opts.now ?? new Date();
97
+ const availableAt = opts.availableAt ?? now;
98
+ const payloadJson = JSON.stringify(payload ?? {});
99
+ const existing = opts.dedupeKey ? this.getQueuedByDedupe(opts.dedupeKey) : null;
100
+ if (existing) return existing.id;
101
+
102
+ const result = this.db.prepare(`
103
+ INSERT INTO scheduler_queue
104
+ (task_name, payload_json, priority, available_at, dedupe_key, status, attempts, created_at, updated_at)
105
+ VALUES (?, ?, ?, ?, ?, 'queued', 0, ?, ?)
106
+ `).run(
107
+ taskName,
108
+ payloadJson,
109
+ opts.priority ?? 0,
110
+ availableAt.toISOString(),
111
+ opts.dedupeKey ?? null,
112
+ now.toISOString(),
113
+ now.toISOString(),
114
+ );
115
+ return Number(result.lastInsertRowid);
116
+ }
117
+
118
+ claimNext(now = new Date()): QueueItem | null {
119
+ this.db.exec("BEGIN IMMEDIATE");
120
+ try {
121
+ const row = this.db.prepare(`
122
+ SELECT * FROM scheduler_queue
123
+ WHERE status = 'queued' AND available_at <= ?
124
+ ORDER BY priority DESC, id ASC
125
+ LIMIT 1
126
+ `).get(now.toISOString()) as QueueRow | undefined;
127
+
128
+ if (!row) {
129
+ this.db.exec("COMMIT");
130
+ return null;
131
+ }
132
+
133
+ this.db.prepare(`
134
+ UPDATE scheduler_queue
135
+ SET status = 'running', attempts = attempts + 1, updated_at = ?
136
+ WHERE id = ?
137
+ `).run(now.toISOString(), row.id);
138
+ this.db.exec("COMMIT");
139
+ return rowToItem({ ...row, status: "running", attempts: row.attempts + 1, updated_at: now.toISOString() });
140
+ } catch (err) {
141
+ try { this.db.exec("ROLLBACK"); } catch {}
142
+ throw err;
143
+ }
144
+ }
145
+
146
+ complete(id: number, now = new Date()): void {
147
+ this.db.prepare("UPDATE scheduler_queue SET status = 'done', updated_at = ? WHERE id = ?").run(now.toISOString(), id);
148
+ }
149
+
150
+ fail(id: number, error: string, opts: { retryAt?: Date; maxAttempts?: number; now?: Date } = {}): void {
151
+ const now = opts.now ?? new Date();
152
+ const row = this.get(id);
153
+ if (!row) return;
154
+ const maxAttempts = opts.maxAttempts ?? 3;
155
+ if (row.attempts < maxAttempts && opts.retryAt) {
156
+ this.db.prepare(`
157
+ UPDATE scheduler_queue
158
+ SET status = 'queued', available_at = ?, updated_at = ?
159
+ WHERE id = ?
160
+ `).run(opts.retryAt.toISOString(), now.toISOString(), id);
161
+ } else {
162
+ this.db.prepare("UPDATE scheduler_queue SET status = 'failed', updated_at = ? WHERE id = ?").run(now.toISOString(), id);
163
+ }
164
+ this.recordHistory(row.taskName, row.payload, "failed", now, now, 0, error);
165
+ }
166
+
167
+ get(id: number): QueueItem | null {
168
+ const row = this.db.prepare("SELECT * FROM scheduler_queue WHERE id = ?").get(id) as QueueRow | undefined;
169
+ return row ? rowToItem(row) : null;
170
+ }
171
+
172
+ list(status?: QueueStatus): QueueItem[] {
173
+ const rows = status
174
+ ? this.db.prepare("SELECT * FROM scheduler_queue WHERE status = ? ORDER BY id ASC").all(status) as QueueRow[]
175
+ : this.db.prepare("SELECT * FROM scheduler_queue ORDER BY id ASC").all() as QueueRow[];
176
+ return rows.map(rowToItem);
177
+ }
178
+
179
+ requeueRunning(now = new Date()): number {
180
+ const result = this.db.prepare(`
181
+ UPDATE scheduler_queue
182
+ SET status = 'queued', available_at = ?, updated_at = ?
183
+ WHERE status = 'running'
184
+ `).run(now.toISOString(), now.toISOString());
185
+ return result.changes;
186
+ }
187
+
188
+ retryFailed(taskName?: string, now = new Date()): number {
189
+ const result = taskName
190
+ ? this.db.prepare(`
191
+ UPDATE scheduler_queue
192
+ SET status = 'queued', available_at = ?, updated_at = ?
193
+ WHERE status = 'failed' AND task_name = ?
194
+ `).run(now.toISOString(), now.toISOString(), taskName)
195
+ : this.db.prepare(`
196
+ UPDATE scheduler_queue
197
+ SET status = 'queued', available_at = ?, updated_at = ?
198
+ WHERE status = 'failed'
199
+ `).run(now.toISOString(), now.toISOString());
200
+ return result.changes;
201
+ }
202
+
203
+ listHistory(opts: { taskName?: string; limit?: number } = {}): SchedulerHistoryItem[] {
204
+ const limit = opts.limit ?? 20;
205
+ const rows = opts.taskName
206
+ ? this.db.prepare("SELECT * FROM scheduler_history WHERE task_name = ? ORDER BY id DESC LIMIT ?").all(opts.taskName, limit) as HistoryRow[]
207
+ : this.db.prepare("SELECT * FROM scheduler_history ORDER BY id DESC LIMIT ?").all(limit) as HistoryRow[];
208
+ return rows.map(rowToHistoryItem);
209
+ }
210
+
211
+ getHistoryItem(id: number): SchedulerHistoryItem | null {
212
+ const row = this.db.prepare("SELECT * FROM scheduler_history WHERE id = ?").get(id) as HistoryRow | undefined;
213
+ return row ? rowToHistoryItem(row) : null;
214
+ }
215
+
216
+ recordHistory(
217
+ taskName: string,
218
+ payload: unknown,
219
+ status: string,
220
+ startedAt: Date,
221
+ finishedAt: Date | null,
222
+ durationMs: number | null,
223
+ error: string | null,
224
+ ): void {
225
+ this.db.prepare(`
226
+ INSERT INTO scheduler_history
227
+ (task_name, payload_json, status, started_at, finished_at, duration_ms, error)
228
+ VALUES (?, ?, ?, ?, ?, ?, ?)
229
+ `).run(
230
+ taskName,
231
+ JSON.stringify(payload ?? {}),
232
+ status,
233
+ startedAt.toISOString(),
234
+ finishedAt?.toISOString() ?? null,
235
+ durationMs,
236
+ error,
237
+ );
238
+ }
239
+
240
+ close(): void {
241
+ this.db.close();
242
+ }
243
+
244
+ private getQueuedByDedupe(dedupeKey: string): QueueItem | null {
245
+ const row = this.db.prepare(`
246
+ SELECT * FROM scheduler_queue
247
+ WHERE dedupe_key = ? AND status IN ('queued', 'running')
248
+ ORDER BY id ASC LIMIT 1
249
+ `).get(dedupeKey) as QueueRow | undefined;
250
+ return row ? rowToItem(row) : null;
251
+ }
252
+ }
253
+
254
+ interface QueueRow {
255
+ id: number;
256
+ task_name: string;
257
+ payload_json: string;
258
+ priority: number;
259
+ available_at: string;
260
+ dedupe_key: string | null;
261
+ status: QueueStatus;
262
+ attempts: number;
263
+ created_at: string;
264
+ updated_at: string;
265
+ }
266
+
267
+ interface HistoryRow {
268
+ id: number;
269
+ task_name: string;
270
+ payload_json: string | null;
271
+ status: string;
272
+ started_at: string;
273
+ finished_at: string | null;
274
+ duration_ms: number | null;
275
+ error: string | null;
276
+ }
277
+
278
+ function rowToItem(row: QueueRow): QueueItem {
279
+ return {
280
+ id: row.id,
281
+ taskName: row.task_name,
282
+ payload: JSON.parse(row.payload_json) as unknown,
283
+ priority: row.priority,
284
+ availableAt: row.available_at,
285
+ dedupeKey: row.dedupe_key,
286
+ status: row.status,
287
+ attempts: row.attempts,
288
+ createdAt: row.created_at,
289
+ updatedAt: row.updated_at,
290
+ };
291
+ }
292
+
293
+ function rowToHistoryItem(row: HistoryRow): SchedulerHistoryItem {
294
+ return {
295
+ id: row.id,
296
+ taskName: row.task_name,
297
+ payload: row.payload_json ? JSON.parse(row.payload_json) as unknown : null,
298
+ status: row.status,
299
+ startedAt: row.started_at,
300
+ finishedAt: row.finished_at,
301
+ durationMs: row.duration_ms,
302
+ error: row.error,
303
+ };
304
+ }
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { readFileSync } from "node:fs";
10
10
  import { Type, type Static } from "typebox";
11
- import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
11
+ import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core";
12
12
 
13
13
  // -- Session loading ---------------------------------------------------------
14
14
 
@@ -0,0 +1,126 @@
1
+ import type { ParaUserConfig, ProviderCredentialRef } from "./config.js";
2
+
3
+ export function modelSelectionLabel(selection: ParaUserConfig["models"]["capture"]): string {
4
+ if (selection === "auto") return "auto";
5
+ return `${selection.provider}/${selection.model ?? "auto"} via ${selection.credentialRef}`;
6
+ }
7
+
8
+ export function providerProfileLabel(profile: ProviderCredentialRef | null | undefined): string {
9
+ if (!profile) return "not configured";
10
+ const model = profile.model ?? "model not set";
11
+ const location = profile.baseUrl ? ` at ${profile.baseUrl}` : "";
12
+ return `${profile.provider}/${model}${location} via ${profile.credentialRef}`;
13
+ }
14
+
15
+ export function setContextMaxTokens(config: ParaUserConfig, value: string): number {
16
+ const parsed = parsePositiveInteger(value, config.context.maxTokens);
17
+ config.context.maxTokens = parsed;
18
+ return parsed;
19
+ }
20
+
21
+ export function setSearchLimit(config: ParaUserConfig, value: string): number {
22
+ const parsed = parsePositiveInteger(value, config.context.searchLimit);
23
+ config.context.searchLimit = parsed;
24
+ return parsed;
25
+ }
26
+
27
+ export function setLintStaleDays(config: ParaUserConfig, value: string): number {
28
+ const parsed = parsePositiveInteger(value, config.lint.staleDays);
29
+ config.lint.staleDays = parsed;
30
+ return parsed;
31
+ }
32
+
33
+ export function toggleSearchGraphBoost(config: ParaUserConfig): boolean {
34
+ config.context.searchGraphBoost = !config.context.searchGraphBoost;
35
+ return config.context.searchGraphBoost;
36
+ }
37
+
38
+ export function toggleLintAutoFix(config: ParaUserConfig): boolean {
39
+ config.lint.autoFix = !config.lint.autoFix;
40
+ return config.lint.autoFix;
41
+ }
42
+
43
+ export function setCaptureModelAuto(config: ParaUserConfig): void {
44
+ config.models.capture = "auto";
45
+ }
46
+
47
+ export function setCaptureModel(config: ParaUserConfig, provider: string, model: string): ProviderCredentialRef {
48
+ const profile: ProviderCredentialRef = {
49
+ provider,
50
+ model,
51
+ credentialRef: `pi-auth:${provider}`,
52
+ };
53
+ config.models.capture = profile;
54
+ return profile;
55
+ }
56
+
57
+ export function setWebWikiEnabled(config: ParaUserConfig, enabled: boolean): void {
58
+ config.webWiki.enabled = enabled;
59
+ }
60
+
61
+ export function setWebWikiHost(config: ParaUserConfig, host: string): void {
62
+ config.webWiki.host = host.trim() || config.webWiki.host;
63
+ }
64
+
65
+ export function setWebWikiPort(config: ParaUserConfig, value: string): number {
66
+ const parsed = parsePositiveInteger(value, config.webWiki.port);
67
+ config.webWiki.port = parsed;
68
+ return parsed;
69
+ }
70
+
71
+ export function setProviderProfileField(
72
+ profile: ProviderCredentialRef,
73
+ field: "provider" | "baseUrl" | "model" | "apiFormat" | "credentialRef",
74
+ value: string,
75
+ ): void {
76
+ if (field === "apiFormat") {
77
+ if (value !== "openai" && value !== "anthropic" && value !== "custom") {
78
+ throw new Error("apiFormat must be openai, anthropic, or custom");
79
+ }
80
+ profile.apiFormat = value;
81
+ return;
82
+ }
83
+ if (field === "credentialRef") {
84
+ if (value !== "none" && !value.startsWith("pi-auth:") && !value.startsWith("secret:")) {
85
+ throw new Error("credentialRef must be pi-auth:<provider>, secret:<name>, or none");
86
+ }
87
+ profile.credentialRef = value as ProviderCredentialRef["credentialRef"];
88
+ return;
89
+ }
90
+ profile[field] = value;
91
+ }
92
+
93
+ export function setProviderDims(profile: ProviderCredentialRef, value: string): number {
94
+ const parsed = parsePositiveInteger(value, profile.dims ?? 0);
95
+ profile.dims = parsed;
96
+ return parsed;
97
+ }
98
+
99
+ export function ensureEmbeddingProfile(config: ParaUserConfig): ProviderCredentialRef {
100
+ config.qmd.embedding ??= {
101
+ provider: "openai",
102
+ model: "text-embedding-3-small",
103
+ apiFormat: "openai",
104
+ credentialRef: "secret:embedding",
105
+ };
106
+ return config.qmd.embedding;
107
+ }
108
+
109
+ export function ensureRerankProfile(config: ParaUserConfig): ProviderCredentialRef {
110
+ config.qmd.rerank ??= {
111
+ provider: "jina",
112
+ model: "jina-reranker-v2-base-multilingual",
113
+ apiFormat: "openai",
114
+ credentialRef: "secret:rerank",
115
+ };
116
+ return config.qmd.rerank;
117
+ }
118
+
119
+ export function disableRerank(config: ParaUserConfig): void {
120
+ config.qmd.rerank = null;
121
+ }
122
+
123
+ function parsePositiveInteger(value: string, fallback: number): number {
124
+ const parsed = parseInt(value, 10);
125
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
126
+ }
package/src/setup.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { atomicWriteFile } from "./atomic-write.js";
5
+ import { loadParaConfig, type LoadedParaConfig } from "./config.js";
6
+ import { getParaPaths, resolvePath } from "./paths.js";
7
+ import { initWiki } from "./wiki.js";
8
+ import { openStore, closeStore } from "./store.js";
9
+
10
+ export interface SetupOptions {
11
+ homeDir?: string;
12
+ yes?: boolean;
13
+ dryRun?: boolean;
14
+ localPath?: string;
15
+ validateQmd?: boolean;
16
+ initWiki?: boolean;
17
+ }
18
+
19
+ export interface SetupResult {
20
+ dryRun: boolean;
21
+ extensionRef: string;
22
+ configPath: string;
23
+ wikiDir: string;
24
+ changes: string[];
25
+ warnings: string[];
26
+ migratedFromLegacy: boolean;
27
+ }
28
+
29
+ export async function runSetup(options: SetupOptions = {}): Promise<SetupResult> {
30
+ const loaded = await loadParaConfig({ homeDir: options.homeDir, migrate: true });
31
+ const paths = getParaPaths({ homeDir: options.homeDir, wikiDir: loaded.config.wiki.dir });
32
+ const extensionRef = resolveExtensionRef(options.localPath);
33
+ const changes: string[] = [];
34
+ const warnings: string[] = [];
35
+
36
+ if (loaded.migratedFromLegacy) {
37
+ changes.push(`migrated legacy config to ${paths.userConfigPath}`);
38
+ } else {
39
+ changes.push(`config ready at ${paths.userConfigPath}`);
40
+ }
41
+
42
+ if (options.initWiki !== false) {
43
+ if (!options.dryRun) await initWiki(paths.wikiDir);
44
+ changes.push(`initialized wiki at ${paths.wikiDir}`);
45
+ }
46
+
47
+ const registration = await ensurePiExtensionRegistration(paths.piSettingsPath, extensionRef, {
48
+ dryRun: options.dryRun ?? false,
49
+ });
50
+ changes.push(registration.changed ? `registered extension ${extensionRef}` : `extension already registered: ${extensionRef}`);
51
+
52
+ if (options.validateQmd !== false) {
53
+ try {
54
+ if (!options.dryRun) {
55
+ const store = await openStore(paths.wikiDir, {
56
+ paraConfig: { ...loaded.config, qmd: { ...loaded.config.qmd, embedEnabled: false } },
57
+ secretsPath: paths.secretsPath,
58
+ });
59
+ await closeStore(store);
60
+ }
61
+ changes.push("validated QMD SDK store");
62
+ } catch (err) {
63
+ warnings.push(`QMD SDK validation failed: ${err instanceof Error ? err.message : String(err)}`);
64
+ }
65
+ }
66
+
67
+ return {
68
+ dryRun: options.dryRun ?? false,
69
+ extensionRef,
70
+ configPath: paths.userConfigPath,
71
+ wikiDir: paths.wikiDir,
72
+ changes,
73
+ warnings,
74
+ migratedFromLegacy: loaded.migratedFromLegacy,
75
+ };
76
+ }
77
+
78
+ export function formatSetupResult(result: SetupResult): string {
79
+ const lines = [`pi-para setup ${result.dryRun ? "dry run " : ""}complete.`];
80
+ for (const change of result.changes) lines.push(` ✓ ${change}`);
81
+ for (const warning of result.warnings) lines.push(` ⚠ ${warning}`);
82
+ lines.push("", "Next: restart open Pi sessions, then run /wiki or pi-para doctor.");
83
+ return lines.join("\n");
84
+ }
85
+
86
+ export function resolveExtensionRef(localPath?: string): string {
87
+ if (!localPath) return "npm:pi-para";
88
+ return resolvePath(localPath, process.cwd());
89
+ }
90
+
91
+ export async function ensurePiExtensionRegistration(
92
+ settingsPath: string,
93
+ extensionRef: string,
94
+ opts: { dryRun?: boolean } = {},
95
+ ): Promise<{ changed: boolean; packages: string[] }> {
96
+ const settings = readPiSettings(settingsPath);
97
+ const packages = Array.isArray(settings.packages) ? settings.packages.map(String) : [];
98
+ if (packages.includes(extensionRef)) return { changed: false, packages };
99
+
100
+ const nextPackages = [...packages, extensionRef];
101
+ settings.packages = nextPackages;
102
+ if (!opts.dryRun) {
103
+ await mkdir(dirname(settingsPath), { recursive: true });
104
+ await atomicWriteFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 0o600);
105
+ }
106
+ return { changed: true, packages: nextPackages };
107
+ }
108
+
109
+ function readPiSettings(settingsPath: string): Record<string, unknown> {
110
+ if (!existsSync(settingsPath)) return {};
111
+ return JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<string, unknown>;
112
+ }
113
+
114
+ export function summarizeSetupConfig(loaded: LoadedParaConfig): string {
115
+ return `config=${loaded.sourcePath} wiki=${loaded.config.wiki.dir}`;
116
+ }