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,63 @@
1
+ import type { CollectionConfig } from "qmd-engine";
2
+ import type { ParaUserConfig, ProviderCredentialRef } from "./config.js";
3
+ import { resolveCredentialRef, type CredentialRef } from "./credentials.js";
4
+
5
+ export type QmdProvidersConfig = NonNullable<CollectionConfig["providers"]>;
6
+
7
+ export interface BuildQmdProvidersOptions {
8
+ secretsPath?: string;
9
+ authStorage?: { getApiKey(provider: string): Promise<string | undefined> };
10
+ }
11
+
12
+ export async function buildQmdProvidersFromParaConfig(
13
+ config: ParaUserConfig,
14
+ opts: BuildQmdProvidersOptions = {},
15
+ ): Promise<QmdProvidersConfig | undefined> {
16
+ if (config.qmd.providerConfig !== "pi-para-profiles") return undefined;
17
+
18
+ const providers: QmdProvidersConfig = {};
19
+
20
+ if (config.qmd.embedEnabled && config.qmd.embedding) {
21
+ providers.embed = await toQmdEndpoint(config.qmd.embedding, "embed", opts);
22
+ }
23
+
24
+ if (config.qmd.rerank) {
25
+ providers.rerank = await toQmdEndpoint(config.qmd.rerank, "rerank", opts);
26
+ }
27
+
28
+ return Object.keys(providers).length > 0 ? providers : undefined;
29
+ }
30
+
31
+ async function toQmdEndpoint(
32
+ profile: ProviderCredentialRef,
33
+ role: "embed" | "rerank",
34
+ opts: BuildQmdProvidersOptions,
35
+ ): Promise<NonNullable<QmdProvidersConfig[typeof role]>> {
36
+ const resolved = await resolveCredentialRef(profile.credentialRef as CredentialRef, opts);
37
+ const endpoint: NonNullable<QmdProvidersConfig[typeof role]> = {
38
+ url: profile.baseUrl || defaultBaseUrl(profile.provider, role),
39
+ model: profile.model,
40
+ };
41
+
42
+ if (profile.apiFormat === "openai" || profile.apiFormat === "anthropic") {
43
+ endpoint.api = profile.apiFormat;
44
+ }
45
+ if (resolved.ok && resolved.value) {
46
+ endpoint.key = resolved.value;
47
+ }
48
+ if (role === "embed" && profile.dims) {
49
+ (endpoint as NonNullable<QmdProvidersConfig["embed"]>).dims = profile.dims;
50
+ }
51
+
52
+ return endpoint;
53
+ }
54
+
55
+ export function defaultBaseUrl(provider: string, role: "embed" | "rerank"): string | undefined {
56
+ const normalized = provider.toLowerCase();
57
+ if (normalized === "openai") return "https://api.openai.com/v1";
58
+ if (normalized === "openrouter") return "https://openrouter.ai/api/v1";
59
+ if (normalized === "jina" && role === "rerank") return "https://api.jina.ai/v1";
60
+ if (normalized === "voyage") return "https://api.voyageai.com/v1";
61
+ if (normalized === "cohere") return "https://api.cohere.com/v2";
62
+ return undefined;
63
+ }
package/src/query.ts CHANGED
@@ -79,7 +79,7 @@ export async function queryWiki(
79
79
  effectiveScope = scope;
80
80
  }
81
81
 
