mixdog 0.9.78 → 0.9.80

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.78",
3
+ "version": "0.9.80",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -74,8 +74,8 @@
74
74
  "test:grok-oauth-race": "node --test scripts/grok-oauth-refresh-race-test.mjs",
75
75
  "test:atomiclock": "node --test scripts/atomic-lock-tryonce-test.mjs",
76
76
  "test:memory-routing": "node --test scripts/memory-cycle-routing-test.mjs scripts/maintenance-default-routes-test.mjs scripts/embedding-worker-exit-test.mjs scripts/embedding-runtime-prune-test.mjs",
77
- "test:embedding-runtime": "node --test scripts/embedding-runtime-prune-test.mjs && node scripts/verify-embedding-runtime.mjs",
78
- "test:embedding-runtime:core": "node --test scripts/embedding-runtime-prune-test.mjs && node scripts/verify-embedding-runtime.mjs --core",
77
+ "test:embedding-runtime": "node --test scripts/embedding-runtime-prune-test.mjs scripts/memory-pg-recovery-test.mjs && node scripts/verify-embedding-runtime.mjs",
78
+ "test:embedding-runtime:core": "node --test scripts/embedding-runtime-prune-test.mjs scripts/memory-pg-recovery-test.mjs && node scripts/verify-embedding-runtime.mjs --core",
79
79
  "test:embedding-runtime:warmup": "node scripts/verify-embedding-runtime.mjs --warmup",
80
80
  "test:code-graph-dispatch": "node --test scripts/code-graph-dispatch-test.mjs",
81
81
  "test:code-graph-clean-cache": "node --test scripts/code-graph-dispatch-test.mjs",
