pi-para 0.7.0 → 0.7.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/CONFIGURATION.md +2 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +47 -23
  5. package/dist/index.js.map +1 -1
  6. package/dist/processor.d.ts +7 -0
  7. package/dist/processor.d.ts.map +1 -1
  8. package/dist/processor.js +29 -15
  9. package/dist/processor.js.map +1 -1
  10. package/dist/scheduler/controls.d.ts.map +1 -1
  11. package/dist/scheduler/controls.js +2 -1
  12. package/dist/scheduler/controls.js.map +1 -1
  13. package/dist/scheduler/index.d.ts +4 -0
  14. package/dist/scheduler/index.d.ts.map +1 -1
  15. package/dist/scheduler/index.js +27 -2
  16. package/dist/scheduler/index.js.map +1 -1
  17. package/dist/scheduler/session-capture.d.ts +8 -0
  18. package/dist/scheduler/session-capture.d.ts.map +1 -1
  19. package/dist/scheduler/session-capture.js +58 -4
  20. package/dist/scheduler/session-capture.js.map +1 -1
  21. package/dist/scheduler/state.d.ts +3 -0
  22. package/dist/scheduler/state.d.ts.map +1 -1
  23. package/dist/scheduler/state.js +18 -0
  24. package/dist/scheduler/state.js.map +1 -1
  25. package/dist/state.d.ts +6 -1
  26. package/dist/state.d.ts.map +1 -1
  27. package/dist/state.js +55 -11
  28. package/dist/state.js.map +1 -1
  29. package/dist/wiki.d.ts.map +1 -1
  30. package/dist/wiki.js +1 -0
  31. package/dist/wiki.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/index.ts +45 -22
  34. package/src/processor.ts +28 -13
  35. package/src/scheduler/controls.ts +2 -1
  36. package/src/scheduler/index.ts +28 -2
  37. package/src/scheduler/session-capture.ts +68 -6
  38. package/src/scheduler/state.ts +21 -0
  39. package/src/state.ts +54 -10
  40. package/src/wiki.ts +1 -0
@@ -158,6 +158,11 @@ export class WikiScheduler {
158
158
  // No real embedding provider configured — embed calls would hit the inert
159
159
  // shim endpoint (slow TCP timeouts on Windows) and always fail. Skip.
160
160
  if (!storeHasApiProviders(store)) return;
161
+ // A credential/billing failure (401/402/403) cannot heal by retrying every
162
+ // tick and every session start; it needs a human (top up credits, fix the
163
+ // key). Pause embedding durably and resume automatically after backoff.
164
+ const backoffUntil = this.state.getValue(EMBED_BACKOFF_KEY);
165
+ if (backoffUntil && Date.parse(backoffUntil) > Date.now()) return;
161
166
 
162
167
  const status = await store.getStatus();
163
168
  const pending = status.needsEmbedding ?? 0;
@@ -170,18 +175,39 @@ export class WikiScheduler {
170
175
  const diagnostics = [...new Set(attempt.diagnostics)].join(" | ");
171
176
  if (!attempt.ok) {
172
177
  const reason = attempt.error instanceof Error ? attempt.error.message : String(attempt.error);
173
- throw new Error(diagnostics ? `${reason} — ${diagnostics}` : reason);
178
+ throw this.embedFailure(diagnostics ? `${reason} — ${diagnostics}` : reason);
174
179
  }
175
180
  const result = attempt.value;
176
181
  if (result.errors > 0) {
177
- throw new Error(
182
+ throw this.embedFailure(
178
183
  `qmd embed finished with ${result.errors} error(s) (${result.chunksEmbedded} chunk(s) embedded, ${pending} doc(s) were pending)${diagnostics ? ` — ${diagnostics}` : ""}`,
179
184
  );
180
185
  }
186
+ this.state.deleteValue(EMBED_BACKOFF_KEY);
181
187
  } finally {
182
188
  releaseLease(this.state.db, leaseKey, this.holderId);
183
189
  }
184
190
  }
191
+
192
+ /** Record durable backoff for credential/billing embed failures, then return the error. */
193
+ private embedFailure(message: string): Error {
194
+ if (isCredentialEmbedError(message)) {
195
+ const until = new Date(Date.now() + EMBED_CREDENTIAL_BACKOFF_MS).toISOString();
196
+ try {
197
+ this.state.setValue(EMBED_BACKOFF_KEY, until);
198
+ } catch { /* backoff bookkeeping is best-effort */ }
199
+ return new Error(`${message} — embedding paused until ${until} (credential/billing error; fix the provider and it resumes automatically, BM25 search keeps working)`);
200
+ }
201
+ return new Error(message);
202
+ }
203
+ }
204
+
205
+ const EMBED_BACKOFF_KEY = "qmd-embed-backoff-until";
206
+ const EMBED_CREDENTIAL_BACKOFF_MS = 6 * 60 * 60_000;
207
+
208
+ /** Credential/billing embed failures (401/402/403) that retries cannot fix. */
209
+ export function isCredentialEmbedError(message: string): boolean {
210
+ return /\bAPI 40[123]\b/.test(message);
185
211
  }