82
- const results = await searchWiki(store, options.query, {
82
+ let results = await searchWiki(store, options.query, {
83
83
  scope: effectiveScope,
84
84
  category: options.category,
85
85
  limit,
@@ -87,6 +87,20 @@ export async function queryWiki(
87
87
  graphBoost: options.graphBoost,
88
88
  });
89
89
 
90
+ // If QMD's BM25 index is stale, freshly written/readable pages can return
91
+ // as zero search results. On a miss, refresh the filesystem index once and
92
+ // retry before telling the agent the wiki lacks matching content.
93
+ if (results.length === 0) {
94
+ await store.update();
95
+ results = await searchWiki(store, options.query, {
96
+ scope: effectiveScope,
97
+ category: options.category,
98
+ limit,
99
+ includeArchives,
100
+ graphBoost: options.graphBoost,
101
+ });
102
+ }
103
+
90
104
  return {
91
105
  results,
92
106
  scopeUsed: effectiveScope ?? scope,
package/src/repair.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { existsSync, chmodSync, readFileSync, statSync } from "node:fs";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export const GENERATED_STATE_GITIGNORE_PATTERNS = [
6
+ ".qmd.sqlite*",
7
+ ".daemon.sqlite*",
8
+ ".pi-para.sqlite*",
9
+ "gepa/input/",
10
+ "gepa/output/",
11
+ ];
12
+
13
+ export interface GitignoreRepairResult {
14
+ path: string;
15
+ changed: boolean;
16
+ added: string[];
17
+ }
18
+
19
+ export function missingGeneratedStateGitignorePatterns(wikiDir: string): string[] {
20
+ const path = join(wikiDir, ".gitignore");
21
+ const existing = existsSync(path) ? readFileSync(path, "utf-8") : "";
22
+ const lines = existing.split(/\r?\n/);
23
+ return GENERATED_STATE_GITIGNORE_PATTERNS.filter((pattern) => !lines.includes(pattern));
24
+ }
25
+
26
+ export async function ensureGeneratedStateGitignore(wikiDir: string): Promise<GitignoreRepairResult> {
27
+ const path = join(wikiDir, ".gitignore");
28
+ await mkdir(dirname(path), { recursive: true });
29
+ const existing = existsSync(path) ? readFileSync(path, "utf-8") : "";
30
+ const added = missingGeneratedStateGitignorePatterns(wikiDir);
31
+ if (added.length === 0) return { path, changed: false, added: [] };
32
+
33
+ const prefix = existing.trimEnd().length > 0 ? `${existing.trimEnd()}\n` : "";
34
+ const block = `${prefix}${prefix ? "\n" : ""}# pi-para generated state (do not version)\n${added.join("\n")}\n`;
35
+ await writeFile(path, block, "utf-8");
36
+ return { path, changed: true, added };
37
+ }
38
+
39
+ export function fixSecretPermissions(secretsPath: string): boolean {
40
+ if (!existsSync(secretsPath)) return false;
41
+ const before = readMode(secretsPath);
42
+ if ((before & 0o077) === 0) return false;
43
+ chmodSync(secretsPath, 0o600);
44
+ return true;
45
+ }
46
+
47
+ export function readMode(path: string): number {
48
+ return existsSync(path) ? (statSync(path).mode & 0o777) : 0;
49
+ }
@@ -0,0 +1,125 @@
1
+ import { getParaPaths } from "../paths.js";
2
+ import { StateDB } from "../state.js";
3
+ import { WikiScheduler } from "./index.js";
4
+ import { SchedulerStateDB, type QueueItem, type QueueStatus, type SchedulerHistoryItem } from "./state.js";
5
+ import { enqueueCompletedSessions, readCompletedSessionRegistry } from "./session-capture.js";
6
+
7
+ export interface QueueCaptureRecentOptions {
8
+ wikiDir: string;
9
+ dbPath?: string;
10
+ hours?: number;
11
+ now?: Date;
12
+ }
13
+
14
+ export interface QueueCaptureRecentResult {
15
+ scanned: number;
16
+ eligible: number;
17
+ queuedIds: number[];
18
+ }
19
+
20
+ export async function queueCaptureRecent(options: QueueCaptureRecentOptions): Promise<QueueCaptureRecentResult> {
21
+ const now = options.now ?? new Date();
22
+ const hours = options.hours ?? 24;
23
+ const cutoff = now.getTime() - hours * 60 * 60 * 1000;
24
+ const entries = await readCompletedSessionRegistry(options.wikiDir);
25
+ const eligible = entries.filter((entry) => {
26
+ const ts = Date.parse(entry.timestamp);
27
+ return Number.isFinite(ts) && ts >= cutoff;
28
+ });
29
+
30
+ const dbPath = options.dbPath ?? getParaPaths({ wikiDir: options.wikiDir }).schedulerDbPath;
31
+ const scheduler = new WikiScheduler({ wikiDir: options.wikiDir, dbPath, enabled: true });
32
+ const stateDb = new StateDB(options.wikiDir);
33
+ try {
34
+ const queuedIds = enqueueCompletedSessions(scheduler, eligible, { stateDb });
35
+ return { scanned: entries.length, eligible: eligible.length, queuedIds };
36
+ } finally {
37
+ stateDb.close();
38
+ scheduler.stop();
39
+ }
40
+ }
41
+
42
+ export function listSchedulerTasks(
43
+ wikiDir: string,
44
+ opts: { dbPath?: string; status?: QueueStatus } = {},
45
+ ): QueueItem[] {
46
+ const db = new SchedulerStateDB(opts.dbPath ?? getParaPaths({ wikiDir }).schedulerDbPath);
47
+ try {
48
+ return db.list(opts.status);
49
+ } finally {
50
+ db.close();
51
+ }
52
+ }
53
+
54
+ export function retryFailedSchedulerTasks(
55
+ wikiDir: string,
56
+ opts: { dbPath?: string; taskName?: string; now?: Date } = {},
57
+ ): number {
58
+ const db = new SchedulerStateDB(opts.dbPath ?? getParaPaths({ wikiDir }).schedulerDbPath);
59
+ try {
60
+ return db.retryFailed(opts.taskName, opts.now);
61
+ } finally {
62
+ db.close();
63
+ }
64
+ }
65
+
66
+ export function getSchedulerTask(
67
+ wikiDir: string,
68
+ id: number,
69
+ opts: { dbPath?: string } = {},
70
+ ): QueueItem | null {
71
+ const db = new SchedulerStateDB(opts.dbPath ?? getParaPaths({ wikiDir }).schedulerDbPath);
72
+ try {
73
+ return db.get(id);
74
+ } finally {
75
+ db.close();
76
+ }
77
+ }
78
+
79
+ export function listSchedulerHistory(
80
+ wikiDir: string,
81
+ opts: { dbPath?: string; taskName?: string; limit?: number } = {},
82
+ ): SchedulerHistoryItem[] {
83
+ const db = new SchedulerStateDB(opts.dbPath ?? getParaPaths({ wikiDir }).schedulerDbPath);
84
+ try {
85
+ return db.listHistory({ taskName: opts.taskName, limit: opts.limit });
86
+ } finally {
87
+ db.close();
88
+ }
89
+ }
90
+
91
+ export function formatQueueItems(items: QueueItem[]): string {
92
+ if (items.length === 0) return "No scheduler tasks queued.";
93
+ return items
94
+ .map((item) => `${item.id} ${item.status} ${item.taskName} attempts=${item.attempts} dedupe=${item.dedupeKey ?? "-"}`)
95
+ .join("\n");
96
+ }
97
+
98
+ export function formatQueueCaptureRecentResult(result: QueueCaptureRecentResult): string {
99
+ return [
100
+ `Scanned ${result.scanned} completed-session entr${result.scanned === 1 ? "y" : "ies"}.`,
101
+ `Eligible within window: ${result.eligible}.`,
102
+ `Queued ${result.queuedIds.length} capture task${result.queuedIds.length === 1 ? "" : "s"}.`,
103
+ ].join("\n");
104
+ }
105
+
106
+ export function formatQueueItem(item: QueueItem | null): string {
107
+ if (!item) return "Scheduler task not found.";
108
+ return [
109
+ `Task ${item.id}: ${item.taskName}`,
110
+ `Status: ${item.status}`,
111
+ `Attempts: ${item.attempts}`,
112
+ `Available: ${item.availableAt}`,
113
+ `Dedupe: ${item.dedupeKey ?? "-"}`,
114
+ `Payload: ${JSON.stringify(item.payload, null, 2)}`,
115
+ ].join("\n");
116
+ }
117
+
118
+ export function formatSchedulerHistory(items: SchedulerHistoryItem[]): string {
119
+ if (items.length === 0) return "No scheduler history.";
120
+ return items.map((item) => {
121
+ const duration = item.durationMs === null ? "-" : `${item.durationMs}ms`;
122
+ const error = item.error ? ` error=${item.error.slice(0, 80)}` : "";
123
+ return `${item.id} ${item.status} ${item.taskName} duration=${duration}${error}`;
124
+ }).join("\n");
125
+ }
@@ -0,0 +1,210 @@
1
+ import type { QMDStore } from "qmd-engine";
2
+ import { randomUUID } from "node:crypto";
3
+ import { gitCommit, rebuildIndex } from "../wiki.js";
4
+ import { reindex } from "../store.js";
5
+ import { getParaPaths } from "../paths.js";
6
+ import { acquireLease, releaseLease } from "./leases.js";
7
+ import { SchedulerStateDB, type QueueItem } from "./state.js";
8
+
9
+ export type SchedulerTaskHandler = (item: QueueItem, scheduler: WikiScheduler) => Promise<void>;
10
+
11
+ export interface WikiSchedulerOptions {
12
+ wikiDir: string;
13
+ dbPath?: string;
14
+ intervalMs?: number;
15
+ enabled?: boolean;
16
+ storeProvider?: () => QMDStore | null;
17
+ markDirty?: () => void;
18
+ handlers?: Record<string, SchedulerTaskHandler>;
19
+ }
20
+
21
+ export class WikiScheduler {
22
+ readonly wikiDir: string;
23
+ readonly holderId: string;
24
+ readonly state: SchedulerStateDB;
25
+ private timer: ReturnType<typeof setInterval> | null = null;
26
+ private running = false;
27
+ private stopped = false;
28
+ private closed = false;
29
+ private readonly intervalMs: number;
30
+ private readonly enabled: boolean;
31
+ private readonly storeProvider: () => QMDStore | null;
32
+ private readonly markDirty: () => void;
33
+ private handlers: Record<string, SchedulerTaskHandler>;
34
+
35
+ constructor(opts: WikiSchedulerOptions) {
36
+ this.wikiDir = opts.wikiDir;
37
+ this.holderId = `scheduler:${process.pid}:${randomUUID()}`;
38
+ this.state = new SchedulerStateDB(opts.dbPath ?? getParaPaths({ wikiDir: opts.wikiDir }).schedulerDbPath);
39
+ this.intervalMs = opts.intervalMs ?? 15 * 60_000;
40
+ this.enabled = opts.enabled ?? true;
41
+ this.storeProvider = opts.storeProvider ?? (() => null);
42
+ this.markDirty = opts.markDirty ?? (() => {});
43
+ this.handlers = {
44
+ "wiki-maintenance": async () => this.runWikiMaintenance(),
45
+ ...(opts.handlers ?? {}),
46
+ };
47
+ }
48
+
49
+ start(): void {
50
+ if (!this.enabled || this.timer || this.closed) return;
51
+ this.stopped = false;
52
+ this.state.requeueRunning();
53
+ void this.tick();
54
+ this.timer = setInterval(() => void this.tick(), this.intervalMs);
55
+ (this.timer as { unref?: () => void }).unref?.();
56
+ }
57
+
58
+ stop(): void {
59
+ this.stopped = true;
60
+ if (this.timer) clearInterval(this.timer);
61
+ this.timer = null;
62
+ if (!this.running) this.closeState();
63
+ }
64
+
65
+ private closeState(): void {
66
+ if (this.closed) return;
67
+ this.closed = true;
68
+ this.state.close();
69
+ }
70
+
71
+ enqueue(taskName: string, payload: unknown = {}, opts: { priority?: number; dedupeKey?: string; availableAt?: Date } = {}): number {
72
+ if (this.closed) throw new Error("Wiki scheduler is stopped.");
73
+ return this.state.enqueue(taskName, payload, opts);
74
+ }
75
+
76
+ registerHandlers(handlers: Record<string, SchedulerTaskHandler>): void {
77
+ this.handlers = { ...this.handlers, ...handlers };
78
+ }
79
+
80
+ async tick(): Promise<number> {
81
+ if (!this.enabled || this.running || this.stopped || this.closed) return 0;
82
+ this.running = true;
83
+ let processed = 0;
84
+ try {
85
+ for (;;) {
86
+ if (this.stopped) break;
87
+ const item = this.state.claimNext();
88
+ if (!item) break;
89
+ await this.runItem(item);
90
+ processed++;
91
+ }
92
+ } finally {
93
+ this.running = false;
94
+ if (this.stopped) this.closeState();
95
+ }
96
+ return processed;
97
+ }
98
+
99
+ private async runItem(item: QueueItem): Promise<void> {
100
+ const handler = this.handlers[item.taskName];
101
+ const started = new Date();
102
+ if (!handler) {
103
+ this.state.fail(item.id, `Unknown scheduler task: ${item.taskName}`, { now: started, maxAttempts: 1 });
104
+ return;
105
+ }
106
+
107
+ try {
108
+ await handler(item, this);
109
+ if (this.stopped || this.closed) return;
110
+ const finished = new Date();
111
+ this.state.complete(item.id, finished);
112
+ this.state.recordHistory(item.taskName, item.payload, "done", started, finished, finished.getTime() - started.getTime(), null);
113
+ } catch (err) {
114
+ if (this.stopped || this.closed) return;
115
+ const retryAt = new Date(Date.now() + Math.min(60_000, 1_000 * 2 ** item.attempts));
116
+ this.state.fail(item.id, err instanceof Error ? err.message : String(err), { retryAt, now: new Date() });
117
+ }
118
+ }
119
+
120
+ private async runWikiMaintenance(): Promise<void> {
121
+ const leaseKey = `wiki-maintenance:${this.wikiDir}`;
122
+ if (!acquireLease(this.state.db, leaseKey, this.holderId, { ttlMs: 5 * 60_000 })) return;
123
+ try {
124
+ await rebuildIndex(this.wikiDir);
125
+ const store = this.storeProvider();
126
+ if (store) await reindex(store);
127
+ this.markDirty();
128
+ await gitCommit(this.wikiDir, "wiki: rebuild index and refresh search");
129
+ } finally {
130
+ releaseLease(this.state.db, leaseKey, this.holderId);
131
+ }
132
+ }
133
+ }
134
+
135
+ const schedulers = new Map<string, WikiScheduler>();
136
+ const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
137
+
138
+ export function getWikiScheduler(opts: WikiSchedulerOptions): WikiScheduler {
139
+ const key = opts.dbPath ?? getParaPaths({ wikiDir: opts.wikiDir }).schedulerDbPath;
140
+ const existing = schedulers.get(key);
141
+ if (existing) {
142
+ if (opts.handlers) existing.registerHandlers(opts.handlers);
143
+ return existing;
144
+ }
145
+ const scheduler = new WikiScheduler(opts);
146
+ schedulers.set(key, scheduler);
147
+ return scheduler;
148
+ }
149
+
150
+ export function startWikiScheduler(opts: WikiSchedulerOptions): WikiScheduler {
151
+ const scheduler = getWikiScheduler(opts);
152
+ scheduler.start();
153
+ return scheduler;
154
+ }
155
+
156
+ export function stopWikiScheduler(wikiDir: string, dbPath?: string): void {
157
+ const key = dbPath ?? getParaPaths({ wikiDir }).schedulerDbPath;
158
+ const timer = debounceTimers.get(key);
159
+ if (timer) {
160
+ clearTimeout(timer);
161
+ debounceTimers.delete(key);
162
+ }
163
+ const scheduler = schedulers.get(key);
164
+ if (!scheduler) return;
165
+ scheduler.stop();
166
+ schedulers.delete(key);
167
+ }
168
+
169
+ export function enqueueWikiMaintenance(
170
+ wikiDir: string,
171
+ store: QMDStore,
172
+ markDirty: () => void,
173
+ opts: { delayMs?: number; dbPath?: string } = {},
174
+ ): number | null {
175
+ const key = opts.dbPath ?? getParaPaths({ wikiDir }).schedulerDbPath;
176
+ const existingTimer = debounceTimers.get(key);
177
+ if (existingTimer) clearTimeout(existingTimer);
178
+
179
+ const scheduler = getWikiScheduler({
180
+ wikiDir,
181
+ dbPath: opts.dbPath,
182
+ storeProvider: () => store,
183
+ markDirty,
184
+ });
185
+
186
+ const delayMs = opts.delayMs ?? 2_000;
187
+ if (delayMs <= 0) {
188
+ const id = scheduler.enqueue("wiki-maintenance", {}, { dedupeKey: "wiki-maintenance", priority: 10 });
189
+ void scheduler.tick();
190
+ return id;
191
+ }
192
+
193
+ const timer = setTimeout(() => {
194
+ debounceTimers.delete(key);
195
+ scheduler.enqueue("wiki-maintenance", {}, { dedupeKey: "wiki-maintenance", priority: 10 });
196
+ void scheduler.tick();
197
+ }, delayMs);
198
+ (timer as { unref?: () => void }).unref?.();
199
+ debounceTimers.set(key, timer);
200
+ return null;
201
+ }
202
+
203
+ export function resetSchedulersForTests(): void {
204
+ for (const timer of debounceTimers.values()) clearTimeout(timer);
205
+ debounceTimers.clear();
206
+ for (const [key, scheduler] of schedulers) {
207
+ scheduler.stop();
208
+ schedulers.delete(key);
209
+ }
210
+ }
@@ -0,0 +1,98 @@
1
+ import Database from "better-sqlite3";
2
+ import { randomUUID } from "node:crypto";
3
+
4
+ export interface LeaseRecord {
5
+ leaseKey: string;
6
+ holderId: string;
7
+ expiresAt: string;
8
+ heartbeatAt: string;
9
+ metadataJson: string | null;
10
+ }
11
+
12
+ export function ensureLeaseSchema(db: Database.Database): void {
13
+ db.exec(`
14
+ CREATE TABLE IF NOT EXISTS scheduler_leases (
15
+ lease_key TEXT PRIMARY KEY,
16
+ holder_id TEXT NOT NULL,
17
+ expires_at TEXT NOT NULL,
18
+ heartbeat_at TEXT NOT NULL,
19
+ metadata_json TEXT
20
+ );
21
+ `);
22
+ }
23
+
24
+ export function createLeaseHolder(prefix = "pi-para"): string {
25
+ return `${prefix}:${process.pid}:${randomUUID()}`;
26
+ }
27
+
28
+ export function acquireLease(
29
+ db: Database.Database,
30
+ leaseKey: string,
31
+ holderId: string,
32
+ opts: { ttlMs?: number; now?: Date; metadata?: unknown } = {},
33
+ ): boolean {
34
+ ensureLeaseSchema(db);
35
+ const now = opts.now ?? new Date();
36
+ const ttlMs = opts.ttlMs ?? 2 * 60_000;
37
+ const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
38
+ const nowIso = now.toISOString();
39
+ const metadataJson = opts.metadata === undefined ? null : JSON.stringify(opts.metadata);
40
+
41
+ db.exec("BEGIN IMMEDIATE");
42
+ try {
43
+ const current = db.prepare("SELECT holder_id, expires_at FROM scheduler_leases WHERE lease_key = ?").get(leaseKey) as
44
+ | { holder_id: string; expires_at: string }
45
+ | undefined;
46
+
47
+ const expired = !current || Date.parse(current.expires_at) <= now.getTime();
48
+ const sameHolder = current?.holder_id === holderId;
49
+ if (!expired && !sameHolder) {
50
+ db.exec("ROLLBACK");
51
+ return false;
52
+ }
53
+
54
+ db.prepare(`
55
+ INSERT OR REPLACE INTO scheduler_leases
56
+ (lease_key, holder_id, expires_at, heartbeat_at, metadata_json)
57
+ VALUES (?, ?, ?, ?, ?)
58
+ `).run(leaseKey, holderId, expiresAt, nowIso, metadataJson);
59
+ db.exec("COMMIT");
60
+ return true;
61
+ } catch (err) {
62
+ try { db.exec("ROLLBACK"); } catch {}
63
+ throw err;
64
+ }
65
+ }
66
+
67
+ export function renewLease(
68
+ db: Database.Database,
69
+ leaseKey: string,
70
+ holderId: string,
71
+ opts: { ttlMs?: number; now?: Date } = {},
72
+ ): boolean {
73
+ ensureLeaseSchema(db);
74
+ const now = opts.now ?? new Date();
75
+ const expiresAt = new Date(now.getTime() + (opts.ttlMs ?? 2 * 60_000)).toISOString();
76
+ const result = db.prepare(`
77
+ UPDATE scheduler_leases
78
+ SET expires_at = ?, heartbeat_at = ?
79
+ WHERE lease_key = ? AND holder_id = ?
80
+ `).run(expiresAt, now.toISOString(), leaseKey, holderId);
81
+ return result.changes === 1;
82
+ }
83
+
84
+ export function releaseLease(db: Database.Database, leaseKey: string, holderId: string): boolean {
85
+ ensureLeaseSchema(db);
86
+ const result = db.prepare("DELETE FROM scheduler_leases WHERE lease_key = ? AND holder_id = ?").run(leaseKey, holderId);
87
+ return result.changes === 1;
88
+ }
89
+
90
+ export function getLease(db: Database.Database, leaseKey: string): LeaseRecord | null {
91
+ ensureLeaseSchema(db);
92
+ const row = db.prepare(`
93
+ SELECT lease_key as leaseKey, holder_id as holderId, expires_at as expiresAt,
94
+ heartbeat_at as heartbeatAt, metadata_json as metadataJson
95
+ FROM scheduler_leases WHERE lease_key = ?
96
+ `).get(leaseKey) as LeaseRecord | undefined;
97
+ return row ?? null;
98
+ }