@@ -146,12 +146,6 @@ export async function searchRelevantHybrid(db, query, options = {}) {
146
146
  const queryTokenCount = countQueryTokens(clean)
147
147
  const minExactHits = exactTerms.length >= 8 ? 3 : exactTerms.length >= 4 ? 2 : 1
148
148
 
149
- // For very short queries (< 3 chars) the trigram operator still works but
150
- // we relax the server-side threshold via set_limit() — however that requires
151
- // a separate round-trip. Instead we fall back to a plain ILIKE scan for
152
- // short text (rare edge case; sequential scan is acceptable for < 3 chars).
153
- const isShortQuery = clean.length < 3
154
-
155
149
  // $5 onward are the filter params for the entries legs (non-MV path).
156
150
  // Each CTE leg duplicates the same positional params because they live in
157
151
  // independent SELECT scopes. When useHotActive=true, the trgm leg still uses
@@ -269,50 +263,26 @@ sparse AS (
269
263
  ),`) : `
270
264
  sparse AS (SELECT NULL::bigint AS id, NULL::float8 AS lex, NULL::bigint AS sparse_rank WHERE $2::text IS NOT NULL AND false),`
271
265
 
272
- // trgm CTE: pg_trgm similarity path. For short queries (< 3 chars) the %
273
- // operator is unreliable (trigrams need at least 3 chars); use ILIKE instead.
274
- // NOTE: trgm always queries entries regardless of useHotActive mv_hot_active
275
- // lacks the content column (trgm/ILIKE) and ts column (short-query ORDER BY).
276
- // Uses trgmFilterClause whose $N offsets are aligned to activeBindParams.
277
- const trgmCte = isShortQuery ? `
266
+ // Portable substring leg. The curated Unix PG runtimes include pgvector but
267
+ // not the optional pg_trgm contrib extension, so fuzzy similarity cannot be
268
+ // a startup/runtime requirement. FTS and dense vector search retain broad
269
+ // matching while this leg gives exact substrings a deterministic rescue.
270
+ // It always queries entries because mv_hot_active omits display text + ts.
271
+ const trgmCte = `
278
272
  trgm AS (
279
273
  SELECT id,
280
- 0.5::float8 AS trg_sim,
274
+ 1.0::float8 AS trg_sim,
281
275
  ROW_NUMBER() OVER (ORDER BY ts DESC) AS trgm_rank
282
276
  FROM entries
283
- WHERE (content ILIKE '%' || $3 || '%' OR element ILIKE '%' || $3 || '%')
277
+ WHERE (
278
+ content ILIKE '%' || $3 || '%'
279
+ OR coalesce(element, '') ILIKE '%' || $3 || '%'
280
+ OR coalesce(summary, '') ILIKE '%' || $3 || '%'
281
+ )
284
282
  ${trgmFilterClause}
285
283
  ORDER BY ts DESC
286
284
  LIMIT $4
287
- ),` : `
288
- trgm AS (
289
- SELECT id,
290
- GREATEST(
291
- CASE WHEN content ILIKE '%' || $3 || '%' THEN 1.0 ELSE similarity(content, $3) END,
292
- CASE WHEN coalesce(element, '') ILIKE '%' || $3 || '%' THEN 1.0 ELSE similarity(coalesce(element, ''), $3) END,
293
- CASE WHEN coalesce(summary, '') ILIKE '%' || $3 || '%' THEN 1.0 ELSE similarity(coalesce(summary, ''), $3) END
294
- ) AS trg_sim,
295
- ROW_NUMBER() OVER (ORDER BY GREATEST(
296
- CASE WHEN content ILIKE '%' || $3 || '%' THEN 1.0 ELSE similarity(content, $3) END,
297
- CASE WHEN coalesce(element, '') ILIKE '%' || $3 || '%' THEN 1.0 ELSE similarity(coalesce(element, ''), $3) END,
298
- CASE WHEN coalesce(summary, '') ILIKE '%' || $3 || '%' THEN 1.0 ELSE similarity(coalesce(summary, ''), $3) END
299
- ) DESC) AS trgm_rank
300
- FROM entries
301
- WHERE (
302
- content % $3 OR element % $3 OR summary % $3
303
- OR content ILIKE '%' || $3 || '%'
304
- OR coalesce(element, '') ILIKE '%' || $3 || '%'
305
- OR coalesce(summary, '') ILIKE '%' || $3 || '%'
306
- )
307
- AND GREATEST(
308
- CASE WHEN content ILIKE '%' || $3 || '%' THEN 1.0 ELSE similarity(content, $3) END,
309
- CASE WHEN coalesce(element, '') ILIKE '%' || $3 || '%' THEN 1.0 ELSE similarity(coalesce(element, ''), $3) END,
310
- CASE WHEN coalesce(summary, '') ILIKE '%' || $3 || '%' THEN 1.0 ELSE similarity(coalesce(summary, ''), $3) END
311
- ) >= 0.10
312
- ${trgmFilterClause}
313
- ORDER BY trg_sim DESC
314
- LIMIT $4
315
- ),`
285
+ ),`
316
286
 
317
287
  const exactCte = exactTerms.length > 0 ? `