186
212
 
187
213
  const schedulers = new Map<string, WikiScheduler>();
@@ -1,5 +1,5 @@
1
- import { appendFile, readFile } from "node:fs/promises";
2
- import { existsSync, readFileSync } from "node:fs";
1
+ import { appendFile, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import type { Model } from "@earendil-works/pi-ai";
5
5
  import type { QMDStore } from "qmd-engine";
@@ -62,31 +62,93 @@ export async function appendCompletedSession(
62
62
  return { timestamp, sessionPath };
63
63
  }
64
64
 
65
+ /**
66
+ * A session file modified this recently is treated as still active: capturing
67
+ * it now would both re-read a growing file and produce a summary that is
68
+ * immediately stale, so it stays in the registry for a later pass.
69
+ */
70
+ export const ACTIVE_SESSION_QUIET_MS = 10 * 60_000;
71
+
65
72
  export async function enqueueCompletedSessionsFromRegistry(
66
73
  scheduler: WikiScheduler,
67
74
  wikiDir: string,
68
- opts: { stateDb?: StateDB } = {},
75
+ opts: { stateDb?: StateDB; quietMs?: number } = {},
69
76
  ): Promise<number[]> {
70
77
  const entries = await readCompletedSessionRegistry(wikiDir);
71
- return enqueueCompletedSessions(scheduler, entries, opts);
78
+ const { ids, survivors } = scanCompletedSessions(scheduler, entries, opts);
79
+ // Compact the registry: keep one line per still-relevant session so the
80
+ // file (and the startup scan) cannot grow without bound. Processed and
81
+ // deleted sessions are dropped; unprocessed and still-active ones survive.
82
+ if (survivors.length < entries.length) {
83
+ await compactCompletedSessionRegistry(wikiDir, entries, survivors);
84
+ }
85
+ return ids;
72
86
  }
73
87
 
74
88
  export function enqueueCompletedSessions(
75
89
  scheduler: WikiScheduler,
76
90
  entries: CompletedSessionEntry[],
77
- opts: { stateDb?: StateDB } = {},
91
+ opts: { stateDb?: StateDB; quietMs?: number } = {},
78
92
  ): number[] {
93
+ return scanCompletedSessions(scheduler, entries, opts).ids;
94
+ }
95
+
96
+ function scanCompletedSessions(
97
+ scheduler: WikiScheduler,
98
+ entries: CompletedSessionEntry[],
99
+ opts: { stateDb?: StateDB; quietMs?: number } = {},
100
+ ): { ids: number[]; survivors: CompletedSessionEntry[] } {
101
+ const quietMs = opts.quietMs ?? ACTIVE_SESSION_QUIET_MS;
79
102
  const ids: number[] = [];
103
+ const survivors: CompletedSessionEntry[] = [];
104
+ const seen = new Set<string>();
80
105
  for (const entry of entries) {
106
+ if (seen.has(entry.sessionPath)) continue;
107
+ seen.add(entry.sessionPath);
81
108
  if (!existsSync(entry.sessionPath)) continue;
109
+ // Still-active session: skip this pass but keep it registered.
110
+ try {
111
+ if (Date.now() - statSync(entry.sessionPath).mtimeMs < quietMs) {
112
+ survivors.push(entry);
113
+ continue;
114
+ }
115
+ } catch {
116
+ continue;
117
+ }
82
118
  if (opts.stateDb?.isProcessed(entry.sessionPath)) continue;
119
+ survivors.push(entry);
83
120
  ids.push(scheduler.enqueue(
84
121
  "capture-session",
85
122
  { sessionPath: entry.sessionPath, scope: detectScopeFromSessionPath(entry.sessionPath) } satisfies CaptureSessionPayload,
86
123
  { dedupeKey: `capture-session:${entry.sessionPath}`, priority: 20 },
87
124
  ));
88
125
  }
89
- return ids;
126
+ return { ids, survivors };
127
+ }
128
+
129
+ /**
130
+ * Atomically rewrite the registry with the surviving entries, merging any
131
+ * lines appended concurrently since `snapshot` was read.
132
+ */
133
+ async function compactCompletedSessionRegistry(
134
+ wikiDir: string,
135
+ snapshot: CompletedSessionEntry[],
136
+ survivors: CompletedSessionEntry[],
137
+ ): Promise<void> {
138
+ const registryPath = join(wikiDir, ".completed-sessions");
139
+ try {
140
+ const snapshotPaths = new Set(snapshot.map((entry) => entry.sessionPath));
141
+ const current = await readCompletedSessionRegistry(wikiDir);
142
+ const appendedSinceSnapshot = current.filter((entry) => !snapshotPaths.has(entry.sessionPath));
143
+ const lines = [...survivors, ...appendedSinceSnapshot]
144
+ .map((entry) => `${entry.timestamp}|${entry.sessionPath}`)
145
+ .join("\n");
146
+ const tmpPath = `${registryPath}.tmp-${process.pid}`;
147
+ await writeFile(tmpPath, lines ? `${lines}\n` : "", "utf-8");
148
+ await rename(tmpPath, registryPath);
149
+ } catch {
150
+ // Compaction is an optimization; the registry stays valid without it.
151
+ }
90
152
  }
91
153
 
92
154
  export function createCaptureSessionHandler(deps: CaptureSessionDeps): SchedulerTaskHandler {
@@ -89,9 +89,30 @@ export class SchedulerStateDB {
89
89
  duration_ms INTEGER,
90
90
  error TEXT
91
91
  );
92
+
93
+ CREATE TABLE IF NOT EXISTS scheduler_kv (
94
+ key TEXT PRIMARY KEY,
95
+ value TEXT NOT NULL,
96
+ updated_at TEXT NOT NULL
97
+ );
92
98
  `);
93
99
  }
94
100
 
101
+ getValue(key: string): string | null {
102
+ const row = this.db.prepare("SELECT value FROM scheduler_kv WHERE key = ?").get(key) as { value: string } | undefined;
103
+ return row?.value ?? null;
104
+ }
105
+
106
+ setValue(key: string, value: string): void {
107
+ this.db
108
+ .prepare("INSERT OR REPLACE INTO scheduler_kv (key, value, updated_at) VALUES (?, ?, ?)")
109
+ .run(key, value, new Date().toISOString());
110
+ }
111
+
112
+ deleteValue(key: string): void {
113
+ this.db.prepare("DELETE FROM scheduler_kv WHERE key = ?").run(key);
114
+ }
115
+
95
116
  enqueue(taskName: string, payload: unknown, opts: EnqueueOptions = {}): number {
96
117
  const now = opts.now ?? new Date();
97
118
  const availableAt = opts.availableAt ?? now;
package/src/state.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import Database from "better-sqlite3";
8
8
  import { createHash } from "node:crypto";
9
- import { readFileSync } from "node:fs";
9
+ import { readFileSync, statSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
 
12
12
  export interface PageSummary {
@@ -62,17 +62,48 @@ export class StateDB {
62
62
  updated_at TEXT NOT NULL
63
63
  );
64
64
  `);
65
+ // Stat fast path for isProcessed(): when size+mtime are unchanged since
66
+ // the recorded hash, skip re-hashing the whole session file. Critical for
67
+ // startup catch-up scans over registries with very large session JSONLs.
68
+ try {
69
+ this.db.exec("ALTER TABLE processed_sessions ADD COLUMN file_size INTEGER");
70
+ } catch { /* column exists */ }
71
+ try {
72
+ this.db.exec("ALTER TABLE processed_sessions ADD COLUMN file_mtime_ms INTEGER");
73
+ } catch { /* column exists */ }
65
74
  }
