mixdog 0.9.25 → 0.9.26
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 +1 -1
- package/scripts/steering-fold-provenance-test.mjs +71 -0
- package/scripts/webhook-smoke.mjs +46 -53
- package/src/defaults/skills/setup/SKILL.md +6 -1
- package/src/rules/shared/01-tool.md +8 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
- package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
- package/src/runtime/channels/lib/config.mjs +13 -11
- package/src/runtime/channels/lib/memory-client.mjs +22 -2
- package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
- package/src/runtime/channels/lib/scheduler.mjs +226 -208
- package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
- package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
- package/src/runtime/channels/lib/webhook.mjs +98 -150
- package/src/runtime/channels/lib/worker-main.mjs +1 -1
- package/src/runtime/memory/index.mjs +50 -25
- package/src/runtime/memory/lib/cycle-scheduler.mjs +3 -1
- package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
- package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
- package/src/runtime/memory/lib/pg/process.mjs +19 -1
- package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
- package/src/runtime/memory/lib/query-handlers.mjs +102 -0
- package/src/runtime/memory/lib/recall-format.mjs +60 -22
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/child-guardian.mjs +2 -2
- package/src/runtime/shared/open-url.mjs +2 -1
- package/src/runtime/shared/schedules-db.mjs +350 -0
- package/src/runtime/shared/service-discovery.mjs +169 -0
- package/src/runtime/shared/spawn-flags.mjs +27 -0
- package/src/runtime/shared/tool-primitives.mjs +3 -1
- package/src/runtime/shared/tool-surface.mjs +19 -3
- package/src/runtime/shared/update-checker.mjs +54 -10
- package/src/runtime/shared/webhooks-db.mjs +405 -0
- package/src/session-runtime/channel-config-api.mjs +13 -13
- package/src/session-runtime/lifecycle-api.mjs +14 -0
- package/src/session-runtime/runtime-core.mjs +39 -20
- package/src/standalone/agent-tool.mjs +42 -11
- package/src/standalone/channel-admin.mjs +173 -121
- package/src/standalone/channel-worker.mjs +2 -2
- package/src/standalone/memory-runtime-proxy.mjs +10 -5
- package/src/tui/App.jsx +32 -10
- package/src/tui/app/channel-pickers.mjs +9 -9
- package/src/tui/app/clipboard.mjs +14 -0
- package/src/tui/app/doctor.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +17 -7
- package/src/tui/app/settings-picker.mjs +2 -2
- package/src/tui/app/transcript-window.mjs +37 -1
- package/src/tui/app/use-mouse-input.mjs +19 -6
- package/src/tui/components/ToolExecution.jsx +12 -4
- package/src/tui/display-width.mjs +10 -4
- package/src/tui/dist/index.mjs +129 -55
- package/src/tui/engine/session-api-ext.mjs +12 -12
- package/src/tui/index.jsx +12 -2
- package/src/ui/statusline-segments.mjs +12 -1
- package/vendor/ink/build/display-width.js +5 -4
- 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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
//
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
@@ -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 (
|
|
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',
|
|
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 {
|