mixdog 0.9.25 → 0.9.27

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 (87) hide show
  1. package/package.json +2 -1
  2. package/scripts/arg-guard-test.mjs +68 -0
  3. package/scripts/compacted-placeholder-scrub-test.mjs +77 -0
  4. package/scripts/steering-fold-provenance-test.mjs +71 -0
  5. package/scripts/webhook-smoke.mjs +46 -53
  6. package/src/cli.mjs +40 -4
  7. package/src/defaults/skills/setup/SKILL.md +6 -1
  8. package/src/rules/shared/01-tool.md +11 -4
  9. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +3 -1
  10. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
  11. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
  12. package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
  13. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
  14. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
  15. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
  16. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
  17. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
  18. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +75 -0
  19. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +5 -0
  20. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
  21. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
  22. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +14 -1
  23. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  24. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
  28. package/src/runtime/channels/lib/config.mjs +13 -11
  29. package/src/runtime/channels/lib/memory-client.mjs +22 -2
  30. package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
  31. package/src/runtime/channels/lib/scheduler.mjs +226 -208
  32. package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
  33. package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
  34. package/src/runtime/channels/lib/webhook.mjs +98 -150
  35. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  36. package/src/runtime/memory/index.mjs +50 -25
  37. package/src/runtime/memory/lib/cycle-scheduler.mjs +3 -1
  38. package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
  39. package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
  40. package/src/runtime/memory/lib/pg/process.mjs +19 -1
  41. package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
  42. package/src/runtime/memory/lib/query-handlers.mjs +102 -0
  43. package/src/runtime/memory/lib/recall-format.mjs +60 -22
  44. package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
  45. package/src/runtime/memory/tool-defs.mjs +3 -3
  46. package/src/runtime/shared/child-guardian.mjs +2 -2
  47. package/src/runtime/shared/open-url.mjs +2 -1
  48. package/src/runtime/shared/schedules-db.mjs +350 -0
  49. package/src/runtime/shared/service-discovery.mjs +169 -0
  50. package/src/runtime/shared/spawn-flags.mjs +27 -0
  51. package/src/runtime/shared/staged-install-worker.mjs +14 -0
  52. package/src/runtime/shared/staged-update.mjs +530 -0
  53. package/src/runtime/shared/tool-primitives.mjs +3 -1
  54. package/src/runtime/shared/tool-surface.mjs +19 -3
  55. package/src/runtime/shared/update-checker.mjs +54 -10
  56. package/src/runtime/shared/webhooks-db.mjs +405 -0
  57. package/src/session-runtime/channel-config-api.mjs +13 -13
  58. package/src/session-runtime/lifecycle-api.mjs +12 -0
  59. package/src/session-runtime/runtime-core.mjs +41 -23
  60. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  61. package/src/standalone/agent-tool.mjs +42 -11
  62. package/src/standalone/channel-admin.mjs +173 -121
  63. package/src/standalone/channel-worker.mjs +2 -2
  64. package/src/standalone/memory-runtime-proxy.mjs +10 -5
  65. package/src/tui/App.jsx +32 -10
  66. package/src/tui/app/channel-pickers.mjs +9 -9
  67. package/src/tui/app/clipboard.mjs +14 -0
  68. package/src/tui/app/doctor.mjs +1 -1
  69. package/src/tui/app/maintenance-pickers.mjs +17 -7
  70. package/src/tui/app/settings-picker.mjs +2 -2
  71. package/src/tui/app/transcript-window.mjs +63 -7
  72. package/src/tui/app/use-mouse-input.mjs +19 -6
  73. package/src/tui/components/Spinner.jsx +5 -2
  74. package/src/tui/components/StatusLine.jsx +7 -7
  75. package/src/tui/components/ToolExecution.jsx +36 -62
  76. package/src/tui/display-width.mjs +18 -8
  77. package/src/tui/dist/index.mjs +502 -324
  78. package/src/tui/engine/session-api-ext.mjs +12 -12
  79. package/src/tui/hooks/useSharedTick.mjs +103 -0
  80. package/src/tui/index.jsx +12 -2
  81. package/src/tui/markdown/format-token.mjs +46 -8
  82. package/src/tui/markdown/render-ansi.mjs +24 -1
  83. package/src/tui/markdown/stream-fence.mjs +60 -0
  84. package/src/tui/markdown/streaming-markdown.mjs +22 -1
  85. package/src/ui/statusline-segments.mjs +12 -1
  86. package/vendor/ink/build/display-width.js +10 -8
  87. package/src/runtime/shared/schedules-store.mjs +0 -82
@@ -290,7 +290,12 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
290
290
  // so we return the instant pg_isready succeeds rather than waiting on pg_ctl.
291
291
  // Only the long-lived child handle is unref'd; poll timers stay ref'd.