66
75
 
67
- /** Check if a session file has already been processed with the same content. */
76
+ /**
77
+ * Check if a session file has already been processed with the same content.
78
+ * Uses a stat (size+mtime) fast path before falling back to a full content
79
+ * hash, so registry-wide scans stay cheap even with multi-hundred-MB
80
+ * session files. A hash match under a changed stat refreshes the cache.
81
+ */
68
82
  isProcessed(sessionPath: string): boolean {
69
83
  const row = this.db
70
- .prepare("SELECT content_hash FROM processed_sessions WHERE session_path = ?")
71
- .get(sessionPath) as { content_hash: string } | undefined;
84
+ .prepare("SELECT content_hash, file_size, file_mtime_ms FROM processed_sessions WHERE session_path = ?")
85
+ .get(sessionPath) as { content_hash: string; file_size: number | null; file_mtime_ms: number | null } | undefined;
72
86
  if (!row) return false;
73
87
 
88
+ let stat: { size: number; mtimeMs: number };
89
+ try {
90
+ stat = statSync(sessionPath);
91
+ } catch {
92
+ return false;
93
+ }
94
+ // mtimeMs is a float and loses sub-millisecond precision across
95
+ // utimes/copy round-trips; compare with a small tolerance. Size is exact.
96
+ if (row.file_size !== null && row.file_mtime_ms !== null &&
97
+ row.file_size === stat.size && Math.abs(row.file_mtime_ms - stat.mtimeMs) <= 2) {
98
+ return true;
99
+ }
100
+
74
101
  const currentHash = hashFile(sessionPath);
75
- return row.content_hash === currentHash;
102
+ if (row.content_hash !== currentHash) return false;
103
+ this.db
104
+ .prepare("UPDATE processed_sessions SET file_size = ?, file_mtime_ms = ? WHERE session_path = ?")
105
+ .run(stat.size, Math.floor(stat.mtimeMs), sessionPath);
106
+ return true;
76
107
  }
