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.
Files changed (109) hide show
  1. package/package.json +2 -1
  2. package/scripts/lib/run-node-tests.mjs +13 -0
  3. package/scripts/session-efficiency-diag.mjs +283 -0
  4. package/src/defaults/skills/computer-use/SKILL.md +3 -3
  5. package/src/defaults/skills/local-provider/SKILL.md +2 -1
  6. package/src/defaults/skills/setup/SKILL.md +4 -4
  7. package/src/defaults/skills/setup/references/actions.md +1 -3
  8. package/src/defaults/skills/setup/references/surfaces.md +2 -3
  9. package/src/headless-exec.mjs +1 -0
  10. package/src/rules/shared/70-delivery.md +2 -2
  11. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +10 -0
  12. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +1 -1
  13. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  14. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +8 -4
  15. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +10 -1
  16. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-tokens.mjs +18 -3
  18. package/src/runtime/agent/orchestrator/providers/client-version-store.mjs +50 -0
  19. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +27 -4
  20. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +9 -5
  21. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +18 -4
  22. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +2 -2
  23. package/src/runtime/agent/orchestrator/providers/lib/anthropic-models.mjs +7 -1
  24. package/src/runtime/agent/orchestrator/providers/npm-cli-version.mjs +25 -2
  25. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +7 -1
  26. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +4 -0
  27. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +8 -0
  28. package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +17 -3
  29. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +7 -1
  30. package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +16 -0
  31. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +0 -20
  32. package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +12 -10
  33. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +3 -1
  34. package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +15 -3
  36. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +11 -2
  37. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +5 -1
  38. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +23 -9
  39. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/deadline-plan.mjs +15 -6
  40. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +4 -4
  41. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +7 -0
  42. package/src/runtime/channels/lib/voice-transcription.mjs +30 -4
  43. package/src/runtime/computer-bridge/action-schema.mjs +3 -3
  44. package/src/runtime/computer-bridge/actions.mjs +4 -0
  45. package/src/runtime/computer-bridge/bridge-env.test-support.mjs +22 -0
  46. package/src/runtime/computer-bridge/client.mjs +10 -183
  47. package/src/runtime/computer-bridge/core-actions.mjs +2 -1
  48. package/src/runtime/computer-bridge/error-recovery.mjs +46 -34
  49. package/src/runtime/computer-bridge/result-canonical.mjs +181 -0
  50. package/src/runtime/local-provider/asset-installer.mjs +20 -10
  51. package/src/runtime/local-provider/catalog.mjs +4 -7
  52. package/src/runtime/local-provider/data/manifest.json +14 -1
  53. package/src/runtime/local-provider/hardware.mjs +36 -5
  54. package/src/runtime/local-provider/resumable-installations.mjs +2 -2
  55. package/src/runtime/local-provider/server.mjs +1 -1
  56. package/src/runtime/memory/index.mjs +2 -4
  57. package/src/runtime/memory/lib/core-memory-file.mjs +3 -8
  58. package/src/runtime/memory/lib/core-memory-store.mjs +14 -61
  59. package/src/runtime/memory/lib/cycle-scheduler/backlog-probe.mjs +5 -9
  60. package/src/runtime/memory/lib/cycle1/cycle1-plan.mjs +7 -0
  61. package/src/runtime/memory/lib/cycle1/cycle1-rows.mjs +43 -16
  62. package/src/runtime/memory/lib/cycle1/cycle1-window.mjs +11 -2
  63. package/src/runtime/memory/lib/embedding-provider.mjs +2 -5
  64. package/src/runtime/memory/lib/embedding-reindex.mjs +1 -5
  65. package/src/runtime/memory/lib/http-router/lifecycle-routes.mjs +0 -1
  66. package/src/runtime/memory/lib/memory-action-handlers/maintenance-actions.mjs +1 -1
  67. package/src/runtime/memory/lib/memory-schema/core-and-meta.mjs +4 -22
  68. package/src/runtime/memory/lib/memory.mjs +79 -32
  69. package/src/runtime/memory/lib/query-maintenance-handlers.mjs +0 -2
  70. package/src/runtime/office/com/com-adapter.mjs +3 -0
  71. package/src/runtime/office/pdf/pdf-adapter.mjs +50 -14
  72. package/src/runtime/office/pdf/pdf-render.mjs +2 -6
  73. package/src/runtime/office/pdf/pdf-security.mjs +8 -2
  74. package/src/runtime/office/portable/font-provisioner.mjs +28 -0
  75. package/src/runtime/office/portable/portable-docx-edits.mjs +28 -1
  76. package/src/runtime/office/portable/portable-soffice.mjs +44 -7
  77. package/src/runtime/shared/llm/anthropic-betas.mjs +7 -1
  78. package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +1 -1
  79. package/src/runtime/shared/llm/default-context-window.mjs +42 -0
  80. package/src/runtime/shared/llm/model-catalog-projection.mjs +2 -0
  81. package/src/runtime/shared/llm/model-catalog.mjs +2 -0
  82. package/src/runtime/shared/plugin-paths.mjs +9 -2
  83. package/src/runtime/shared/worker-exec-argv.mjs +25 -0
  84. package/src/runtime/web-search/lib/formatter.mjs +43 -18
  85. package/src/session-runtime/automation-agents.mjs +88 -0
  86. package/src/session-runtime/automation-workflow.mjs +19 -6
  87. package/src/session-runtime/boot/apis.mjs +0 -1
  88. package/src/session-runtime/cwd-plugins/core-memory-context.mjs +14 -12
  89. package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +4 -25
  90. package/src/session-runtime/model-recency.mjs +10 -2
  91. package/src/session-runtime/model-route/set-route.mjs +7 -1
  92. package/src/session-runtime/resource-api.mjs +8 -2
  93. package/src/session-runtime/resource-plugins-api.mjs +14 -2
  94. package/src/session-runtime/route-state.mjs +6 -2
  95. package/src/session-runtime/schedule-session-run.mjs +7 -1
  96. package/src/session-runtime/services/agent-tool/render.mjs +0 -5
  97. package/src/session-runtime/settings-builtin-tools-api.mjs +0 -16
  98. package/src/session-runtime/setup-tool/executor.mjs +7 -13
  99. package/src/session-runtime/setup-tool/tool-defs.mjs +2 -3
  100. package/src/session-runtime/tool-policy-refresh.mjs +0 -2
  101. package/src/session-runtime/webhook-session-run.mjs +10 -11
  102. package/src/session-runtime/workflow-agents-api/agent-route.mjs +5 -0
  103. package/src/standalone/daemon-agent-control.mjs +8 -1
  104. package/src/standalone/daemon.mjs +4 -0
  105. package/src/tui/dist/index.mjs +3 -2
  106. package/src/tui/session/agent-job-feed/notification-router.mjs +7 -0
  107. package/src/tui/session/live-share.mjs +16 -1
  108. package/src/tui/session/notification-plan.mjs +4 -0
  109. 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