292
292
  async function startAndWaitReady() {
293
- const child = spawn(pgctl, startArgs, { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
293
+ // detached on win32: give pg_ctl (and the postmaster it spawns) its own
294
+ // process group / console so an ancestor `taskkill /F /T` cannot enumerate
295
+ // it via the Node process tree. The orphaned postmaster survives and is
296
+ // re-adopted next boot via supervisor tryReusePgInstance (postmaster.pid).
297
+ const detached = process.platform === 'win32'
298
+ const child = spawn(pgctl, startArgs, { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, detached })
294
299
  child.unref?.()
295
300
  let stdout = '', stderr = '', closed = false, exitCode = null
296
301
  child.stdout?.on('data', d => { stdout += d.toString() })
@@ -375,6 +380,19 @@ export async function stopPg({ runtimeDir, pgdataDir }) {
375
380
  }
376
381
  }
377
382
 
383
+ // Synchronous best-effort variant for a process 'exit' hook (only sync work is
384
+ // possible there). One bounded `pg_ctl stop -m fast`; never throws. On Windows
385
+ // a force-kill of the daemon can orphan the postmaster mid-write, so this is
386
+ // the last-ditch graceful attempt before the process goes away.
387
+ export function stopPgSync({ runtimeDir, pgdataDir }) {
388
+ try {
389
+ spawnSync(pgBin(runtimeDir, 'pg_ctl'), ['stop', '-m', 'fast', '-D', pgdataDir], {
390
+ env: libEnv(runtimeDir), stdio: 'ignore', timeout: 8_000, windowsHide: true,
391
+ })
392
+ __mixdogMemoryLog('[pg-process] stopPgSync: pg_ctl stop -m fast issued on exit\n')
393
+ } catch {}
394
+ }
395
+
378
396
  // ---------------------------------------------------------------------------
379
397
  // healthcheckPg
380
398
  // ---------------------------------------------------------------------------
@@ -28,7 +28,12 @@ import {
28
28
  } from 'node:fs';
29
29
  import { join, resolve } from 'node:path';
30
30
  import { tmpdir } from 'node:os';
31
- import { updateJsonAtomicSync } from '../../../shared/atomic-file.mjs';
31
+ import {
32
+ discoveryPath as _pgDiscoveryPath,
33
+ readServiceAdvert as _readPgServiceAdvert,
34
+ writeServiceAdvert as _writePgServiceAdvert,
35
+ } from '../../../shared/service-discovery.mjs';
36
+ import { withFileLockSync } from '../../../shared/atomic-file.mjs';
32
37
 
33
38
  // ── pg-process interface (Track A) ───────────────────────────────────────────
34
39
  // Dynamic import so this module loads even before Track A's file exists.
@@ -42,6 +47,7 @@ async function _getPgProc() {
42
47
  stopPg: mod.stopPg,
43
48
  healthcheckPg: mod.healthcheckPg,
44
49
  reconcileConfV2: mod.reconcileConfV2,
50
+ stopPgSync: mod.stopPgSync,
45
51
  };
46
52
  return _pgProc;
47
53
  }
@@ -181,23 +187,95 @@ const _RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
181
187
  ? resolve(process.env.MIXDOG_RUNTIME_ROOT)
182
188
  : join(tmpdir(), 'mixdog');
183
189
  const _ACTIVE_FILE = join(_RUNTIME_ROOT, 'active-instance.json');
190
+ // Single-writer PG advert. Port discovery (pg_port et al.) moved OUT of the
191
+ // shared, lock-guarded active-instance.json into discovery/pg.json, written by
192
+ // this supervisor via a plain atomic rename (NO .lock) so it can never be
193
+ // starved by active-instance lock contention.
194
+ const _PG_DISCOVERY = 'pg';
195
+
196
+ // Read the PG advert, preferring the single-writer discovery file
197
+ // (discovery/pg.json) and falling back to the legacy active-instance.json pg_*
198
+ // fields for cross-version compat. Field names are identical in both sources.
199
+ function _readPgAdvert() {
200
+ const advert = _readPgServiceAdvert(_PG_DISCOVERY);
201
+ if (advert && Number(advert.pg_port) > 0) return advert;
202
+ try {
203
+ const ai = JSON.parse(readFileSync(_ACTIVE_FILE, 'utf8'));
204
+ if (ai && Number(ai.pg_port) > 0) return ai;
205
+ } catch {}
206
+ return advert ?? null;
207
+ }
184
208
 
185
- // Lock contention codes thrown by withFileLockSync when the active-instance
186
- // lock cannot be acquired within timeoutMs (or try-once contention).
187
- const _LOCK_CONTENTION_CODES = new Set(['ELOCKTIMEOUT', 'ELOCKCONTENDED']);
209
+ // Transient write-contention codes. ELOCK* survive from the old locked path;
210
+ // EPERM/EBUSY/EACCES cover Windows atomic-rename hiccups on the plain-rename
211
+ // discovery write so it backs off and retries rather than dropping the advert.
212
+ const _LOCK_CONTENTION_CODES = new Set(['ELOCKTIMEOUT', 'ELOCKCONTENDED', 'EPERM', 'EBUSY', 'EACCES']);
213
+
214
+ // Is the current on-disk advert owned by a DIFFERENT, still-live supervisor?
215
+ // A clear (pg_port → null) must never unlink a NEWER supervisor's fresh advert:
216
+ // re-read under the lock and, if pg_owner_pid names another live process, leave
217
+ // it. Our own pid, a dead owner, or a missing/legacy owner → safe to unlink.
218
+ function _pgAdvertIsForeignLive(adv) {
219
+ const owner = Number(adv?.pg_owner_pid);
220
+ if (!Number.isInteger(owner) || owner <= 0) return false;
221
+ if (owner === process.pid) return false;
222
+ return isPidAlive(owner);
223
+ }
188
224
 
189
225
  function _applyActiveInstancePatch(fields, timeoutMs) {
190
- updateJsonAtomicSync(_ACTIVE_FILE, (curRaw) => {
191
- // Drop stale fields (pid/startedAt) written by older server versions.
192
- const { pid: _legacyPid, startedAt: _legacyStartedAt, ...cur } = curRaw ?? {};
193
- // Omit null-valued fields (clean removal when pg is stopped).
194
- const merged = { ...cur, updatedAt: Date.now() };
226
+ // pg.json is NOT single-writer: start/attach/stop paths write it across
227
+ // separate supervisor processes, so the unlocked read-merge-write raced and
228
+ // lost cross-process updates. Guard it with pg.json's OWN private lock
229
+ // supervisor-only contention, so it never recreates the shared
230
+ // active-instance.json bottleneck (memory.json stays lock-free).
231
+ const file = _pgDiscoveryPath(_PG_DISCOVERY);
232
+ withFileLockSync(`${file}.lock`, () => {
233
+ const cur = _readPgServiceAdvert(_PG_DISCOVERY) ?? {};
234
+ // Drop stale fields (pid/startedAt/updatedAt) written by older versions.
235
+ const { pid: _legacyPid, startedAt: _legacyStartedAt, updatedAt: _prevUpdatedAt, ...merged } = cur;
195
236
  for (const [k, v] of Object.entries(fields)) {
196
237
  if (v == null) delete merged[k];
197
238
  else merged[k] = v;
198
239
  }
199
- return merged;
200
- }, { compact: true, fsyncDir: true, renameFallback: 'truncate', timeoutMs });
240
+ const port = Number(merged.pg_port);
241
+ if (!Number.isInteger(port) || port <= 0) {
242
+ // No live port left → drop the advert (clean pg shutdown). But a late
243
+ // clear from an old shutdown/recovery path must NOT delete a fresh advert
244
+ // a newer supervisor just published — unlink only when it is ours or a
245
+ // dead corpse, never another live owner's.
246
+ if (_pgAdvertIsForeignLive(cur)) return;
247
+ try { unlinkSync(file); } catch {}
248
+ return;
249
+ }
250
+ // Stamp our supervisor pid so a later clear can prove advert ownership.
251
+ merged.pg_owner_pid = process.pid;
252
+ _writePgServiceAdvert(_PG_DISCOVERY, merged);
253
+ }, { timeoutMs });
254
+ }
255
+
256
+ // Re-stamp advert ownership on REUSE without clobbering a concurrently-updated
257
+ // advert. Unlike patchActiveInstance (which blindly overwrites pg_port/pg_pgdata
258
+ // /pg_runtime_dir from the reuse-time snapshot — already stale by the time the
259
+ // lock is acquired, so a restart or a different live owner that landed meanwhile
260
+ // gets clobbered), this RE-READS under the lock and only stamps our pid when the
261
+ // advert STILL describes the same instance we reused (same pgdata + port) and no
262
+ // DIFFERENT live owner has since taken it. It touches only pg_owner_pid — never
263
+ // rewriting other fields from the stale snapshot.
264
+ function _restampReuseOwner({ pgdata, port }) {
265
+ const file = _pgDiscoveryPath(_PG_DISCOVERY);
266
+ try {
267
+ withFileLockSync(`${file}.lock`, () => {
268
+ const cur = _readPgServiceAdvert(_PG_DISCOVERY);
269
+ if (!cur) return; // advert gone — nothing to re-stamp
270
+ if (Number(cur.pg_port) !== Number(port)) return; // restarted on a new port
271
+ if (!cur.pg_pgdata || resolve(cur.pg_pgdata) !== resolve(pgdata)) return; // different instance
272
+ if (_pgAdvertIsForeignLive(cur)) return; // a different live owner holds it
273
+ if (Number(cur.pg_owner_pid) === process.pid) return; // already ours
274
+ const { pid: _legacyPid, startedAt: _legacyStartedAt, updatedAt: _prevUpdatedAt, ...merged } = cur;
275
+ merged.pg_owner_pid = process.pid; // minimal: owner pid only (updatedAt re-stamped on write)
276
+ _writePgServiceAdvert(_PG_DISCOVERY, merged);
277
+ }, { timeoutMs: 1000 });
278
+ } catch { /* best-effort: lock contention → skip re-stamp (guard reverts to prior owner) */ }
201
279
  }
202
280
 
203
281
  // Single module-level background-retry slot: a delayed retry must NEVER replay
@@ -347,7 +425,7 @@ async function tryReusePgInstance({ pgdata, runtimeDir, healthcheckPg, source =
347
425
  let existingPort = null;
348
426
  let existingRtDir = runtimeDir;
349
427
  try {
350
- ai = JSON.parse(readFileSync(_ACTIVE_FILE, 'utf8'));
428
+ ai = _readPgAdvert();
351
429
  if (ai?.pg_port && ai?.pg_pgdata && resolve(ai.pg_pgdata) === resolve(pgdata)) {
352
430
  existingPort = ai.pg_port;
353
431
  existingRtDir = ai?.pg_runtime_dir ?? runtimeDir;
@@ -359,6 +437,14 @@ async function tryReusePgInstance({ pgdata, runtimeDir, healthcheckPg, source =
359
437
  if (await healthcheckPg({ port: existingPort })) {
360
438
  __mixdogMemoryLog(`[supervisor-pg] reusing PG on port ${existingPort} (${source}:active-instance)\n`);
361
439
  _live = { port: existingPort, pgdata, runtimeDir: existingRtDir, proc: null };
440
+ // Re-stamp advert ownership: this process is now a live owner of the
441
+ // reused postmaster. Without it the advert keeps the previous (possibly
442
+ // dead) owner pid, and a later sync exit hook's _pgAdvertIsForeignLive
443
+ // guard would judge it "not foreign-live" and wrongly sync-stop a
444
+ // postmaster this reuser still uses. Use the revalidating re-stamp (not
445
+ // patchActiveInstance) so a concurrent restart/owner change is not
446
+ // clobbered by this reuse-time snapshot.
447
+ _restampReuseOwner({ pgdata, port: existingPort });
362
448
  return { host: '127.0.0.1', port: existingPort, runtimeDir: existingRtDir, pgdataDir: pgdata };
363
449
  }
364
450
  } catch {}
@@ -452,7 +538,7 @@ async function _doEnsure(dataDir) {
452
538
  let existingPort = null;
453
539
  let ai = null;
454
540
  try {
455
- ai = JSON.parse(readFileSync(_ACTIVE_FILE, 'utf8'));
541
+ ai = _readPgAdvert();
456
542
  // Only reuse a recorded instance when it was started for THIS pgdata.
457
543
  // active-instance.json is process-global; without matching pg_pgdata a
458
544
  // healthy PG serving a different data directory would be reused, binding
@@ -536,7 +622,7 @@ export async function stopPgForShutdown() {
536
622
  // _live may be null if PG was started by another process or adapter call.
537
623
  // Attempt graceful stop via active-instance.json.
538
624
  let ai = null;
539
- try { ai = JSON.parse(readFileSync(_ACTIVE_FILE, 'utf8')); } catch {}
625
+ try { ai = _readPgAdvert(); } catch {}
540
626
  if (!ai?.pg_port || !ai?.pg_pgdata) return;
541
627
  const pgdataDir2 = ai.pg_pgdata;
542
628
  const runtimeDir2 = ai.pg_runtime_dir;
@@ -570,3 +656,28 @@ export async function stopPgForShutdown() {
570
656
  // tolerated — readers healthcheck-verify the port before reuse.
571
657
  patchActiveInstance({ pg_port: null, pg_started_at: null, pg_pgdata: null, pg_runtime_dir: null }, { timeoutMs: 2000, syncRetries: 1 });
572
658
  }
659
+
660
+ /**
661
+ * Synchronous last-ditch PG stop for a process 'exit' hook. Covers the paths
662
+ * where the async stopPgForShutdown() above never runs/completes — a Windows
663
+ * force-kill, or stop() hitting its exit timeout — which otherwise kill the
664
+ * postmaster mid-write and force WAL crash recovery on next boot. No-op once
665
+ * the async path has cleared _live + active-instance (its record is gone), so
666
+ * a graceful shutdown is never double-stopped.
667
+ */
668
+ export function stopPgForShutdownSync() {
669
+ // Mirror the async clear's ownership guard (_pgAdvertIsForeignLive) and apply
670
+ // it UNCONDITIONALLY — even when _live is set. Async shutdown or a
671
+ // cross-process reuse may have handed this postmaster to a different, still-
672
+ // live owner that re-stamped pg_owner_pid; that owner will stop it itself, so
673
+ // the exit hook must never sync-stop it out from under them.
674
+ let ai = null;
675
+ try { ai = _readPgAdvert(); } catch {}
676
+ if (_pgAdvertIsForeignLive(ai)) return;
677
+ let runtimeDir = _live?.runtimeDir, pgdataDir = _live?.pgdata;
678
+ if (!runtimeDir || !pgdataDir) {
679
+ runtimeDir = ai?.pg_runtime_dir; pgdataDir = ai?.pg_pgdata;
680
+ }
681
+ if (!runtimeDir || !pgdataDir || !_pgProc?.stopPgSync) return;
682
+ try { _pgProc.stopPgSync({ runtimeDir, pgdataDir }); } catch {}
683
+ }
@@ -23,6 +23,7 @@ import {
23
23
  import { searchRelevantHybrid } from './memory-recall-store.mjs'
24
24
  import { fetchEntriesByIdsScoped } from './memory-recall-id-patch.mjs'
25
25
  import { retrieveEntries } from './memory-retrievers.mjs'
26
+ import { buildPromotedExclusionClauses } from './memory-recall-scope-filter.mjs'
26
27
  import { cleanMemoryText } from './memory.mjs'
27
28
  import { insertTraceEvents } from './trace-store.mjs'
28
29
  import {
@@ -661,6 +662,107 @@ export function createQueryHandlers({
661
662
  return out
662
663
  }
663
664
 
665
+ // period='last': session-grouped browse. Pick the N most-recently-active
666
+ // sessions (limit = session count, default 5; offset = session-level
667
+ // paging) ranked by MAX(ts) DESC, then fill each with its newest rows
668
+ // under a per-session row cap. Session selection and per-session fetch
669
+ // reuse the same projectScope / excludeStatuses filters as the generic
670
+ // browse below; the grouped renderer adds activity-span headers. Output
671
+ // size is bounded by session count x per-session row cap plus the
672
+ // orchestrator-level tool-output KB cap — no recall-local line budget.
673
+ if (temporal?.mode === 'last') {
674
+ const sessionCount = Number.isFinite(requestedLimit) ? limit : 5
675
+ const PER_SESSION_ROW_CAP = 10
676
+ const excludeStatuses = includeArchived ? [] : ['archived']
677
+ const VALID_LAST_CATS = new Set(['rule', 'constraint', 'decision', 'fact', 'goal', 'preference', 'task', 'issue'])
678
+ const catList = category == null
679
+ ? []
680
+ : (Array.isArray(category) ? category : [category])
681
+ .map((c) => String(c).trim().toLowerCase())
682
+ .filter((c) => VALID_LAST_CATS.has(c))
683
+ // 1) rank sessions by most-recent activity (MAX(ts)) over the SAME pool
684
+ // the fill draws from — roots (status-filtered) + unchunked raw
685
+ // leaves, member rows excluded — so a session ranked here can never
686
+ // fill empty (e.g. an archived-root session whose only surviving rows
687
+ // are members). projectScope + excludeStatuses + category + promoted
688
+ // exclusion all match the fill filters below.
689
+ const selWhere = [
690
+ 'session_id IS NOT NULL',
691
+ "btrim(session_id) <> ''",
692
+ '(is_root = 1 OR chunk_root IS NULL OR chunk_root = id)',
693
+ ]
694
+ const selParams = []
695
+ if (projectScope === 'common') {
696
+ selWhere.push('project_id IS NULL')
697
+ } else if (typeof projectScope === 'string' && projectScope && projectScope !== 'all') {
698
+ selParams.push(projectScope)
699
+ selWhere.push(`(project_id IS NULL OR project_id = $${selParams.length})`)
700
+ }
701
+ if (excludeStatuses.length > 0) {
702
+ const ph = excludeStatuses.map((s) => { selParams.push(s); return `$${selParams.length}` }).join(',')
703
+ selWhere.push(`(status IS NULL OR status NOT IN (${ph}))`)
704
+ }
705
+ if (catList.length > 0) {
706
+ const ph = catList.map((c) => { selParams.push(c); return `$${selParams.length}` }).join(',')
707
+ selWhere.push(`category IN (${ph})`)
708
+ }
709
+ for (const c of buildPromotedExclusionClauses()) selWhere.push(c)
710
+ selParams.push(sessionCount, offset)
711
+ // Deterministic tie-breaker: equal MAX(ts) across sessions would let the
712
+ // LIMIT/OFFSET window skip or duplicate a session between offset pages
713
+ // without a stable secondary sort.
714
+ const sessSql = `SELECT session_id, MAX(ts) AS last_ts
715
+ FROM entries
716
+ WHERE ${selWhere.join(' AND ')}
717
+ GROUP BY session_id
718
+ ORDER BY last_ts DESC, session_id DESC
719
+ LIMIT $${selParams.length - 1} OFFSET $${selParams.length}`
720
+ const sessRows = (await db.query(sessSql, selParams)).rows
721
+ // 2) per selected session, fetch its newest rows (roots+members, sort by
722
+ // date) plus the fresh raw window, capped at PER_SESSION_ROW_CAP.
723
+ const allRows = []
724
+ for (const s of sessRows) {
725
+ const sid = String(s?.session_id || '').trim()
726
+ if (!sid) continue
727
+ const sf = { limit: PER_SESSION_ROW_CAP, session_id: sid, projectScope, sort: 'date' }
728
+ if (includeMembers) sf.includeMembers = true
729
+ if (excludeStatuses.length > 0) sf.excludeStatuses = excludeStatuses
730
+ if (catList.length > 0) sf.category = catList
731
+ const sRows = await retrieveEntries(db, sf)
732
+ let merged = sRows
733
+ if (includeRaw) {
734
+ const rawRows = await readRawRowsInWindow(db, null, Date.now(), PER_SESSION_ROW_CAP, { projectScope, sessionId: sid })
735
+ const seenIds = new Set(sRows.map((r) => Number(r.id)))
736
+ for (const r of sRows) if (Array.isArray(r.members)) for (const m of r.members) seenIds.add(Number(m.id))
737
+ // readRawRowsInWindow carries no category filter, so a category-
738
+ // scoped last must gate raw rows here to match the ranking/fill
739
+ // category predicate (unclassified raw rows have no category and
740
+ // are correctly dropped when a category is requested).
741
+ let newRaw = rawRows.filter((r) => !seenIds.has(Number(r.id)))
742
+ if (catList.length > 0) {
743
+ newRaw = newRaw.filter((r) => catList.includes(String(r.category || '').trim().toLowerCase()))
744
+ }
745
+ // readRawRowsInWindow carries no status filter either, so an
746
+ // includeArchived:false browse must drop archived raw rows here to
747
+ // match the ranking/root-fill excludeStatuses predicate.
748
+ if (excludeStatuses.length > 0) {
749
+ newRaw = newRaw.filter((r) => {
750
+ const st = String(r.status || '').trim().toLowerCase()
751
+ return !st || !excludeStatuses.includes(st)
752
+ })
753
+ }
754
+ if (newRaw.length > 0) {
755
+ merged = [...sRows, ...newRaw]
756
+ merged.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0) || (Number(b.id) || 0) - (Number(a.id) || 0))
757
+ merged = merged.slice(0, PER_SESSION_ROW_CAP)
758
+ }
759
+ }
760
+ for (const r of merged) allRows.push(r)
761
+ }
762
+ const _currentSessionHint = String(args?.currentSessionId || '').trim()
763
+ return { text: recallCapPrefix + renderSessionGroupedLines(allRows, { currentSessionId: _currentSessionHint, recencyOrder: true, spanHeaders: true }) }
764
+ }
765
+
664
766
  const filters = { limit: limit + offset }
665
767
  if (temporal?.startMs != null) { filters.ts_from = temporal.startMs; filters.ts_to = temporal.endMs }
666
768
  filters.projectScope = projectScope
@@ -157,22 +157,18 @@ export function renderEntryLines(rows, { recencyOrder = false } = {}) {
157
157
  // this global re-sort a multi-member chunk would emit oldest-first lines and
158
158
  // break a strict newest-first contract (bench: recency-today).
159
159
  const units = []
160
- // Bound total emitted lines (roots x members) so a many-member recall can't
161
- // inject unbounded output. Per-line content is already capped at 1000 chars;
162
- // this caps the line COUNT. Narrow the query (limit/period/projectScope) for more.
163
- const RECALL_LINE_CAP = 200
164
- // recencyOrder collects every unit first (so the ts sort sees the full set)
165
- // and caps AFTER sorting; the default path keeps the original cap-as-we-go.
166
- const COLLECT_CAP = recencyOrder ? Infinity : RECALL_LINE_CAP
167
- let _capped = false
168
- outer:
160
+ // No line-count cap here: the orchestrator enforces a global tool-output KB
161
+ // cap (builtin.mjs tool_output_token_limit), so recall need not self-truncate.
162
+ // recencyOrder still collects every unit first so the ts sort sees the full
163
+ // set; the default path emits in row order. Per-line body is bounded by a
164
+ // loose 8000-char safety cap so one giant pasted-log row can't monopolize
165
+ // the envelope.
169
166
  for (const r of rows) {
170
167
  // Collapsed near-duplicate (search path only — set by
171
168
  // collapseNearDuplicateRows). Emit a one-line stub carrying its #id so an
172
169
  // id-lookup follow-up can still fetch the full body; never rendered for
173
170
  // id-lookup output (those rows never carry _dupStub).
174
171
  if (r && r._dupStub) {
175
- if (units.length >= COLLECT_CAP) { _capped = true; break }
176
172
  units.push({ ts: Number(r.ts) || 0, text: `[${formatTs(r.ts)}] (near-duplicate of #${r._dupOf} — collapsed) #${r.id}` })
177
173
  continue
178
174
  }
@@ -182,14 +178,12 @@ export function renderEntryLines(rows, { recencyOrder = false } = {}) {
182
178
  // grouping artifact for retrieval — the caller wants the chunk
183
179
  // content (cycle1 raw), not the cycle2-compressed summary.
184
180
  for (const m of r.members) {
185
- if (units.length >= COLLECT_CAP) { _capped = true; break outer }
186
181
  const mTs = formatTs(m.ts)
187
182
  const role = m.role === 'user' ? 'u' : m.role === 'assistant' ? 'a' : (m.role || '?')
188
- const content = cleanMemoryText(String(m.content ?? '')).slice(0, 1000)
183
+ const content = cleanMemoryText(String(m.content ?? '')).slice(0, 8000)
189
184
  units.push({ ts: Number(m.ts) || 0, text: `[${mTs}] ${role}: ${content} #${m.id}` })
190
185
  }
191
186
  } else {
192
- if (units.length >= COLLECT_CAP) { _capped = true; break }
193
187
  // No chunks (root not yet chunked by cycle1, or orphan leaf): emit
194
188
  // the row itself in the same shape. element/summary fall back to
195
189
  // raw content when both are absent.
@@ -205,28 +199,25 @@ export function renderEntryLines(rows, { recencyOrder = false } = {}) {
205
199
  : ''
206
200
  const body = element || summary
207
201
  ? `${element}${summary ? ' — ' + summary : ''}`
208
- : cleanMemoryText(String(r.content ?? '')).slice(0, 1000)
202
+ : cleanMemoryText(String(r.content ?? '')).slice(0, 8000)
209
203
  // Unchunked raw leaf (cycle1 hasn't classified it yet): mark it so
210
204
  // callers can tell fresh-but-unprocessed rows from chunked memory.
211
205
  const pendingMark = (r.is_root === 0 && r.chunk_root == null) ? ' [pending]' : ''
212
- units.push({ ts: Number(r.ts) || 0, text: `[${ts}] ${rolePrefix}${body.slice(0, 1000)}${pendingMark} #${r.id}` })
206
+ units.push({ ts: Number(r.ts) || 0, text: `[${ts}] ${rolePrefix}${body.slice(0, 8000)}${pendingMark} #${r.id}` })
213
207
  }
214
208
  }
215
209
  if (recencyOrder) {
216
210
  // Array.sort is stable in V8, so units sharing the same ts keep their
217
211
  // insertion (root/member) order; only cross-unit ts breaks are corrected.
218
212
  units.sort((a, b) => b.ts - a.ts)
219
- if (units.length > RECALL_LINE_CAP) { units.length = RECALL_LINE_CAP; _capped = true }
220
213
  }
221
- const lines = units.map((u) => u.text)
222
- if (_capped) lines.push(`[recall truncated — showing first ${RECALL_LINE_CAP} lines; narrow the query (limit/period/projectScope) for the rest]`)
223
- return lines.join('\n')
214
+ return units.map((u) => u.text).join('\n')
224
215
  }
225
216
 
226
217
  // Search-result de-duplication. Within a SINGLE formatted result set, hybrid
227
218
  // recall frequently returns several long rows that restate the same design in
228
219
  // slightly different words (e.g. a root summary plus a chunk that paraphrases
229
- // it). They each spend the ~1000-char line budget on near-identical text.
220
+ // it). They each spend a large slice of the envelope on near-identical text.
230
221
  // Cheap heuristic (no embeddings): normalize each row's body to word tokens,
231
222
  // build 3-gram shingle sets, and measure containment overlap = |A∩B| /
232
223
  // min(|A|,|B|) against already-kept rows. Rows arrive rank/date-ordered, so the
@@ -308,6 +299,31 @@ function shortSessionLabel(sid) {
308
299
  return `…${s.slice(-16)}`
309
300
  }
310
301
 
302
+ // Collect every ts in a session group (roots + their inline members) so the
303
+ // span header reflects the true activity window, not just the root ts.
304
+ function collectGroupTs(groupRows) {
305
+ const all = []
306
+ for (const r of groupRows) {
307
+ const t = Number(r?.ts)
308
+ if (Number.isFinite(t)) all.push(t)
309
+ if (Array.isArray(r?.members)) for (const m of r.members) {
310
+ const mt = Number(m?.ts)
311
+ if (Number.isFinite(mt)) all.push(mt)
312
+ }
313
+ }
314
+ return all
315
+ }
316
+
317
+ // Activity-span header suffix: `(MM-DD HH:mm ~ HH:mm, n건)`; the end keeps the
318
+ // MM-DD prefix only when it falls on a different calendar day than the start.
319
+ function spanHeaderSuffix(minTs, maxTs, n) {
320
+ const min = formatTs(minTs) // "YYYY-MM-DD HH:mm"
321
+ const max = formatTs(maxTs)
322
+ const startPart = min.slice(5) // "MM-DD HH:mm"
323
+ const endPart = min.slice(0, 10) === max.slice(0, 10) ? max.slice(11) : max.slice(5)
324
+ return `(${startPart} ~ ${endPart}, ${n}건)`
325
+ }
326
+
311
327
  // Session-grouped rendering for the GLOBAL query-less browse ("what did we
312
328
  // work on recently") when the window spans multiple sessions. A flat ts-desc
313
329
  // list interleaves sessions into one indistinguishable stream and lets one
@@ -320,7 +336,7 @@ function shortSessionLabel(sid) {
320
336
  // group's lines are globally ts-desc: without it a chunk root's members (stored
321
337
  // ts-ASC) interleave with raw rows and invert the visible timeline within a
322
338
  // session (e.g. 04:33 rendered above 04:41).
323
- export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder = false } = {}) {
339
+ export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder = false, spanHeaders = false } = {}) {
324
340
  if (!rows || rows.length === 0) return '(no results)'
325
341
  const groups = new Map()
326
342
  for (const r of rows) {
@@ -329,8 +345,30 @@ export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder
329
345
  if (!groups.has(key)) groups.set(key, [])
330
346
  groups.get(key).push(r)
331
347
  }
332
- if (groups.size <= 1) return renderEntryLines(rows, { recencyOrder })
348
+ if (!spanHeaders && groups.size <= 1) return renderEntryLines(rows, { recencyOrder })
333
349
  const current = String(currentSessionId || '').trim()
350
+ // period='last' session-grouped browse: activity-span headers over each
351
+ // group's body. No line budget — the orchestrator's global tool-output KB
352
+ // cap bounds total size; each group's body is already row-capped by the
353
+ // caller.
354
+ if (spanHeaders) {
355
+ const parts = []
356
+ for (const [sid, groupRows] of groups) {
357
+ const mark = current && sid === current ? ' (current)' : ''
358
+ const label = sid === '(no session)' ? sid : `session ${shortSessionLabel(sid)}`
359
+ const tsAll = collectGroupTs(groupRows)
360
+ const suffix = tsAll.length
361
+ ? ` ${spanHeaderSuffix(Math.min(...tsAll), Math.max(...tsAll), groupRows.length)}`
362
+ : ` (${groupRows.length}건)`
363
+ parts.push(`## ${label}${mark}${suffix}`)
364
+ const bodyStr = renderEntryLines(groupRows, { recencyOrder })
365
+ const bodyLines = bodyStr === '(no results)'
366
+ ? []
367
+ : bodyStr.split('\n')
368
+ for (const l of bodyLines) parts.push(l)
369
+ }
370
+ return parts.join('\n')
371
+ }
334
372
  const parts = []
335
373
  for (const [sid, groupRows] of groups) {
336
374
  const mark = current && sid === current ? ' (current)' : ''
@@ -371,17 +371,31 @@ export function createSessionIngestRuntime({
371
371
  // cap). Serialized on _rawEmbedFlushChain so bursts don't stack full
372
372
  // backlog scans; the ~60s tick still sweeps whatever this misses.
373
373
  if (insertedIds.length > 0) {
374
- const run = flushRawEmbeddings(db, { limit: 200, ids: insertedIds })
374
+ const runOwnFlush = () => flushRawEmbeddings(db, { limit: 200, ids: insertedIds })
375
375
  .then((r) => {
376
376
  if (r.attempted > 0) log(`[embed] post-ingest raw flush (own) attempted=${r.attempted} embedded=${r.embedded}\n`)
377
377
  return r
378
378
  })
379
379
  .catch((err) => log(`[embed] post-ingest raw flush failed: ${err?.message || err}\n`))
380
- let timer
381
- await Promise.race([
382
- run,
383
- new Promise((resolve) => { timer = setTimeout(resolve, INGEST_EMBED_WAIT_MS) }),
384
- ]).finally(() => clearTimeout(timer))
380
+ // Clear/manual-compact path opts out (embedWait:false): those rows are
381
+ // about to be summarized away, so dense-search immediacy is pointless and
382
+ // the bounded wait would only delay compaction. Enqueue the flush onto
383
+ // _rawEmbedFlushChain (append, don't await) so clear-path ingest bursts
384
+ // stay serialized like the backlog sweep — never running concurrent raw
385
+ // flushes. All other callers keep the awaited (bounded) wait so a
386
+ // following recall sees the rows.
387
+ if (args.embedWait === false) {
388
+ _rawEmbedFlushChain = _rawEmbedFlushChain
389
+ .catch(() => {})
390
+ .then(runOwnFlush)
391
+ .catch(() => {})
392
+ } else {
393
+ let timer
394
+ await Promise.race([
395
+ runOwnFlush(),
396
+ new Promise((resolve) => { timer = setTimeout(resolve, INGEST_EMBED_WAIT_MS) }),
397
+ ]).finally(() => clearTimeout(timer))
398
+ }
385
399
  }
386
400
  // Background backlog sweep — kicked, never awaited. Runs even when THIS
387
401
  // call inserted 0 rows, so pre-existing backlog is not left waiting for
@@ -15,8 +15,8 @@ export const TOOL_DEFS = [
15
15
  action: { type: 'string', enum: ['core','status'], description: 'Operation.' },
16
16
  op: { type: 'string', enum: ['add','edit','delete','list','candidates','promote','dismiss'], description: 'Mutation op. candidates/promote/dismiss drive core-memory proposal approval.' },
17
17
  id: { type: 'number', description: 'Exact memory id.' },
18
- element: { type: 'string', description: 'Memory key/title.' },
19
- summary: { type: 'string', description: 'Memory content.' },
18
+ element: { type: 'string', description: 'Memory key/title. Max 40 chars.' },
19
+ summary: { type: 'string', description: 'Memory content: 1 fact, 1-2 sentences, max 100 chars.' },
20
20
  category: { type: 'string', enum: ['rule','constraint','decision','fact','goal','preference','task','issue'], description: 'Category.' },
21
21
  status: { type: 'string', enum: ['pending','active','archived'], description: 'Lifecycle status.' },
22
22
  limit: { type: 'number', description: 'Max rows/items.' },
@@ -36,7 +36,7 @@ export const TOOL_DEFS = [
36
36
  properties: {
37
37
  query: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Search text, or array for independent fan-out queries.' },
38
38
  id: { anyOf: [{ type: 'number' }, { type: 'array', items: { type: 'number' }, minItems: 1 }], description: 'Exact #id(s) from recall. Do not invent ids.' },
39
- period: { type: 'string', description: "last (all history newest-first), Nm/Nh/Nd (rolling), today/yesterday/this_week/last_week, all, YYYY-MM-DD, YYYY-MM-DD~YYYY-MM-DD, HH:MM~HH:MM (today), or 'YYYY-MM-DD HH:MM~HH:MM'." },
39
+ period: { type: 'string', description: "last (recent sessions grouped, newest-active first; limit=session count [default 5], offset=session paging), Nm/Nh/Nd (rolling), today/yesterday/this_week/last_week, all, YYYY-MM-DD, YYYY-MM-DD~YYYY-MM-DD, HH:MM~HH:MM (today), or 'YYYY-MM-DD HH:MM~HH:MM'." },
40
40
  limit: { type: 'number', description: 'Max entries.' },
41
41
  offset: { type: 'number', description: 'Skip entries.' },
42
42
  sort: { type: 'string', enum: ['importance', 'date'], description: 'importance or date.' },
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
+ import { detachedSpawnOpts } from './spawn-flags.mjs';
4
5
 
5
6
  function positiveInt(value) {
6
7
  const n = Number(value);
@@ -141,14 +142,13 @@ export function startChildGuardian({
141
142
  forceGraceMs: Math.max(100, Math.floor(Number(forceGraceMs) || Number(graceMs) || 3000)),
142
143
  }),
143
144
  ], {
144
- detached: true,
145
145
  stdio: 'ignore',
146
- windowsHide: true,
147
146
  env: {
148
147
  PATH: process.env.PATH || '',
149
148
  SystemRoot: process.env.SystemRoot || process.env.WINDIR || '',
150
149
  WINDIR: process.env.WINDIR || process.env.SystemRoot || '',
151
150
  },
151
+ ...detachedSpawnOpts,
152
152
  });
153
153
  guardian.unref?.();
154
154
  return { pid: guardian.pid || null, label, childPid: child };
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'child_process';
2
2
  import { isWSL } from './wsl.mjs';
3
+ import { detachedSpawnOpts } from './spawn-flags.mjs';
3
4
 
4
5
  /**
5
6
  * Open a URL in the user's default browser. Best-effort and non-blocking.
@@ -29,7 +30,7 @@ function tryOpenCandidates(candidates, index) {
29
30
  if (index >= candidates.length) return;
30
31
  const [cmd, args] = candidates[index];
31
32
  try {
32
- const child = spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true });
33
+ const child = spawn(cmd, args, { stdio: 'ignore', ...detachedSpawnOpts });
33
34
  child.on('error', () => { tryOpenCandidates(candidates, index + 1); });
34
35
  child.unref();
35
36
  } catch {