318
288
  exact AS (
@@ -134,17 +134,14 @@ export async function init(db, dims) {
134
134
  await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_phase_sweep ON entries(status, is_root, error_count, reviewed_at, id)`)
135
135
  await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_promoted_at ON entries(promoted_at) WHERE promoted_at IS NOT NULL`)
136
136
  await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_tsv ON entries USING GIN (search_tsv)`)
137
- // Recall CTEs (memory-recall-store.mjs dense/trgm legs) intentionally match
137
+ // Recall CTEs (memory-recall-store.mjs dense/text legs) intentionally match
138
138
  // BOTH root and leaf/chunk rows, so their SQL has NO `is_root = 1` predicate
139
- // (only `embedding IS NOT NULL` / content|element|summary text filters).
139
+ // (only `embedding IS NOT NULL` / portable substring text filters).
140
140
  // The old root-only PARTIAL indexes therefore could not be used by those
141
141
  // queries — the planner fell back to a Seq Scan + top-N heapsort over every
142
- // embedding (verified via EXPLAIN ANALYZE). Broaden the predicates to match
143
- // the query shape so HNSW/GIN are actually used. A `summary` trgm index is
144
- // added because the trgm leg also filters on `summary` but had no index.
145
- await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_content_trgm ON entries USING GIN (content gin_trgm_ops)`)
146
- await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_element_trgm ON entries USING GIN (element gin_trgm_ops) WHERE element IS NOT NULL`)
147
- await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_summary_trgm ON entries USING GIN (summary gin_trgm_ops) WHERE summary IS NOT NULL`)
142
+ // embedding (verified via EXPLAIN ANALYZE). Broaden the HNSW predicate to
143
+ // match the query shape. Substring rescue intentionally stays index-free:
144
+ // bundled Unix PG runtimes do not include the optional pg_trgm extension.
148
145
  await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_embedding_hnsw ON entries USING hnsw (embedding halfvec_cosine_ops) WHERE embedding IS NOT NULL`)
149
146
 
150
147
  // BEFORE INSERT/UPDATE trigger keeps score in sync with category + last_seen_at
@@ -392,8 +389,6 @@ async function _migrateRecallIndexesIfStale(db) {
392
389
  // Each entry: [indexName, newDefTailPredicate] where the presence of
393
390
  // "is_root" in the live indexdef signals the stale root-only shape.
394
391
  const targets = [
395
- { name: 'idx_entries_content_trgm', create: `CREATE INDEX idx_entries_content_trgm ON entries USING GIN (content gin_trgm_ops)` },
396
- { name: 'idx_entries_element_trgm', create: `CREATE INDEX idx_entries_element_trgm ON entries USING GIN (element gin_trgm_ops) WHERE element IS NOT NULL` },
397
392
  { name: 'idx_entries_embedding_hnsw', create: `CREATE INDEX idx_entries_embedding_hnsw ON entries USING hnsw (embedding halfvec_cosine_ops) WHERE embedding IS NOT NULL` },
398
393
  ]
399
394
  try {
@@ -403,8 +398,7 @@ async function _migrateRecallIndexesIfStale(db) {
403
398
  const r = await db.query(`SELECT indexdef FROM pg_indexes WHERE indexname = $1`, [t.name])
404
399
  def = r.rows?.[0]?.indexdef ?? null
405
400
  } catch { def = null }
406
- // Missing index post-loop IF-NOT-EXISTS ensures below self-heal trgm
407
- // indexes; embedding_hnsw is also re-ensured by ensureCurrentSchemaExtensions.
401
+ // Missing embedding_hnsw is re-ensured by ensureCurrentSchemaExtensions.
408
402
  if (def == null) continue
409
403
  // Already broadened (no is_root predicate) → no-op, no rebuild.
410
404
  if (!/is_root/i.test(def)) continue
@@ -416,26 +410,6 @@ async function _migrateRecallIndexesIfStale(db) {
416
410
  __mixdogMemoryLog(`[memory] recall index migration for ${t.name} failed: ${err?.message || err}\n`)
417
411
  }
418
412
  }
419
- // Self-heal trgm recall indexes on already-bootstrapped DBs: init() will
420
- // not re-run, and a missing index is not handled by the stale-shape loop
421
- // above (def == null → continue). These IF-NOT-EXISTS ensures recreate any
422
- // trgm index that never existed or whose migrate-create failed. No-op (cheap
423
- // catalog check) when the broadened index is already present.
424
- try {
425
- await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_content_trgm ON entries USING GIN (content gin_trgm_ops)`)
426
- } catch (err) {
427
- __mixdogMemoryLog(`[memory] idx_entries_content_trgm ensure failed: ${err?.message || err}\n`)
428
- }
429
- try {
430
- await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_element_trgm ON entries USING GIN (element gin_trgm_ops) WHERE element IS NOT NULL`)
431
- } catch (err) {
432
- __mixdogMemoryLog(`[memory] idx_entries_element_trgm ensure failed: ${err?.message || err}\n`)
433
- }
434
- try {
435
- await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_summary_trgm ON entries USING GIN (summary gin_trgm_ops) WHERE summary IS NOT NULL`)
436
- } catch (err) {
437
- __mixdogMemoryLog(`[memory] idx_entries_summary_trgm ensure failed: ${err?.message || err}\n`)
438
- }
439
413
  } catch (err) {
440
414
  __mixdogMemoryLog(`[memory] _migrateRecallIndexesIfStale failed: ${err?.message || err}\n`)
441
415
  }