- export async function markOmittedRows(db, rowIds) {
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, { reviewedBefore = null } = {}) {
85
- const where = ['chunk_root IS NULL', `NULLIF(btrim(session_id), '') IS NOT NULL`];
112
+ async function countSessionUnchunkedRows(db, { dueAt = null } = {}) {
113
+ const where = [cycle1UnchunkedSql()];
86
114
  const params = [];
87
- if (reviewedBefore != null) {
88
- params.push(reviewedBefore);
89
- where.push('(reviewed_at IS NULL OR reviewed_at < $1)');
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, { reviewedBefore: Date.now() - CYCLE1_OMITTED_COOLDOWN_MS });
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 - CYCLE1_OMITTED_COOLDOWN_MS, plan.rowsPerSession];
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 chunk_root IS NULL
140
- AND NULLIF(btrim(session_id), '') IS NOT NULL
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.chunk_root IS NULL
166
- AND (e.reviewed_at IS NULL OR e.reviewed_at < $2)
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) failedRowIds.push(mid);
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 (Array.isArray(execArgv) ? execArgv : []).filter((arg) => {
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
- throwIfAborted(signal);
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} embed_null=${stats.core_embed_null}`,
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, the cycle
3
- * state view and the bootstrap stamps that record which embedding model and
4
- * dimension the schema was built for.
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, dimCount) {
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, dimCount);
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
- const needsCoreReset = coreDims != null && (coreDims !== dimCount || identityChanged);
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 (needsEntriesReset) {
90
- if (entriesDims !== dimCount) {
91
- await db.exec(
92
- `ALTER TABLE entries ALTER COLUMN embedding TYPE halfvec(${dimCount}) USING NULL::halfvec(${dimCount})`
93
- );
94
- } else {
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
- // User-curated entries retain their own embeddings for explicit retrieval.
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
- // Existing retired review metadata is left untouched, but no longer created.
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 === true;
59
- const pageCount = structure.getPageCount();
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) : pageCount;
62
- const selected =
63
- Array.isArray(options.pages) && options.pages.length
64
- ? options.pages.map(Number)
65
- : Array.from({ length: Math.max(0, Math.min(limit, pageCount - offset)) }, (_, index) => offset + index + 1);
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, pageCount),
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
- ...pageGeometry(structure, index),
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, pageCount) } : {}),
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 document = await loadPdf(await readFile(path), { allowEncrypted: true });
192
- const encrypted = document.isEncrypted === true;
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.getPageCount(),
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: process.execArgv.filter(
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.map(quote).join('\n')}\n`, { encoding: 'utf8', mode: 0o600 });
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);