77
108
 
78
109
  /** Get the stored hash for a session (or null if not processed). */
@@ -91,11 +122,12 @@ export class StateDB {
91
122
  pagesUpdated: string[],
92
123
  ): void {
93
124
  const hash = hashFile(sessionPath);
125
+ const stat = statFile(sessionPath);
94
126
  this.db
95
127
  .prepare(`
96
128
  INSERT OR REPLACE INTO processed_sessions
97
- (session_path, content_hash, processed_at, scope, pages_created, pages_updated, error)
98
- VALUES (?, ?, ?, ?, ?, ?, NULL)
129
+ (session_path, content_hash, processed_at, scope, pages_created, pages_updated, error, file_size, file_mtime_ms)
130
+ VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)
99
131
  `)
100
132
  .run(
101
133
  sessionPath,
@@ -104,19 +136,23 @@ export class StateDB {
104
136
  scope,
105
137
  JSON.stringify(pagesCreated),
106
138
  JSON.stringify(pagesUpdated),
139
+ stat?.size ?? null,
140
+ stat ? Math.floor(stat.mtimeMs) : null,
107
141
  );
108
142
  }
109
143
 
110
144
  /** Record a failed processing attempt. */
111
145
  recordFailure(sessionPath: string, scope: string, error: string): void {
112
146
  const hash = hashFile(sessionPath);
147
+ const stat = statFile(sessionPath);
113
148
  this.db
114
149
  .prepare(`
115
150
  INSERT OR REPLACE INTO processed_sessions
116
- (session_path, content_hash, processed_at, scope, pages_created, pages_updated, error)
117
- VALUES (?, ?, ?, ?, '[]', '[]', ?)
151
+ (session_path, content_hash, processed_at, scope, pages_created, pages_updated, error, file_size, file_mtime_ms)
152
+ VALUES (?, ?, ?, ?, '[]', '[]', ?, ?, ?)
118
153
  `)
119
- .run(sessionPath, hash, new Date().toISOString(), scope, error);
154
+ .run(sessionPath, hash, new Date().toISOString(), scope, error,
155
+ stat?.size ?? null, stat ? Math.floor(stat.mtimeMs) : null);
120
156
  }
121
157
 
122
158
  /** Get all failed sessions for retry. */
@@ -241,6 +277,14 @@ export class StateDB {
241
277
  }
242
278
  }
243
279
 
280
+ function statFile(filePath: string): { size: number; mtimeMs: number } | null {
281
+ try {
282
+ return statSync(filePath);
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+
244
288
  function hashFile(filePath: string): string {
245
289
  try {
246
290
  const content = readFileSync(filePath);
package/src/wiki.ts CHANGED
@@ -185,6 +185,7 @@ gepa/output/
185
185
 
186
186
  # local caches/logs
187
187
  *.tmp
188
+ .capture.log*
188
189
  `;
189
190
 
190
191
  // -- Helpers -----------------------------------------------------------------