@@ -139,8 +139,6 @@ async function _initClient(client, schema) {
139
139
  ? 'scheduler, public'
140
140
  : 'memory, public'
141
141
  await client.query(`SET search_path = ${sp}`)
142
- // pg_trgm similarity threshold: session-local, must be set per connection.
143
- await client.query(`SELECT set_limit(0.10)`)
144
142
  await client.query(`SET default_transaction_isolation TO 'read committed'`)
145
143
  // Mark seen only after all init statements succeed; failure leaves client
146
144
  // unmarked so the next checkout retries init.
@@ -400,7 +398,6 @@ async function bootstrapInstance(pgPool, dataDirKey) {
400
398
  const client = await pgPool.connect()
401
399
  try {
402
400
  await client.query(`CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA public`)
403
- await client.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public`)
404
401
  await client.query(`CREATE SCHEMA IF NOT EXISTS memory`)
405
402
  await client.query(`CREATE SCHEMA IF NOT EXISTS trace`)
406
403
  await client.query(`CREATE SCHEMA IF NOT EXISTS scheduler`)
@@ -77,18 +77,11 @@ function readPostmasterInfo(pgdataDir) {
77
77
  }
78
78
  }
79
79
 
80
- function pgIsReady(runtimeDir, env, port) {
81
- const probe = spawnSync(pgBin(runtimeDir, 'pg_isready'), ['-h', '127.0.0.1', '-p', String(port)], {
82
- env, stdio: 'pipe', timeout: 3_000, windowsHide: true,
83
- })
84
- return probe.status === 0
85
- }
86
-
87
80
  async function awaitExistingPostmaster({ runtimeDir, pgdataDir, env, waitMs }) {
88
81
  const deadline = Date.now() + Math.max(0, Number(waitMs) || 0)
89
82
  let info = readPostmasterInfo(pgdataDir)
90
83
  while (info.pid && info.port) {
91
- if (pgIsReady(runtimeDir, env, info.port)) return { state: 'ready', ...info }
84
+ if (await healthcheckPg({ port: info.port })) return { state: 'ready', ...info }
92
85
  if (!isPidAlive(info.pid)) return { state: 'dead', ...info }
93
86
  if (Date.now() >= deadline) return { state: 'alive-not-ready', ...info }
94
87
  await delay(250)
@@ -115,9 +108,8 @@ async function findFreePort(preferred) {
115
108
 
116
109
  const MIXDOG_CONF_V2_MARKER = '# mixdog overrides v2 — bgwriter / checkpoint distribution'
117
110
 
118
- // Lines emitted into postgresql.conf for v2. effective_io_concurrency is
119
- // posix_fadvise-only; PG rejects non-zero values on Windows with an invalid-
120
- // parameter error on every reload. Skip it there.
111
+ // Lines emitted into postgresql.conf for v2. effective_io_concurrency requires
112
+ // posix_fadvise; PostgreSQL rejects non-zero values on Windows and macOS.
121
113
  function buildV2Block() {
122
114
  const lines = [
123
115
  '',
@@ -126,17 +118,16 @@ function buildV2Block() {
126
118
  'bgwriter_lru_maxpages = 1000', // up to 1000 dirty pages per round (default 100)
127
119
  'bgwriter_delay = 50ms', // wake bgwriter every 50ms (default 200ms)
128
120
  ]
129
- if (process.platform !== 'win32') {
130
- lines.push('effective_io_concurrency = 32') // I/O concurrency hint (POSIX only)
121
+ if (process.platform === 'linux') {
122
+ lines.push('effective_io_concurrency = 32')
131
123
  }
132
124
  return lines.join('\n') + '\n'
133
125
  }
134
126
 
135
- // Migration: an earlier v2 always emitted effective_io_concurrency. On Windows
136
- // that line is invalid strip it so pg_ctl reload stops logging a parse error
137
- // every cycle.
138
- function stripWindowsInvalidLines(conf) {
139
- if (process.platform !== 'win32') return conf
127
+ // Migration: an earlier v2 emitted effective_io_concurrency on every POSIX
128
+ // platform. Strip it anywhere except Linux so old macOS clusters can start.
129
+ function stripPlatformInvalidLines(conf) {
130
+ if (process.platform === 'linux') return conf
140
131
  return conf.replace(/^effective_io_concurrency\s*=\s*\d+\s*\r?\n/m, '')
141
132
  }
142
133
 
@@ -144,7 +135,7 @@ function ensureConfV2(pgdataDir) {
144
135
  const confPath = join(pgdataDir, 'postgresql.conf')
145
136
  if (!existsSync(confPath)) return false
146
137
  const original = readFileSync(confPath, 'utf8')
147
- let conf = stripWindowsInvalidLines(original)
138
+ let conf = stripPlatformInvalidLines(original)
148
139
  const stripped = conf !== original
149
140
  const hasMarker = conf.includes(MIXDOG_CONF_V2_MARKER)
150
141
  if (hasMarker && !stripped) return false
@@ -185,10 +176,6 @@ export async function startPg({
185
176
  }) {
186
177
  mkdirSync(pgdataDir, { recursive: true })
187
178
 
188
- if (process.platform === 'darwin') {
189
- try { writeFileSync(join(pgdataDir, '.metadata_never_index'), '') } catch {}
190
- }
191
-
192
179
  // Idempotent v2 conf reconcile — runs before attach/init. Returns true if
193
180
  // the block was just appended (so the attach path can trigger pg_ctl reload).
194
181
  // Fresh-init path falls through to confAppend below which already includes v2.
@@ -282,6 +269,12 @@ export async function startPg({
282
269
  )
283
270
  }
284
271
  }
272
+ // PostgreSQL requires a completely empty target on first init. Creating the
273
+ // Spotlight marker before initdb makes every fresh macOS cluster fail with
274
+ // "directory exists but is not empty"; add it only after initialization.
275
+ if (process.platform === 'darwin') {
276
+ try { writeFileSync(join(pgdataDir, '.metadata_never_index'), '') } catch {}
277
+ }
285
278
 
286
279
  // Choose a free port (guards against stale postmaster from prior crash).
287
280
  const port = await findFreePort(preferredPort)
@@ -350,7 +343,7 @@ export async function startPg({
350
343
  const deadline = Date.now() + 30_000
351
344
  // eslint-disable-next-line no-constant-condition
352
345
  while (true) {
353
- if (pgIsReady(runtimeDir, env, port)) {
346
+ if (await healthcheckPg({ port })) {
354
347
  const pid = await confirmPid()
355
348
  // pid confirmed with matching port → ready. Otherwise keep polling until
356
349
  // the cap (postmaster.pid not yet written or port mismatch).
@@ -383,7 +376,14 @@ export async function startPg({
383
376
  }
384
377
  }
385
378
 
386
- const detail = errText || (r.timeout ? '(readiness probe timed out after 30s; no pg_ctl output)' : '(no captured output)')
379
+ let serverLog = ''
380
+ try { serverLog = readFileSync(logFile, 'utf8').trim() } catch {}
381
+ const detail = [
382
+ errText.trim(),
383
+ serverLog ? `postgres log:\n${serverLog}` : '',
384
+ ].filter(Boolean).join('\n') || (
385
+ r.timeout ? '(readiness probe timed out after 30s; no pg_ctl or postgres log output)' : '(no captured output)'
386
+ )
387
387
  throw new Error(`[pg-process] pg_ctl start failed: ${detail}`)
388
388
  }
389
389