mixdog 1.0.7 → 1.0.9
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/package.json +2 -1
- package/scripts/lib/run-node-tests.mjs +13 -0
- package/scripts/session-efficiency-diag.mjs +283 -0
- package/src/defaults/skills/computer-use/SKILL.md +3 -3
- package/src/defaults/skills/local-provider/SKILL.md +2 -1
- package/src/defaults/skills/setup/SKILL.md +4 -4
- package/src/defaults/skills/setup/references/actions.md +1 -3
- package/src/defaults/skills/setup/references/surfaces.md +2 -3
- package/src/headless-exec.mjs +1 -0
- package/src/rules/shared/70-delivery.md +2 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +10 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +8 -4
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +10 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth-tokens.mjs +18 -3
- package/src/runtime/agent/orchestrator/providers/client-version-store.mjs +50 -0
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +27 -4
- package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +9 -5
- package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +18 -4
- package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-models.mjs +7 -1
- package/src/runtime/agent/orchestrator/providers/npm-cli-version.mjs +25 -2
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +7 -1
- package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +4 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +8 -0
- package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +17 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +7 -1
- package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +16 -0
- package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +0 -20
- package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +12 -10
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +15 -3
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +23 -9
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/deadline-plan.mjs +15 -6
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +7 -0
- package/src/runtime/channels/lib/voice-transcription.mjs +30 -4
- package/src/runtime/computer-bridge/action-schema.mjs +3 -3
- package/src/runtime/computer-bridge/actions.mjs +4 -0
- package/src/runtime/computer-bridge/bridge-env.test-support.mjs +22 -0
- package/src/runtime/computer-bridge/client.mjs +10 -183
- package/src/runtime/computer-bridge/core-actions.mjs +2 -1
- package/src/runtime/computer-bridge/error-recovery.mjs +46 -34
- package/src/runtime/computer-bridge/result-canonical.mjs +181 -0
- package/src/runtime/local-provider/asset-installer.mjs +20 -10
- package/src/runtime/local-provider/catalog.mjs +4 -7
- package/src/runtime/local-provider/data/manifest.json +14 -1
- package/src/runtime/local-provider/hardware.mjs +36 -5
- package/src/runtime/local-provider/resumable-installations.mjs +2 -2
- package/src/runtime/local-provider/server.mjs +1 -1
- package/src/runtime/memory/index.mjs +2 -4
- package/src/runtime/memory/lib/core-memory-file.mjs +3 -8
- package/src/runtime/memory/lib/core-memory-store.mjs +14 -61
- package/src/runtime/memory/lib/cycle-scheduler/backlog-probe.mjs +5 -9
- package/src/runtime/memory/lib/cycle1/cycle1-plan.mjs +7 -0
- package/src/runtime/memory/lib/cycle1/cycle1-rows.mjs +43 -16
- package/src/runtime/memory/lib/cycle1/cycle1-window.mjs +11 -2
- package/src/runtime/memory/lib/embedding-provider.mjs +2 -5
- package/src/runtime/memory/lib/embedding-reindex.mjs +1 -5
- package/src/runtime/memory/lib/http-router/lifecycle-routes.mjs +0 -1
- package/src/runtime/memory/lib/memory-action-handlers/maintenance-actions.mjs +1 -1
- package/src/runtime/memory/lib/memory-schema/core-and-meta.mjs +4 -22
- package/src/runtime/memory/lib/memory.mjs +79 -32
- package/src/runtime/memory/lib/query-maintenance-handlers.mjs +0 -2
- package/src/runtime/office/com/com-adapter.mjs +3 -0
- package/src/runtime/office/pdf/pdf-adapter.mjs +50 -14
- package/src/runtime/office/pdf/pdf-render.mjs +2 -6
- package/src/runtime/office/pdf/pdf-security.mjs +8 -2
- package/src/runtime/office/portable/font-provisioner.mjs +28 -0
- package/src/runtime/office/portable/portable-docx-edits.mjs +28 -1
- package/src/runtime/office/portable/portable-soffice.mjs +44 -7
- package/src/runtime/shared/llm/anthropic-betas.mjs +7 -1
- package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +1 -1
- package/src/runtime/shared/llm/default-context-window.mjs +42 -0
- package/src/runtime/shared/llm/model-catalog-projection.mjs +2 -0
- package/src/runtime/shared/llm/model-catalog.mjs +2 -0
- package/src/runtime/shared/plugin-paths.mjs +9 -2
- package/src/runtime/shared/worker-exec-argv.mjs +25 -0
- package/src/runtime/web-search/lib/formatter.mjs +43 -18
- package/src/session-runtime/automation-agents.mjs +88 -0
- package/src/session-runtime/automation-workflow.mjs +19 -6
- package/src/session-runtime/boot/apis.mjs +0 -1
- package/src/session-runtime/cwd-plugins/core-memory-context.mjs +14 -12
- package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +4 -25
- package/src/session-runtime/model-recency.mjs +10 -2
- package/src/session-runtime/model-route/set-route.mjs +7 -1
- package/src/session-runtime/resource-api.mjs +8 -2
- package/src/session-runtime/resource-plugins-api.mjs +14 -2
- package/src/session-runtime/route-state.mjs +6 -2
- package/src/session-runtime/schedule-session-run.mjs +7 -1
- package/src/session-runtime/services/agent-tool/render.mjs +0 -5
- package/src/session-runtime/settings-builtin-tools-api.mjs +0 -16
- package/src/session-runtime/setup-tool/executor.mjs +7 -13
- package/src/session-runtime/setup-tool/tool-defs.mjs +2 -3
- package/src/session-runtime/tool-policy-refresh.mjs +0 -2
- package/src/session-runtime/webhook-session-run.mjs +10 -11
- package/src/session-runtime/workflow-agents-api/agent-route.mjs +5 -0
- package/src/standalone/daemon-agent-control.mjs +8 -1
- package/src/standalone/daemon.mjs +4 -0
- package/src/tui/dist/index.mjs +3 -2
- package/src/tui/session/agent-job-feed/notification-router.mjs +7 -0
- package/src/tui/session/live-share.mjs +16 -1
- package/src/tui/session/notification-plan.mjs +4 -0
- package/src/session-runtime/bridge-first-use-gate.mjs +0 -72
|
@@ -4,7 +4,31 @@
|
|
|
4
4
|
// terminal / omitted sentinel updates.
|
|
5
5
|
import { __mixdogMemoryLog } from '../memory-log.mjs';
|
|
6
6
|
import { throwIfAborted } from '../memory-cycle-shared.mjs';
|
|
7
|
-
import { CYCLE1_OMITTED_COOLDOWN_MS } from './cycle1-plan.mjs';
|
|
7
|
+
import { CYCLE1_OMITTED_COOLDOWN_MAX_MS, CYCLE1_OMITTED_COOLDOWN_MS } from './cycle1-plan.mjs';
|
|
8
|
+
|
|
9
|
+
const CYCLE1_OMITTED_MAX_DOUBLINGS = Math.ceil(Math.log2(CYCLE1_OMITTED_COOLDOWN_MAX_MS / CYCLE1_OMITTED_COOLDOWN_MS));
|
|
10
|
+
|
|
11
|
+
/** SQL for "this unchunked row may be sent now": never reviewed, or its
|
|
12
|
+
* cooldown has passed. The cooldown starts at CYCLE1_OMITTED_COOLDOWN_MS and
|
|
13
|
+
* doubles with each further miss counted in error_count, up to the maximum.
|
|
14
|
+
* `nowParam` is the placeholder holding the current time in ms. */
|
|
15
|
+
export function cycle1RowDueSql(nowParam, alias = '') {
|
|
16
|
+
const column = alias ? `${alias}.` : '';
|
|
17
|
+
const doublings = `LEAST(GREATEST(COALESCE(${column}error_count, 0) - 1, 0), ${CYCLE1_OMITTED_MAX_DOUBLINGS})`;
|
|
18
|
+
const cooldown = `LEAST(${CYCLE1_OMITTED_COOLDOWN_MAX_MS}::bigint, ${CYCLE1_OMITTED_COOLDOWN_MS}::bigint * (1::bigint << ${doublings}))`;
|
|
19
|
+
return `(${column}reviewed_at IS NULL OR ${column}reviewed_at < ${nowParam}::bigint - ${cooldown})`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** SQL for "an unchunked row that belongs to a session". `session_id IS NOT
|
|
23
|
+
* NULL` is implied by the trimmed check; stating it is what lets the planner
|
|
24
|
+
* use the partial index on pending rows. Without it every count scanned the
|
|
25
|
+
* whole table, and the backlog probe alone runs two a minute (EXPLAIN ANALYZE
|
|
26
|
+
* on a 162K-row store: parallel seq scan, 100-170 ms and 21K blocks read,
|
|
27
|
+
* against 32 ms through the index). */
|
|
28
|
+
export function cycle1UnchunkedSql(alias = '') {
|
|
29
|
+
const column = alias ? `${alias}.` : '';
|
|
30
|
+
return `${column}chunk_root IS NULL AND ${column}session_id IS NOT NULL AND NULLIF(btrim(${column}session_id), '') IS NOT NULL`;
|
|
31
|
+
}
|
|
8
32
|
|
|
9
33
|
export function isStructurallyUnchunkableInput(row) {
|
|
10
34
|
return !String(row?.content ?? '').trim();
|
|
@@ -41,19 +65,23 @@ export async function markTerminalRows(db, rowIds, label = 'terminal') {
|
|
|
41
65
|
}
|
|
42
66
|
}
|
|
43
67
|
|
|
44
|
-
|
|
68
|
+
/** Defers the rows a window left raw. Only a miss the classifier produced
|
|
69
|
+
* counts toward the lengthening cooldown (cycle1RowDueSql); `transientRowIds`
|
|
70
|
+
* never got an answer — a provider error, a commit that lost a race — and
|
|
71
|
+
* come back at the base cooldown however long the outage lasts. */
|
|
72
|
+
export async function markOmittedRows(db, rowIds, { transientRowIds = [] } = {}) {
|
|
45
73
|
const ids = positiveEntryIds(rowIds);
|
|
46
74
|
if (ids.length === 0) return { attempted: 0, deferred: 0, marked: 0, failed: 0 };
|
|
47
75
|
try {
|
|
48
76
|
const result = await db.query(
|
|
49
77
|
`UPDATE entries
|
|
50
78
|
SET reviewed_at = $2,
|
|
51
|
-
error_count = COALESCE(error_count, 0) + 1
|
|
79
|
+
error_count = COALESCE(error_count, 0) + CASE WHEN id = ANY($3::bigint[]) THEN 0 ELSE 1 END
|
|
52
80
|
WHERE id = ANY($1::bigint[])
|
|
53
81
|
AND chunk_root IS NULL
|
|
54
82
|
AND is_root = 0
|
|
55
83
|
RETURNING id`,
|
|
56
|
-
[ids, Date.now()]
|
|
84
|
+
[ids, Date.now(), positiveEntryIds(transientRowIds)]
|
|
57
85
|
);
|
|
58
86
|
const rows = Array.isArray(result?.rows) ? result.rows : [];
|
|
59
87
|
// A model failure is not permission to retire source data. Keep every
|
|
@@ -81,12 +109,12 @@ export function selectRootId(members) {
|
|
|
81
109
|
return rootId;
|
|
82
110
|
}
|
|
83
111
|
|
|
84
|
-
async function countSessionUnchunkedRows(db, {
|
|
85
|
-
const where = [
|
|
112
|
+
async function countSessionUnchunkedRows(db, { dueAt = null } = {}) {
|
|
113
|
+
const where = [cycle1UnchunkedSql()];
|
|
86
114
|
const params = [];
|
|
87
|
-
if (
|
|
88
|
-
params.push(
|
|
89
|
-
where.push('
|
|
115
|
+
if (dueAt != null) {
|
|
116
|
+
params.push(dueAt);
|
|
117
|
+
where.push(cycle1RowDueSql('$1'));
|
|
90
118
|
}
|
|
91
119
|
try {
|
|
92
120
|
const result = await db.query(
|
|
@@ -102,7 +130,7 @@ async function countSessionUnchunkedRows(db, { reviewedBefore = null } = {}) {
|
|
|
102
130
|
}
|
|
103
131
|
|
|
104
132
|
export function countPendingRows(db) {
|
|
105
|
-
return countSessionUnchunkedRows(db, {
|
|
133
|
+
return countSessionUnchunkedRows(db, { dueAt: Date.now() });
|
|
106
134
|
}
|
|
107
135
|
|
|
108
136
|
export function countRawUnchunkedRows(db) {
|
|
@@ -115,7 +143,7 @@ export function countRawUnchunkedRows(db) {
|
|
|
115
143
|
* classifier prompt. Rows come back newest-first. */
|
|
116
144
|
export async function fetchCycle1Rows(db, plan, now = Date.now()) {
|
|
117
145
|
const sessionFilterSql = plan.onlySessionId ? 'AND session_id = $4' : '';
|
|
118
|
-
const queryParams = [plan.sessionCap, now
|
|
146
|
+
const queryParams = [plan.sessionCap, now, plan.rowsPerSession];
|
|
119
147
|
if (plan.onlySessionId) queryParams.push(plan.onlySessionId);
|
|
120
148
|
queryParams.push(plan.backfillCap);
|
|
121
149
|
const backfillParam = `$${queryParams.length}`;
|
|
@@ -136,9 +164,8 @@ export async function fetchCycle1Rows(db, plan, now = Date.now()) {
|
|
|
136
164
|
`WITH eligible_sessions AS (
|
|
137
165
|
SELECT session_id, MAX(ts) AS latest_ts, MAX(id) AS latest_id, MIN(ts) AS oldest_ts
|
|
138
166
|
FROM entries
|
|
139
|
-
WHERE
|
|
140
|
-
AND
|
|
141
|
-
AND (reviewed_at IS NULL OR reviewed_at < $2)
|
|
167
|
+
WHERE ${cycle1UnchunkedSql()}
|
|
168
|
+
AND ${cycle1RowDueSql('$2')}
|
|
142
169
|
${sessionFilterSql}
|
|
143
170
|
GROUP BY session_id
|
|
144
171
|
), due_sessions AS (
|
|
@@ -162,8 +189,8 @@ export async function fetchCycle1Rows(db, plan, now = Date.now()) {
|
|
|
162
189
|
ROW_NUMBER() OVER (PARTITION BY e.session_id ORDER BY e.ts DESC, e.id DESC) AS rn
|
|
163
190
|
FROM entries e
|
|
164
191
|
JOIN selected_sessions s ON s.session_id = e.session_id
|
|
165
|
-
WHERE e
|
|
166
|
-
AND (
|
|
192
|
+
WHERE ${cycle1UnchunkedSql('e')}
|
|
193
|
+
AND ${cycle1RowDueSql('$2', 'e')}
|
|
167
194
|
)
|
|
168
195
|
SELECT id, ts, role, content, session_id, source_ref, project_id
|
|
169
196
|
FROM ranked
|
|
@@ -66,6 +66,11 @@ async function commitGeneratedChunks(db, generated, signal) {
|
|
|
66
66
|
const invalidChunks = generated.invalidChunks;
|
|
67
67
|
const invalidRowIds = new Set(invalidChunks.flatMap((chunk) => chunk.member_ids || []));
|
|
68
68
|
const failedRowIds = generated.rawRowIds.filter((id) => invalidRowIds.has(id));
|
|
69
|
+
// Rows that never got an answer from the classifier, as opposed to rows it
|
|
70
|
+
// answered badly or left out: these do not lengthen their retry cooldown.
|
|
71
|
+
const transientRowIds = invalidChunks
|
|
72
|
+
.filter((chunk) => chunk.reason === 'llm_error')
|
|
73
|
+
.flatMap((chunk) => chunk.member_ids || []);
|
|
69
74
|
const commitStartedAt = Date.now();
|
|
70
75
|
for (const chunk of generated.chunks) {
|
|
71
76
|
// A chunk commit is one DB transaction; do not split it with an abort
|
|
@@ -97,7 +102,10 @@ async function commitGeneratedChunks(db, generated, signal) {
|
|
|
97
102
|
} catch (err) {
|
|
98
103
|
__mixdogMemoryLog(`[cycle1] chunk commit failed (root=${rootId}): ${err.message}\n`);
|
|
99
104
|
skippedChunks += 1;
|
|
100
|
-
for (const mid of memberIds)
|
|
105
|
+
for (const mid of memberIds) {
|
|
106
|
+
failedRowIds.push(mid);
|
|
107
|
+
transientRowIds.push(mid);
|
|
108
|
+
}
|
|
101
109
|
}
|
|
102
110
|
}
|
|
103
111
|
return {
|
|
@@ -107,6 +115,7 @@ async function commitGeneratedChunks(db, generated, signal) {
|
|
|
107
115
|
skippedChunks,
|
|
108
116
|
invalidChunks,
|
|
109
117
|
failedRowIds,
|
|
118
|
+
transientRowIds,
|
|
110
119
|
commitStartedAt,
|
|
111
120
|
};
|
|
112
121
|
}
|
|
@@ -160,7 +169,7 @@ export async function processCycle1Window({ db, rows: originalRows, windowIdx, p
|
|
|
160
169
|
|
|
161
170
|
const rawRowIds = rows.map((r) => Number(r.id)).filter((id) => !commit.committedRowIds.has(id));
|
|
162
171
|
const llmOmittedRowIds = rawRowIds.filter((id) => !commit.failedRowIds.includes(id));
|
|
163
|
-
const omittedMark = await markOmittedRows(db, rawRowIds);
|
|
172
|
+
const omittedMark = await markOmittedRows(db, rawRowIds, { transientRowIds: commit.transientRowIds });
|
|
164
173
|
const omittedRowIds = llmOmittedRowIds.concat(prefilteredRowIds);
|
|
165
174
|
|
|
166
175
|
__mixdogMemoryLog(
|
|
@@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
10
10
|
import { writeProfilePoint } from './model-profile.mjs';
|
|
11
11
|
import { createCompactVectorCache } from './compact-vector-cache.mjs';
|
|
12
12
|
import { EMBED_KEEP_WARM_MS } from './embedding-idle-lease.mjs';
|
|
13
|
+
import { workerExecArgv } from '../../shared/worker-exec-argv.mjs';
|
|
13
14
|
import {
|
|
14
15
|
getConfiguredEmbeddingModelId,
|
|
15
16
|
getDefaultEmbeddingDtype,
|
|
@@ -47,11 +48,7 @@ const queryEmbeddingCache = createCompactVectorCache({
|
|
|
47
48
|
const WORKER_PATH = join(fileURLToPath(import.meta.url), '..', 'embedding-worker.mjs');
|
|
48
49
|
|
|
49
50
|
export function embeddingWorkerExecArgv(execArgv = process.execArgv) {
|
|
50
|
-
return (
|
|
51
|
-
const value = String(arg);
|
|
52
|
-
if (value.startsWith('--input-type')) return false;
|
|
53
|
-
return !/^--(?:max-old-space-size|max-semi-space-size|initial-old-space-size)(?:=|$)/.test(value);
|
|
54
|
-
});
|
|
51
|
+
return workerExecArgv(execArgv);
|
|
55
52
|
}
|
|
56
53
|
|
|
57
54
|
function embeddingCacheKey(text, inputType, dtype = _configuredDtype) {
|
|
@@ -20,7 +20,6 @@ function waitForNextPass(ms, signal) {
|
|
|
20
20
|
|
|
21
21
|
export async function drainEmbeddingReindex({
|
|
22
22
|
flushEntries,
|
|
23
|
-
backfillCore = async () => 0,
|
|
24
23
|
signal,
|
|
25
24
|
pauseMs = DEFAULT_PASS_PAUSE_MS,
|
|
26
25
|
wait = waitForNextPass,
|
|
@@ -45,8 +44,5 @@ export async function drainEmbeddingReindex({
|
|
|
45
44
|
await wait(pauseMs, signal);
|
|
46
45
|
}
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
const coreFilled = Number((await backfillCore({ signal })) ?? 0);
|
|
50
|
-
throwIfAborted(signal);
|
|
51
|
-
return { passes, attempted, succeeded, failed, coreFilled };
|
|
47
|
+
return { passes, attempted, succeeded, failed };
|
|
52
48
|
}
|
|
@@ -61,7 +61,6 @@ export function createLifecycleRoutes({
|
|
|
61
61
|
archived_roots: stats.archived_roots,
|
|
62
62
|
unchunked_leaves: stats.unchunked_leaves,
|
|
63
63
|
core_entries: stats.core_entries,
|
|
64
|
-
core_embed_null: stats.core_embed_null,
|
|
65
64
|
cycle_running: cycleScheduler.getCycleRunning(),
|
|
66
65
|
cycle_health: cycleScheduler.getCycleHealth(),
|
|
67
66
|
cycle_backlog: cycleScheduler.getCycleBacklogSnapshot(),
|
|
@@ -32,7 +32,7 @@ export function createMaintenanceActions({ getDb, entryStats, getCycleLastRun })
|
|
|
32
32
|
`entries: total=${stats.total} roots=${stats.roots} cycle1_raw=${stats.unchunked_leaves} (unchunked leaves)`,
|
|
33
33
|
`status: ${stats.byStatus.map((r) => `${r.status ?? '?'}:${r.c}`).join(', ') || 'empty'}`,
|
|
34
34
|
`categories: ${stats.byCategory.map((r) => `${r.category ?? 'NULL'}:${r.c}`).join(', ') || 'empty'}`,
|
|
35
|
-
`core_memory: user=${stats.core_entries}
|
|
35
|
+
`core_memory: user=${stats.core_entries}`,
|
|
36
36
|
`embedding_index: ready dims=${dims}${dimsErr ? ` (meta_read_error: ${dimsErr})` : ''}`,
|
|
37
37
|
`bootstrap: ${bootstrapComplete ? 'complete' : 'incomplete'}`,
|
|
38
38
|
`last_cycle1: ${agoText(last.cycle1)}`,
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* core-and-meta.mjs — core_entries, the meta key/value table
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* core-and-meta.mjs — core_entries, the meta key/value table and the
|
|
3
|
+
* bootstrap stamps that record which embedding model and dimension the schema
|
|
4
|
+
* was built for.
|
|
5
5
|
*/
|
|
6
6
|
import { ensureCoreKeyIndex } from '../core-memory-uniqueness.mjs';
|
|
7
7
|
|
|
8
8
|
const UPSERT_META = `INSERT INTO meta(key, value) VALUES ($1, $2::jsonb)
|
|
9
9
|
ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value`;
|
|
10
10
|
|
|
11
|
-
export async function ensureCoreEntriesSchema(db
|
|
11
|
+
export async function ensureCoreEntriesSchema(db) {
|
|
12
12
|
await db.exec(`
|
|
13
13
|
CREATE TABLE IF NOT EXISTS core_entries (
|
|
14
14
|
id BIGSERIAL PRIMARY KEY,
|
|
@@ -16,16 +16,12 @@ export async function ensureCoreEntriesSchema(db, dimCount) {
|
|
|
16
16
|
summary TEXT NOT NULL,
|
|
17
17
|
category TEXT NOT NULL,
|
|
18
18
|
project_id TEXT,
|
|
19
|
-
embedding halfvec(${dimCount}),
|
|
20
19
|
created_at BIGINT NOT NULL,
|
|
21
20
|
updated_at BIGINT NOT NULL
|
|
22
21
|
)
|
|
23
22
|
`);
|
|
24
23
|
await db.exec(`CREATE INDEX IF NOT EXISTS core_entries_project_idx ON core_entries(project_id)`);
|
|
25
24
|
await ensureCoreKeyIndex(db);
|
|
26
|
-
await db.exec(
|
|
27
|
-
`CREATE INDEX IF NOT EXISTS core_entries_embedding_hnsw ON core_entries USING hnsw (embedding halfvec_cosine_ops) WHERE embedding IS NOT NULL`
|
|
28
|
-
);
|
|
29
25
|
}
|
|
30
26
|
|
|
31
27
|
export async function ensureMetaSchema(db) {
|
|
@@ -35,20 +31,6 @@ export async function ensureMetaSchema(db) {
|
|
|
35
31
|
value JSONB NOT NULL
|
|
36
32
|
)
|
|
37
33
|
`);
|
|
38
|
-
|
|
39
|
-
// Operational view — used by /health and dashboards. One round-trip,
|
|
40
|
-
// covers the metrics that previously needed 6+ COUNT queries.
|
|
41
|
-
await db.exec(`
|
|
42
|
-
CREATE OR REPLACE VIEW v_cycle_state AS
|
|
43
|
-
SELECT
|
|
44
|
-
COUNT(*) FILTER (WHERE is_root = 1) AS roots,
|
|
45
|
-
COUNT(*) FILTER (WHERE is_root = 1 AND status = 'pending') AS pending,
|
|
46
|
-
COUNT(*) FILTER (WHERE is_root = 1 AND status = 'active') AS active,
|
|
47
|
-
COUNT(*) FILTER (WHERE is_root = 1 AND status = 'archived') AS archived,
|
|
48
|
-
COUNT(*) FILTER (WHERE chunk_root IS NULL) AS unclassified,
|
|
49
|
-
COUNT(*) AS total
|
|
50
|
-
FROM entries
|
|
51
|
-
`);
|
|
52
34
|
}
|
|
53
35
|
|
|
54
36
|
export async function stampBootstrapMeta(db, dimCount, embeddingIdentity) {
|
|
@@ -35,7 +35,7 @@ export async function init(db, dims, embeddingIdentity = null) {
|
|
|
35
35
|
await ensureScoreSchema(db);
|
|
36
36
|
await ensureEntriesSchema(db, dimCount);
|
|
37
37
|
await ensureEntryTriggers(db);
|
|
38
|
-
await ensureCoreEntriesSchema(db
|
|
38
|
+
await ensureCoreEntriesSchema(db);
|
|
39
39
|
await ensureMetaSchema(db);
|
|
40
40
|
await stampBootstrapMeta(db, dimCount, embeddingIdentity);
|
|
41
41
|
}
|
|
@@ -61,7 +61,6 @@ async function getEmbeddingColumnDims(db, tableName) {
|
|
|
61
61
|
|
|
62
62
|
export async function resetEmbeddingColumnsForModel(db, dimCount, embeddingIdentity = null) {
|
|
63
63
|
const entriesDims = await getEmbeddingColumnDims(db, 'entries');
|
|
64
|
-
const coreDims = await getEmbeddingColumnDims(db, 'core_entries');
|
|
65
64
|
const normalizedIdentity = embeddingIdentity == null ? null : JSON.stringify(embeddingIdentity);
|
|
66
65
|
let identityChanged = false;
|
|
67
66
|
if (normalizedIdentity != null) {
|
|
@@ -72,39 +71,25 @@ export async function resetEmbeddingColumnsForModel(db, dimCount, embeddingIdent
|
|
|
72
71
|
identityChanged = identity.rows.length === 0 || identity.rows[0].matches !== true;
|
|
73
72
|
}
|
|
74
73
|
const needsEntriesReset = entriesDims != null && (entriesDims !== dimCount || identityChanged);
|
|
75
|
-
|
|
76
|
-
if (!needsEntriesReset && !needsCoreReset) return false;
|
|
74
|
+
if (!needsEntriesReset) return false;
|
|
77
75
|
|
|
78
76
|
__mixdogMemoryLog(
|
|
79
|
-
`[memory] embedding model changed; resetting vectors for halfvec(${dimCount}) `
|
|
80
|
-
`(entries=${entriesDims ?? 'missing'}, core_entries=${coreDims ?? 'missing'})\n`
|
|
77
|
+
`[memory] embedding model changed; resetting vectors for halfvec(${dimCount}) (entries=${entriesDims})\n`
|
|
81
78
|
);
|
|
82
79
|
|
|
83
80
|
// Old installations may still have a derived view depending on embedding.
|
|
84
81
|
// Release that dependency only during a model migration; never recreate it.
|
|
85
82
|
await db.exec(`DROP MATERIALIZED VIEW IF EXISTS mv_hot_active CASCADE`);
|
|
86
83
|
await db.exec(`DROP INDEX IF EXISTS idx_entries_embedding_hnsw`);
|
|
87
|
-
await db.exec(`DROP INDEX IF EXISTS core_entries_embedding_hnsw`);
|
|
88
84
|
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
await db.exec(`UPDATE entries SET embedding = NULL WHERE embedding IS NOT NULL`);
|
|
96
|
-
}
|
|
97
|
-
await db.exec(`UPDATE entries SET summary_hash = NULL WHERE summary_hash IS NOT NULL`);
|
|
98
|
-
}
|
|
99
|
-
if (needsCoreReset) {
|
|
100
|
-
if (coreDims !== dimCount) {
|
|
101
|
-
await db.exec(
|
|
102
|
-
`ALTER TABLE core_entries ALTER COLUMN embedding TYPE halfvec(${dimCount}) USING NULL::halfvec(${dimCount})`
|
|
103
|
-
);
|
|
104
|
-
} else {
|
|
105
|
-
await db.exec(`UPDATE core_entries SET embedding = NULL WHERE embedding IS NOT NULL`);
|
|
106
|
-
}
|
|
85
|
+
if (entriesDims !== dimCount) {
|
|
86
|
+
await db.exec(
|
|
87
|
+
`ALTER TABLE entries ALTER COLUMN embedding TYPE halfvec(${dimCount}) USING NULL::halfvec(${dimCount})`
|
|
88
|
+
);
|
|
89
|
+
} else {
|
|
90
|
+
await db.exec(`UPDATE entries SET embedding = NULL WHERE embedding IS NOT NULL`);
|
|
107
91
|
}
|
|
92
|
+
await db.exec(`UPDATE entries SET summary_hash = NULL WHERE summary_hash IS NOT NULL`);
|
|
108
93
|
|
|
109
94
|
await db.exec(`DROP TABLE IF EXISTS memory.embedding_cache`);
|
|
110
95
|
await setMetaValue(db, 'embedding.current_dims', JSON.stringify(dimCount));
|
|
@@ -244,12 +229,78 @@ async function removeRuntimeNotificationRowsOnce(db) {
|
|
|
244
229
|
}
|
|
245
230
|
}
|
|
246
231
|
|
|
232
|
+
// One-time cleanup: what the relationship-review and promotion cycles left in
|
|
233
|
+
// a database they once ran on. Nothing reads any of it, and its indexes were
|
|
234
|
+
// still maintained on every write:
|
|
235
|
+
// entries columns the five below, with the indexes on them
|
|
236
|
+
// indexes idx_entries_phase_sweep, idx_entries_reviewed_at, idx_roots_active
|
|
237
|
+
// relations mv_hot_active, phase_merge_verdicts, and the v_cycle_state
|
|
238
|
+
// view that every store created and nothing queried
|
|
239
|
+
// meta both cycles' scheduling rows and last-run keys,
|
|
240
|
+
// memory.generated.policy.*, memory.authority-review
|
|
241
|
+
// The entry triggers are recreated first: such a database still lists
|
|
242
|
+
// promoted_at in the score trigger's UPDATE OF columns, which would block
|
|
243
|
+
// dropping that column. One transaction, gated by a meta flag like the
|
|
244
|
+
// notification cleanup above; best-effort so a failure never blocks boot.
|
|
245
|
+
const CYCLE_STATE_CLEANUP_META_KEY = 'cleanup.review_promotion_cycle_state_v1';
|
|
246
|
+
const CYCLE_STATE_ENTRY_COLUMNS = [
|
|
247
|
+
'cycle2_reviewed_at',
|
|
248
|
+
'core_summary',
|
|
249
|
+
'promoted_at',
|
|
250
|
+
'core_candidate_status',
|
|
251
|
+
'core_candidate_at',
|
|
252
|
+
];
|
|
253
|
+
async function removeReviewAndPromotionCycleStateOnce(db) {
|
|
254
|
+
try {
|
|
255
|
+
const already = await db.query(`SELECT 1 FROM meta WHERE key = $1`, [CYCLE_STATE_CLEANUP_META_KEY]);
|
|
256
|
+
if (already?.rows?.length) return;
|
|
257
|
+
const columns = await db.query(
|
|
258
|
+
`SELECT column_name FROM information_schema.columns
|
|
259
|
+
WHERE table_schema = current_schema() AND table_name = 'entries' AND column_name = ANY($1::text[])`,
|
|
260
|
+
[CYCLE_STATE_ENTRY_COLUMNS]
|
|
261
|
+
);
|
|
262
|
+
let metaRows = 0;
|
|
263
|
+
await db.transaction(async (tx) => {
|
|
264
|
+
await ensureEntryTriggers(tx);
|
|
265
|
+
await tx.exec(`DROP MATERIALIZED VIEW IF EXISTS mv_hot_active`);
|
|
266
|
+
await tx.exec(`DROP VIEW IF EXISTS v_cycle_state`);
|
|
267
|
+
await tx.exec(
|
|
268
|
+
`ALTER TABLE entries ${CYCLE_STATE_ENTRY_COLUMNS.map((name) => `DROP COLUMN IF EXISTS ${name}`).join(', ')}`
|
|
269
|
+
);
|
|
270
|
+
await tx.exec(`DROP INDEX IF EXISTS idx_entries_phase_sweep, idx_entries_reviewed_at, idx_roots_active`);
|
|
271
|
+
await tx.exec(`DROP TABLE IF EXISTS phase_merge_verdicts`);
|
|
272
|
+
const deleted = await tx.query(
|
|
273
|
+
`DELETE FROM meta
|
|
274
|
+
WHERE key ~ '^cycle_(request|schedule)\\.cycle[23]\\.'
|
|
275
|
+
OR key LIKE 'memory.generated.policy.%'
|
|
276
|
+
OR key = 'memory.authority-review'`
|
|
277
|
+
);
|
|
278
|
+
metaRows = Number(deleted?.rowCount ?? 0);
|
|
279
|
+
await tx.exec(
|
|
280
|
+
`UPDATE meta SET value = value - ARRAY['cycle2', 'cycle2_last_error', 'cycle3', 'cycle3_last_error']
|
|
281
|
+
WHERE key = 'state.cycle_last_run' AND jsonb_typeof(value) = 'object'`
|
|
282
|
+
);
|
|
283
|
+
await setMetaValue(tx, CYCLE_STATE_CLEANUP_META_KEY, JSON.stringify('1'));
|
|
284
|
+
});
|
|
285
|
+
const dropped = (columns?.rows || []).map((row) => row.column_name);
|
|
286
|
+
if (dropped.length || metaRows > 0) {
|
|
287
|
+
__mixdogMemoryLog(
|
|
288
|
+
`[memory] ensureCurrentSchemaExtensions: removed review/promotion cycle state (columns=${dropped.join(',') || 'none'}, meta rows=${metaRows})\n`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
} catch (err) {
|
|
292
|
+
__mixdogMemoryLog(`[memory] review/promotion cycle cleanup failed: ${err?.message || err}\n`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
247
296
|
export async function ensureCurrentSchemaExtensions(db, dims, embeddingIdentity = null) {
|
|
248
297
|
await removeAttachmentPlaceholderRows(db);
|
|
249
298
|
await removeRuntimeNotificationRowsOnce(db);
|
|
250
|
-
|
|
299
|
+
await removeReviewAndPromotionCycleStateOnce(db);
|
|
300
|
+
// User-curated entries are injected verbatim and recalled by term match;
|
|
301
|
+
// drop the vector column (and its index) older versions maintained.
|
|
302
|
+
await db.exec(`ALTER TABLE core_entries DROP COLUMN IF EXISTS embedding`);
|
|
251
303
|
if (Number.isInteger(dims) && dims > 0) {
|
|
252
|
-
await db.exec(`ALTER TABLE core_entries ADD COLUMN IF NOT EXISTS embedding halfvec(${dims})`);
|
|
253
304
|
// One-time migration for EXISTING deployments (bootstrap-complete DBs never
|
|
254
305
|
// re-run init(), so the broadened index definitions there would otherwise
|
|
255
306
|
// never reach them). This path runs on EVERY boot, so we must NOT
|
|
@@ -260,13 +311,9 @@ export async function ensureCurrentSchemaExtensions(db, dims, embeddingIdentity
|
|
|
260
311
|
await db.exec(
|
|
261
312
|
`CREATE INDEX IF NOT EXISTS idx_entries_embedding_hnsw ON entries USING hnsw (embedding halfvec_cosine_ops) WHERE embedding IS NOT NULL`
|
|
262
313
|
);
|
|
263
|
-
await db.exec(
|
|
264
|
-
`CREATE INDEX IF NOT EXISTS core_entries_embedding_hnsw ON core_entries USING hnsw (embedding halfvec_cosine_ops) WHERE embedding IS NOT NULL`
|
|
265
|
-
);
|
|
266
314
|
}
|
|
267
315
|
await db.exec(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS chunk_quality jsonb`);
|
|
268
|
-
//
|
|
269
|
-
// Preserve the search relationships used by recall.
|
|
316
|
+
// The search relationships used by recall.
|
|
270
317
|
await db.exec(
|
|
271
318
|
`ALTER TABLE entries ADD COLUMN IF NOT EXISTS duplicate_of bigint REFERENCES entries(id) ON DELETE SET NULL`
|
|
272
319
|
);
|
|
@@ -151,7 +151,6 @@ export function createQueryMaintenanceHandlers({ getDb }) {
|
|
|
151
151
|
).rows[0].c;
|
|
152
152
|
const unchunked_leaves = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE chunk_root IS NULL`)).rows[0].c;
|
|
153
153
|
const core_entries = (await tx.query(`SELECT COUNT(*) c FROM core_entries`)).rows[0].c;
|
|
154
|
-
const core_embed_null = (await tx.query(`SELECT COUNT(*) c FROM core_entries WHERE embedding IS NULL`)).rows[0].c;
|
|
155
154
|
const byStatus = (await tx.query(`SELECT status, COUNT(*) c FROM entries WHERE is_root = 1 GROUP BY status`))
|
|
156
155
|
.rows;
|
|
157
156
|
const byCategory = (
|
|
@@ -164,7 +163,6 @@ export function createQueryMaintenanceHandlers({ getDb }) {
|
|
|
164
163
|
archived_roots,
|
|
165
164
|
unchunked_leaves,
|
|
166
165
|
core_entries,
|
|
167
|
-
core_embed_null,
|
|
168
166
|
byStatus,
|
|
169
167
|
byCategory,
|
|
170
168
|
};
|
|
@@ -331,6 +331,9 @@ export async function callMicrosoftOffice(payload, { timeoutMs = DEFAULT_TIMEOUT
|
|
|
331
331
|
}
|
|
332
332
|
|
|
333
333
|
export async function detectMicrosoftOffice({ format = '', path = '' } = {}) {
|
|
334
|
+
// Off Windows there is no COM to probe; spawning powershell.exe only reported
|
|
335
|
+
// "spawn powershell.exe ENOENT" as if Office were broken on a Mac.
|
|
336
|
+
if (!microsoftOfficeComSupported()) return unsupportedPlatformResult();
|
|
334
337
|
return await callMicrosoftOfficeOnce({ action: 'detect', format, path }, { timeoutMs: 20_000 });
|
|
335
338
|
}
|
|
336
339
|
|
|
@@ -51,18 +51,49 @@ function metadataOf(structure) {
|
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// pdf-lib cannot decrypt. An encrypted file that keeps its page tree in
|
|
55
|
+
// ciphered object streams — what qpdf and most current writers produce —
|
|
56
|
+
// therefore reads as having no page tree at all; null says so.
|
|
57
|
+
function structurePageCount(structure) {
|
|
58
|
+
try {
|
|
59
|
+
return structure.getPageCount();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!structure.isEncrypted) throw error;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// pdf.js decrypts: with the user password, or with none under an owner-only
|
|
67
|
+
// lock. Null when the file is locked and no working password was given.
|
|
68
|
+
async function decryptedPageCount(buffer, password) {
|
|
69
|
+
try {
|
|
70
|
+
return (await inspectPdfBuffer(buffer, { password })).pageCount;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (!/password/i.test(String(error?.message || error?.name || ''))) throw error;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
54
77
|
export async function snapshotPdf(path, options = {}) {
|
|
55
78
|
const maxChars = Math.max(1_000, Number(options.maxChars) || 30_000);
|
|
56
79
|
const buffer = await readFile(path);
|
|
57
80
|
const structure = await loadPdf(buffer, { allowEncrypted: true });
|
|
58
|
-
const encrypted = structure.isEncrypted
|
|
59
|
-
const
|
|
81
|
+
const encrypted = structure.isEncrypted;
|
|
82
|
+
const structurePages = structurePageCount(structure);
|
|
83
|
+
const pageCount = structurePages ?? (await decryptedPageCount(buffer, options.password || ''));
|
|
84
|
+
const knownPages = pageCount ?? 0;
|
|
60
85
|
const offset = options.paged ? Math.max(0, Number(options.offset) || 0) : 0;
|
|
61
|
-
const limit = options.paged ? Math.max(1, Number(options.limit) || 20) :
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
86
|
+
const limit = options.paged ? Math.max(1, Number(options.limit) || 20) : knownPages;
|
|
87
|
+
// A locked file whose pages could not be counted selects none.
|
|
88
|
+
let selected = [];
|
|
89
|
+
if (pageCount !== null && Array.isArray(options.pages) && options.pages.length) {
|
|
90
|
+
selected = options.pages.map(Number);
|
|
91
|
+
} else if (pageCount !== null) {
|
|
92
|
+
selected = Array.from(
|
|
93
|
+
{ length: Math.max(0, Math.min(limit, pageCount - offset)) },
|
|
94
|
+
(_, index) => offset + index + 1
|
|
95
|
+
);
|
|
96
|
+
}
|
|
66
97
|
for (const index of selected) {
|
|
67
98
|
if (!Number.isInteger(index) || index < 1 || index > pageCount) throw new Error(`PDF page out of range: ${index}`);
|
|
68
99
|
}
|
|
@@ -73,7 +104,7 @@ export async function snapshotPdf(path, options = {}) {
|
|
|
73
104
|
try {
|
|
74
105
|
result = await inspectPdfBuffer(buffer, {
|
|
75
106
|
extractText: true,
|
|
76
|
-
maxPages: Math.max(500,
|
|
107
|
+
maxPages: Math.max(500, knownPages),
|
|
77
108
|
maxOutputBytes: maxChars,
|
|
78
109
|
pageRange: { from, to },
|
|
79
110
|
password: options.password || '',
|
|
@@ -94,7 +125,8 @@ export async function snapshotPdf(path, options = {}) {
|
|
|
94
125
|
path: `/page[${index}]`,
|
|
95
126
|
index,
|
|
96
127
|
text: (texts.get(index) ?? '').replace(/ {2,}/g, ' '),
|
|
97
|
-
|
|
128
|
+
// A ciphered page tree has no page boxes to read.
|
|
129
|
+
...(structurePages === null ? {} : pageGeometry(structure, index)),
|
|
98
130
|
}));
|
|
99
131
|
// Strings and streams stay ciphered under ignoreEncryption, so the form and
|
|
100
132
|
// attachment views of an encrypted file would be noise rather than data.
|
|
@@ -113,7 +145,10 @@ export async function snapshotPdf(path, options = {}) {
|
|
|
113
145
|
path: `/outline[${index + 1}]`,
|
|
114
146
|
...entry,
|
|
115
147
|
}));
|
|
116
|
-
} catch {
|
|
148
|
+
} catch {
|
|
149
|
+
// The outline is a convenience view; an unreadable one leaves it empty
|
|
150
|
+
// rather than failing the snapshot of a readable document.
|
|
151
|
+
}
|
|
117
152
|
}
|
|
118
153
|
const likelyScannedPages = passwordRequired
|
|
119
154
|
? []
|
|
@@ -137,7 +172,7 @@ export async function snapshotPdf(path, options = {}) {
|
|
|
137
172
|
ocrRequired: likelyScannedPages.length > 0,
|
|
138
173
|
encrypted,
|
|
139
174
|
...(encrypted ? { passwordRequired, hint: PDF_ENCRYPTED_HINT } : {}),
|
|
140
|
-
...(options.paged ? { pagination: pagePagination(options, offset, limit, pages.length,
|
|
175
|
+
...(options.paged ? { pagination: pagePagination(options, offset, limit, pages.length, knownPages) } : {}),
|
|
141
176
|
};
|
|
142
177
|
}
|
|
143
178
|
|
|
@@ -188,12 +223,13 @@ export async function applyPdfBatch(path, operations, context = {}) {
|
|
|
188
223
|
}
|
|
189
224
|
|
|
190
225
|
export async function validatePdf(path) {
|
|
191
|
-
const
|
|
192
|
-
const
|
|
226
|
+
const buffer = await readFile(path);
|
|
227
|
+
const document = await loadPdf(buffer, { allowEncrypted: true });
|
|
228
|
+
const encrypted = document.isEncrypted;
|
|
193
229
|
return {
|
|
194
230
|
ok: true,
|
|
195
231
|
format: 'pdf',
|
|
196
|
-
pages: document
|
|
232
|
+
pages: structurePageCount(document) ?? (await decryptedPageCount(buffer, '')),
|
|
197
233
|
validation: 'pdf-parse',
|
|
198
234
|
encrypted,
|
|
199
235
|
...(encrypted ? { warning: PDF_ENCRYPTED_HINT } : {}),
|
|
@@ -4,6 +4,7 @@ import { isMainThread, parentPort, Worker, workerData } from 'node:worker_thread
|
|
|
4
4
|
import { createCanvas, loadImage } from '@napi-rs/canvas';
|
|
5
5
|
import { pdfjsStandardFontDataUrl, resolvedPdfJs } from '../../attachments/pdfjs-runtime.mjs';
|
|
6
6
|
import { installPdfGlobals } from './pdf-document.mjs';
|
|
7
|
+
import { workerExecArgv } from '../../shared/worker-exec-argv.mjs';
|
|
7
8
|
|
|
8
9
|
const PAGE_NUMBER_PATTERN = /^\s*(?:(?:page\s*)?\d+\s*(?:of|\/)\s*\d+|페이지\s*\d+|\d+\s*페이지)\s*$/i;
|
|
9
10
|
|
|
@@ -110,12 +111,7 @@ async function renderPdfPageDirect(path, pageNumber, targetWidth, minimumScale =
|
|
|
110
111
|
|
|
111
112
|
async function runPdfRenderWorker(data, signal = null) {
|
|
112
113
|
const worker = new Worker(new URL(import.meta.url), {
|
|
113
|
-
execArgv:
|
|
114
|
-
(argument) =>
|
|
115
|
-
!/^--(?:input-type|max-old-space-size|max-semi-space-size|stack-size|heapsnapshot-near-heap-limit)(?:=|$)/.test(
|
|
116
|
-
argument
|
|
117
|
-
)
|
|
118
|
-
),
|
|
114
|
+
execArgv: workerExecArgv(),
|
|
119
115
|
workerData: data,
|
|
120
116
|
});
|
|
121
117
|
return await new Promise((resolve, reject) => {
|
|
@@ -74,15 +74,21 @@ export async function securePdf({ input, output, mode, password = '', ownerPassw
|
|
|
74
74
|
'PDF encryption/decryption requires qpdf on PATH (or MIXDOG_QPDF_PATH) and it is not installed (Windows: winget install qpdf; macOS: brew install qpdf; Debian/Ubuntu: apt install qpdf); tell the user the file was left as is'
|
|
75
75
|
);
|
|
76
76
|
}
|
|
77
|
+
// Passwords travel in a response file, never on the command line where other
|
|
78
|
+
// processes can read them. qpdf takes each line of it as one literal
|
|
79
|
+
// argument — spaces and quotes included, no unquoting — so a line break is
|
|
80
|
+
// the one character a password cannot carry.
|
|
81
|
+
if (/[\r\n]/.test(`${password}${ownerPassword}`)) {
|
|
82
|
+
throw new Error('PDF passwords cannot contain line breaks; the file was left as is');
|
|
83
|
+
}
|
|
77
84
|
const samePath = input.toLowerCase() === output.toLowerCase();
|
|
78
85
|
const target = samePath ? join(dirname(output), `.mixdog-qpdf-${randomUUID()}.pdf`) : output;
|
|
79
86
|
const responsePath = join(dirname(target), `.mixdog-qpdf-${randomUUID()}.args`);
|
|
80
|
-
const quote = (value) => `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
81
87
|
const args =
|
|
82
88
|
mode === 'encrypt'
|
|
83
89
|
? ['--encrypt', password, ownerPassword || password, '256', '--', input, target]
|
|
84
90
|
: [`--password=${password}`, '--decrypt', input, target];
|
|
85
|
-
await writeFile(responsePath, `${args.
|
|
91
|
+
await writeFile(responsePath, `${args.join('\n')}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
86
92
|
try {
|
|
87
93
|
await run(QPDF, [`@${responsePath}`]);
|
|
88
94
|
if (samePath) await rename(target, output);
|