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.
- package/CHANGELOG.md +14 -0
- package/CONFIGURATION.md +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47 -23
- package/dist/index.js.map +1 -1
- package/dist/processor.d.ts +7 -0
- package/dist/processor.d.ts.map +1 -1
- package/dist/processor.js +29 -15
- package/dist/processor.js.map +1 -1
- package/dist/scheduler/controls.d.ts.map +1 -1
- package/dist/scheduler/controls.js +2 -1
- package/dist/scheduler/controls.js.map +1 -1
- package/dist/scheduler/index.d.ts +4 -0
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +27 -2
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/session-capture.d.ts +8 -0
- package/dist/scheduler/session-capture.d.ts.map +1 -1
- package/dist/scheduler/session-capture.js +58 -4
- package/dist/scheduler/session-capture.js.map +1 -1
- package/dist/scheduler/state.d.ts +3 -0
- package/dist/scheduler/state.d.ts.map +1 -1
- package/dist/scheduler/state.js +18 -0
- package/dist/scheduler/state.js.map +1 -1
- package/dist/state.d.ts +6 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +55 -11
- package/dist/state.js.map +1 -1
- package/dist/wiki.d.ts.map +1 -1
- package/dist/wiki.js +1 -0
- package/dist/wiki.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +45 -22
- package/src/processor.ts +28 -13
- package/src/scheduler/controls.ts +2 -1
- package/src/scheduler/index.ts +28 -2
- package/src/scheduler/session-capture.ts +68 -6
- package/src/scheduler/state.ts +21 -0
- package/src/state.ts +54 -10
- package/src/wiki.ts +1 -0
package/src/scheduler/index.ts
CHANGED
|
@@ -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
|
|
178
|
+
throw this.embedFailure(diagnostics ? `${reason} — ${diagnostics}` : reason);
|
|
174
179
|
}
|
|
175
180
|
const result = attempt.value;
|
|
176
181
|
if (result.errors > 0) {
|
|
177
|
-
throw
|
|
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
|
-
|
|
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 {
|
package/src/scheduler/state.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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);
|