gm-skill 2.0.1303 → 2.0.1319

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.
@@ -381,7 +381,18 @@ function emitOrchestratorEvents(verb, taskBase, resultStr) {
381
381
  if (verb === 'prd-resolve' && errData && errData.deviation_kind === 'prd-resolve-unknown-id') {
382
382
  logEvent('hook', 'deviation.prd-resolve-unknown-id', { task: taskBase, prd_id: errData.prd_id, reason: errData.error });
383
383
  }
384
- logEvent('plugkit', 'orchestrator.error', { verb, task: taskBase, error: parsed && parsed.error ? String(parsed.error) : 'unknown' });
384
+ const reason = (parsed && (parsed.reason || parsed.error)) ||
385
+ (parsed && parsed.data && (parsed.data.reason || parsed.data.error)) ||
386
+ (errData && (errData.reason || errData.error)) ||
387
+ (parsed && parsed.stderr) ||
388
+ 'unknown';
389
+ logEvent('plugkit', 'orchestrator.error', {
390
+ verb,
391
+ task: taskBase,
392
+ error: String(reason).slice(0, 500),
393
+ gate_denied: !!(parsed && parsed.gate_denied),
394
+ next_dispatch: parsed && parsed.next_dispatch || null,
395
+ });
385
396
  return;
386
397
  }
387
398
  const data = parsed.data || {};
@@ -722,105 +733,133 @@ function findInstalledChromiumBinary() {
722
733
  }
723
734
  }
724
735
 
736
+ function fetchJsonSync(url, timeoutMs) {
737
+ const r = spawnSync(process.execPath, ['-e', `
738
+ const http = require('http');
739
+ const req = http.get(${JSON.stringify(url)}, (res) => {
740
+ let buf = '';
741
+ res.on('data', d => buf += d);
742
+ res.on('end', () => {
743
+ if (res.statusCode !== 200) { process.stderr.write('status ' + res.statusCode); process.exit(2); }
744
+ process.stdout.write(buf);
745
+ });
746
+ });
747
+ req.on('error', e => { process.stderr.write(e.message); process.exit(1); });
748
+ req.setTimeout(${timeoutMs || 1500}, () => { req.destroy(new Error('timeout')); });
749
+ `], { encoding: 'utf-8', timeout: (timeoutMs || 1500) + 1500, windowsHide: true });
750
+ if (r.status !== 0) return null;
751
+ try { return JSON.parse(r.stdout); } catch (_) { return null; }
752
+ }
753
+
725
754
  function startManagedBrowser(pw, profileDir) {
726
755
  const headless = process.env.GM_BROWSER_HEADLESS === '1';
727
- const args = [...pw.baseArgs, 'browser', 'start', '--user-data-dir', profileDir];
728
- if (headless) args.push('--headless');
729
- const env = { ...process.env };
730
- if (!env.GM_BROWSER_RUNNER_PATH && !env.PLAYWRITER_BROWSER_PATH) {
731
- const browserBin = findInstalledChromiumBinary();
732
- if (browserBin) {
733
- env.GM_BROWSER_RUNNER_PATH = browserBin;
734
- env.PLAYWRITER_BROWSER_PATH = browserBin;
735
- logEvent('plugkit', 'browser.binary-resolved', { path: browserBin });
736
- } else {
737
- logEvent('plugkit', 'browser.binary-missing', {});
738
- }
756
+ let browserBin = findInstalledChromiumBinary();
757
+ if (!browserBin) {
758
+ logEvent('plugkit', 'browser.chromium-installing', {});
759
+ spawnSync(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['--yes', 'playwright', 'install', 'chromium'], {
760
+ encoding: 'utf-8',
761
+ timeout: 300000,
762
+ windowsHide: true,
763
+ shell: process.platform === 'win32',
764
+ stdio: 'ignore',
765
+ });
766
+ browserBin = findInstalledChromiumBinary();
739
767
  }
740
- const child = spawn(pw.cmd, args, {
768
+ if (!browserBin) {
769
+ const err = new Error('chromium binary not found after install attempt');
770
+ logEvent('plugkit', 'browser.launch-failed', { reason: 'chromium-missing' });
771
+ throw err;
772
+ }
773
+ const port = findFreePortSync();
774
+ const args = [
775
+ '--user-data-dir=' + profileDir,
776
+ '--remote-debugging-port=' + port,
777
+ '--remote-debugging-address=127.0.0.1',
778
+ '--no-first-run',
779
+ '--no-default-browser-check',
780
+ '--disable-default-apps',
781
+ ];
782
+ if (headless) args.push('--headless=new');
783
+ const chromeLogPath = path.join(profileDir, '.chrome-launch.log');
784
+ let logFd;
785
+ try { logFd = fs.openSync(chromeLogPath, 'a'); } catch (_) { logFd = null; }
786
+ const child = spawn(browserBin, args, {
741
787
  detached: true,
742
- stdio: 'ignore',
743
- shell: pw.shell,
744
- windowsHide: true,
745
- env,
746
- ...(process.platform === 'win32' ? { creationFlags: 0x08000000 | 0x00000008 } : {}),
788
+ stdio: ['ignore', logFd != null ? logFd : 'ignore', logFd != null ? logFd : 'ignore'],
789
+ windowsHide: false,
790
+ env: process.env,
747
791
  });
792
+ try { if (typeof logFd === 'number') fs.closeSync(logFd); } catch (_) {}
748
793
  const pid = child.pid;
749
794
  child.unref();
750
- return pid;
795
+ logEvent('plugkit', 'browser.chromium-launched', { pid, port, profileDir, headless, binary: browserBin, chromeLogPath });
796
+ const start = Date.now();
797
+ const deadline = start + 30000;
798
+ let wsEndpoint = null;
799
+ let lastErr = null;
800
+ while (Date.now() < deadline) {
801
+ const info = fetchJsonSync(`http://127.0.0.1:${port}/json/version`, 1500);
802
+ if (info && info.webSocketDebuggerUrl) {
803
+ wsEndpoint = info.webSocketDebuggerUrl;
804
+ break;
805
+ }
806
+ sleepSync(500);
807
+ }
808
+ if (!wsEndpoint) {
809
+ logEvent('plugkit', 'browser.launch-failed', { reason: 'cdp-not-ready', pid, port, elapsed_ms: Date.now() - start });
810
+ throw new Error(`chromium launched (pid=${pid}) but CDP at 127.0.0.1:${port} did not become ready within 30s${lastErr ? ' :: ' + lastErr : ''}`);
811
+ }
812
+ logEvent('plugkit', 'browser.cdp-ready', { pid, port, ms: Date.now() - start, wsEndpoint });
813
+ return { pid, port, wsEndpoint };
751
814
  }
752
815
 
753
- function isColdRunProfile(profileDir) {
754
- try {
755
- if (!fs.existsSync(profileDir)) return true;
756
- const entries = fs.readdirSync(profileDir);
757
- if (entries.length === 0) return true;
758
- if (!entries.some(n => n === 'Default' || n === 'Local State')) return true;
759
- return false;
760
- } catch (_) {
761
- return true;
816
+ function killPidQuiet(pid) {
817
+ if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return false;
818
+ try { process.kill(pid, 'SIGTERM'); } catch (_) {}
819
+ if (process.platform === 'win32') {
820
+ try { spawnSync('taskkill', ['/F', '/T', '/PID', String(pid)], { stdio: 'ignore', timeout: 3000 }); } catch (_) {}
762
821
  }
822
+ return true;
763
823
  }
764
824
 
765
- function resolveManagedBrowserKey(pw) {
766
- const explicitKey = process.env.GM_BROWSER_RUNNER_KEY || process.env.PLAYWRITER_BROWSER_KEY;
767
- if (explicitKey) {
768
- if (/^profile:/.test(explicitKey)) {
769
- throw new Error(`GM_BROWSER_RUNNER_KEY=${explicitKey} points at a user OS browser profile; refusing — managed sessions must use install:Chromium:*`);
770
- }
771
- return explicitKey;
825
+ function purgeProfileLockFiles(profileDir) {
826
+ if (!profileDir) return;
827
+ for (const name of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
828
+ try { fs.unlinkSync(path.join(profileDir, name)); } catch (_) {}
772
829
  }
773
- let text = '';
774
- try {
775
- const r = runBrowserRunner(pw, ['browser', 'list'], 8000);
776
- text = (r && (r.stdout || r.stderr)) || '';
777
- } catch (e) {
778
- throw new Error(`managed browser session list failed: ${e.message}`);
779
- }
780
- const lines = text.split(/\r?\n/);
781
- const installChromium = lines.map(l => (l.match(/\b(install:Chromium:[A-Za-z0-9_-]+)\b/) || [])[1]).find(Boolean);
782
- if (installChromium) return installChromium;
783
- throw new Error(`no managed Chromium install detected; browser runner returned: ${scrubBrowserRunnerText(text).trim()}`);
784
830
  }
785
831
 
786
- function waitForExtensionReady(pw, profileDir, opts) {
787
- const cold = (opts && typeof opts.cold === 'boolean') ? opts.cold : isColdRunProfile(profileDir);
788
- const timeoutMs = (opts && opts.timeoutMs) || (cold ? 180000 : 30000);
789
- const start = Date.now();
790
- const deadline = start + timeoutMs;
791
- const backoff = [2000, 4000, 8000];
792
- let attempt = 0;
793
- let lastErr = '';
794
- let lastProgressAt = start;
795
- const browserKey = resolveManagedBrowserKey(pw);
796
- while (Date.now() < deadline) {
797
- const remaining = deadline - Date.now();
798
- const innerTimeout = Math.max(28000, Math.min(remaining, 30000));
799
- const r = runBrowserRunner(pw, ['session', 'new', '--browser', browserKey], innerTimeout);
800
- if (r && r.status === 0) return r;
801
- lastErr = scrubBrowserRunnerText((r && (r.stderr || r.stdout)) || '');
802
- const now = Date.now();
803
- if (now - lastProgressAt >= 10000) {
804
- logEvent('plugkit', 'browser.extension-wait', {
805
- elapsed_ms: now - start,
806
- cold_run: cold,
807
- profileDir,
808
- attempt,
809
- });
810
- lastProgressAt = now;
811
- }
812
- const sleepMs = backoff[Math.min(attempt, backoff.length - 1)];
813
- attempt++;
814
- if (Date.now() + sleepMs >= deadline) break;
815
- sleepSync(sleepMs);
832
+ function sleepSyncMs(ms) {
833
+ if (ms <= 0) return;
834
+ spawnSync(process.execPath, ['-e', `setTimeout(()=>process.exit(0),${ms})`], { timeout: ms + 500, windowsHide: true });
835
+ }
836
+
837
+ function gracefulCloseBrowser(entry, reason) {
838
+ if (!entry) return;
839
+ const { pid, port, profileDir } = entry;
840
+ if (Number.isFinite(port) && port > 0) {
841
+ try {
842
+ const info = fetchJsonSync(`http://127.0.0.1:${port}/json/version`, 600);
843
+ if (info && info.webSocketDebuggerUrl) {
844
+ spawnSync(process.execPath, ['-e', `
845
+ const http = require('http');
846
+ const req = http.request({host:'127.0.0.1',port:${port},path:'/json/close/browser',method:'GET',timeout:1500},
847
+ res => { res.resume(); res.on('end', () => process.exit(0)); });
848
+ req.on('error', () => process.exit(1));
849
+ req.on('timeout', () => { req.destroy(); process.exit(1); });
850
+ req.end();
851
+ `], { timeout: 3000, windowsHide: true });
852
+ }
853
+ } catch (_) {}
816
854
  }
817
- const flavor = cold
818
- ? `cold-run timeout after ${Math.round((Date.now() - start) / 1000)}s waiting for managed browser extension to connect (first run downloads chromium ~150MB and installs the extension; if this persists the extension never registered with the relay server)`
819
- : `warm-run timeout after ${Math.round((Date.now() - start) / 1000)}s waiting for managed browser extension to reconnect (profile exists but extension is not registering; relay server may be wedged)`;
820
- const err = new Error(`managed browser session start failed: ${flavor}${lastErr ? ` :: ${lastErr}` : ''}`);
821
- err._lastErr = lastErr;
822
- err._coldRun = cold;
823
- throw err;
855
+ if (Number.isFinite(pid) && pid > 0) {
856
+ const deadline = Date.now() + 1500;
857
+ try { process.kill(pid, 'SIGTERM'); } catch (_) {}
858
+ while (Date.now() < deadline && isProcessAliveSync(pid)) sleepSyncMs(Math.min(150, deadline - Date.now()));
859
+ if (isProcessAliveSync(pid)) killPidQuiet(pid);
860
+ }
861
+ purgeProfileLockFiles(profileDir);
862
+ try { logEvent('plugkit', 'browser.closed', { reason: reason || 'closed', pid, port, profileDir }); } catch (_) {}
824
863
  }
825
864
 
826
865
  function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
@@ -830,15 +869,29 @@ function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
830
869
  const ports = readJsonFile(portsFile, {});
831
870
  const sessions = readJsonFile(sessionsFile, {});
832
871
  const existing = ports[claudeSessionId];
833
- if (existing && existing.pid) {
872
+ if (existing && existing.pid && existing.wsEndpoint) {
834
873
  const wantProfile = path.join(cwd, '.gm', 'browser-profile');
835
874
  const pidOk = isProcessAliveSync(existing.pid);
836
875
  const profileOk = !existing.profileDir || existing.profileDir === wantProfile || existing.profileDir.startsWith(path.join(cwd, '.gm', 'browser-profile'));
837
- if (pidOk && profileOk) {
876
+ const cdpOk = pidOk && !!fetchJsonSync(`http://127.0.0.1:${existing.port}/json/version`, 1000);
877
+ if (pidOk && profileOk && cdpOk) {
838
878
  const pwIds = sessions[claudeSessionId] || [];
839
- if (pwIds.length > 0) return pwIds[0];
879
+ if (pwIds.length > 0 && existing.pwSessionId) return existing.pwSessionId;
880
+ const r = runBrowserRunner(pw, ['session', 'new', '--direct', existing.wsEndpoint], 30000);
881
+ if (r && r.status === 0) {
882
+ const sid = parseSessionId(r.stdout || '');
883
+ if (sid) {
884
+ existing.pwSessionId = sid;
885
+ ports[claudeSessionId] = existing;
886
+ sessions[claudeSessionId] = [sid];
887
+ writeJsonFile(portsFile, ports);
888
+ writeJsonFile(sessionsFile, sessions);
889
+ logEvent('plugkit', 'browser.attached', { pwSessionId: sid, reused: true });
890
+ return sid;
891
+ }
892
+ }
840
893
  } else {
841
- const reason = !pidOk ? 'pid-dead' : 'profile-drift';
894
+ const reason = !pidOk ? 'pid-dead' : (!cdpOk ? 'cdp-dead' : 'profile-drift');
842
895
  logEvent('hook', 'deviation.browser-profile-collision', {
843
896
  sid: claudeSessionId,
844
897
  stale_pid: existing.pid || null,
@@ -846,6 +899,12 @@ function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
846
899
  want_profile: wantProfile,
847
900
  reason,
848
901
  });
902
+ if (typeof gracefulCloseBrowser === 'function') {
903
+ try { gracefulCloseBrowser(existing, `collision:${reason}`); } catch (_) {}
904
+ } else if (pidOk && Number.isFinite(existing.pid)) {
905
+ try { killPidQuiet(existing.pid); } catch (_) {}
906
+ }
907
+ purgeProfileLockFiles(existing.profileDir);
849
908
  delete ports[claudeSessionId];
850
909
  delete sessions[claudeSessionId];
851
910
  try { writeJsonFile(portsFile, ports); } catch (_) {}
@@ -853,32 +912,58 @@ function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
853
912
  }
854
913
  }
855
914
  cleanDeadProfileFragments(cwd);
856
- const probedProfile = path.join(cwd, '.gm', 'browser-profile');
857
- const coldRun = isColdRunProfile(probedProfile);
858
915
  const profileDir = acquireProfileDir(cwd);
859
- logEvent('plugkit', 'browser.start', { profileDir, cold_run: coldRun });
860
- const browserPid = startManagedBrowser(pw, profileDir);
861
- const newR = waitForExtensionReady(pw, profileDir, { cold: coldRun });
862
- const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
863
- const out = stripAnsi(newR.stdout || '').trim();
864
- let pwSessionId = null;
865
- const created = out.match(/Session\s+(\S+)\s+created/i);
866
- if (created) pwSessionId = created[1];
867
- if (!pwSessionId) {
868
- const hex = out.match(/\b([a-f0-9-]{8,})\b/i);
869
- if (hex) pwSessionId = hex[1];
916
+ const aliveCdpForProfile = (() => {
917
+ for (const key of Object.keys(ports)) {
918
+ const ent = ports[key];
919
+ if (!ent || !ent.pid || !ent.port || !ent.wsEndpoint) continue;
920
+ if (ent.profileDir !== profileDir && !(ent.profileDir || '').startsWith(profileDir)) continue;
921
+ if (!isProcessAliveSync(ent.pid)) continue;
922
+ const info = fetchJsonSync(`http://127.0.0.1:${ent.port}/json/version`, 1000);
923
+ if (info && info.webSocketDebuggerUrl) {
924
+ return { pid: ent.pid, port: ent.port, wsEndpoint: ent.wsEndpoint };
925
+ }
926
+ }
927
+ return null;
928
+ })();
929
+ let browserPid, port, wsEndpoint;
930
+ if (aliveCdpForProfile) {
931
+ ({ pid: browserPid, port, wsEndpoint } = aliveCdpForProfile);
932
+ logEvent('plugkit', 'browser.reused-existing-chromium', { pid: browserPid, port, profileDir });
933
+ } else {
934
+ logEvent('plugkit', 'browser.start', { profileDir });
935
+ ({ pid: browserPid, port, wsEndpoint } = startManagedBrowser(pw, profileDir));
870
936
  }
937
+ const r = runBrowserRunner(pw, ['session', 'new', '--direct', wsEndpoint], 30000);
938
+ if (!r || r.status !== 0) {
939
+ const errTxt = scrubBrowserRunnerText((r && (r.stderr || r.stdout)) || 'unknown');
940
+ logEvent('plugkit', 'browser.launch-failed', { reason: 'session-attach-failed', pid: browserPid, port, error: errTxt });
941
+ throw new Error(`playwriter session new --direct failed: ${errTxt}`);
942
+ }
943
+ const pwSessionId = parseSessionId(r.stdout || '');
871
944
  if (!pwSessionId) {
872
- try { const j = JSON.parse(out); pwSessionId = j.id || j.session_id || j.session; } catch (_) {}
945
+ logEvent('plugkit', 'browser.launch-failed', { reason: 'session-id-unparseable', stdout: r.stdout });
946
+ throw new Error(`could not parse managed browser session id from: ${scrubBrowserRunnerText(r.stdout || '')}`);
873
947
  }
874
- if (!pwSessionId) throw new Error(`could not parse managed browser session id from: ${scrubBrowserRunnerText(out)}`);
875
- ports[claudeSessionId] = { profileDir, pid: browserPid };
948
+ ports[claudeSessionId] = { profileDir, pid: browserPid, port, wsEndpoint, pwSessionId };
876
949
  sessions[claudeSessionId] = [pwSessionId];
877
950
  writeJsonFile(portsFile, ports);
878
951
  writeJsonFile(sessionsFile, sessions);
952
+ logEvent('plugkit', 'browser.attached', { pwSessionId, pid: browserPid, port });
879
953
  return pwSessionId;
880
954
  }
881
955
 
956
+ function parseSessionId(rawOut) {
957
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
958
+ const out = stripAnsi(rawOut || '').trim();
959
+ const created = out.match(/Session\s+(\S+)\s+created/i);
960
+ if (created) return created[1];
961
+ const hex = out.match(/\b([a-f0-9-]{8,})\b/i);
962
+ if (hex) return hex[1];
963
+ try { const j = JSON.parse(out); return j.id || j.session_id || j.session || null; } catch (_) {}
964
+ return null;
965
+ }
966
+
882
967
  const VEC_K_DEFAULT = 10;
883
968
  const EMBED_MODEL_DEFAULT = process.env.EMBED_MODEL || 'mistral/mistral-embed';
884
969
  const INFERENCE_MODEL_DEFAULT = process.env.INFERENCE_MODEL || 'groq/llama-3.3-70b-versatile';
@@ -1048,6 +1133,32 @@ function kvNamespaceDirs(ns) {
1048
1133
  return out;
1049
1134
  }
1050
1135
 
1136
+ function enabledDisciplineNamespaces(baseNs) {
1137
+ const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
1138
+ const set = new Set([baseNs]);
1139
+ try {
1140
+ const enabledPath = path.join(projectRoot, '.gm', 'disciplines', 'enabled.txt');
1141
+ if (fs.existsSync(enabledPath)) {
1142
+ const lines = fs.readFileSync(enabledPath, 'utf-8').split(/\r?\n/);
1143
+ for (const ln of lines) {
1144
+ const name = ln.trim();
1145
+ if (name && !name.startsWith('#')) set.add(name);
1146
+ }
1147
+ }
1148
+ } catch (_) {}
1149
+ return Array.from(set);
1150
+ }
1151
+
1152
+ function jaccardOverlap(a, b) {
1153
+ if (!a || !b) return 0;
1154
+ const tokenize = (s) => new Set(String(s).toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length >= 3));
1155
+ const A = tokenize(a), B = tokenize(b);
1156
+ if (A.size === 0 || B.size === 0) return 0;
1157
+ let inter = 0;
1158
+ for (const t of A) if (B.has(t)) inter++;
1159
+ return inter / (A.size + B.size - inter);
1160
+ }
1161
+
1051
1162
  const __tasks = new Map();
1052
1163
 
1053
1164
  function tasksDir(cwd) {
@@ -1379,8 +1490,8 @@ function makeHostFunctions(instanceRef) {
1379
1490
  if (!raw) return writeWasmJson(instanceRef.value, []);
1380
1491
  let parsedQ;
1381
1492
  try { parsedQ = JSON.parse(raw); } catch (_) { parsedQ = { query: raw }; }
1382
- const q = parsedQ.query || raw;
1383
1493
  const namespace = parsedQ.namespace || 'default';
1494
+ const sigil = parsedQ.sigil || parsedQ.discipline_sigil || null;
1384
1495
  const extractVec = (e) => {
1385
1496
  if (Array.isArray(e)) return e;
1386
1497
  if (Array.isArray(e?.data?.[0]?.embedding)) return e.data[0].embedding;
@@ -1393,37 +1504,58 @@ function makeHostFunctions(instanceRef) {
1393
1504
  if (process.env.PLUGKIT_DEBUG) console.error('[plugkit-wasm] host_vec_search: no embedding in query, raw=', raw.slice(0, 200));
1394
1505
  return writeWasmJson(instanceRef.value, []);
1395
1506
  }
1396
- const vecDirs = kvNamespaceDirs(`${namespace}-vec`);
1397
- const dataDirs = kvNamespaceDirs(namespace);
1398
- if (vecDirs.length === 0 || dataDirs.length === 0) {
1399
- return writeWasmJson(instanceRef.value, []);
1400
- }
1507
+ const namespaces = sigil ? [namespace] : enabledDisciplineNamespaces(namespace);
1508
+ const HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000;
1509
+ const DEDUP_JACCARD = 0.7;
1510
+ const RECENCY_FLOOR = 0.4;
1511
+ const nowMs = Date.now();
1401
1512
  const scored = [];
1402
1513
  const seen = new Set();
1403
- for (const vecDir of vecDirs) {
1404
- for (const f of fs.readdirSync(vecDir)) {
1405
- if (!f.endsWith('.json')) continue;
1406
- const key = f.replace(/\.json$/, '');
1407
- if (seen.has(key)) continue;
1408
- seen.add(key);
1409
- let emb;
1410
- try { emb = JSON.parse(fs.readFileSync(path.join(vecDir, f), 'utf-8')); }
1411
- catch (_) { continue; }
1412
- const vector = Array.isArray(emb?.data?.[0]?.embedding) ? emb.data[0].embedding
1413
- : Array.isArray(emb?.embedding) ? emb.embedding
1414
- : Array.isArray(emb) ? emb : null;
1415
- if (!vector) continue;
1416
- const score = cosineSim(queryEmbedding, vector);
1417
- let text = '';
1418
- for (const dataDir of dataDirs) {
1419
- const valuePath = path.join(dataDir, `${key}.json`);
1420
- if (fs.existsSync(valuePath)) { text = fs.readFileSync(valuePath, 'utf-8'); break; }
1514
+ for (const ns of namespaces) {
1515
+ const vecDirs = kvNamespaceDirs(`${ns}-vec`);
1516
+ const dataDirs = kvNamespaceDirs(ns);
1517
+ if (vecDirs.length === 0 || dataDirs.length === 0) continue;
1518
+ for (const vecDir of vecDirs) {
1519
+ for (const f of fs.readdirSync(vecDir)) {
1520
+ if (!f.endsWith('.json')) continue;
1521
+ const key = f.replace(/\.json$/, '');
1522
+ const seenKey = `${ns}::${key}`;
1523
+ if (seen.has(seenKey)) continue;
1524
+ seen.add(seenKey);
1525
+ const vecPath = path.join(vecDir, f);
1526
+ let emb, mtimeMs;
1527
+ try {
1528
+ emb = JSON.parse(fs.readFileSync(vecPath, 'utf-8'));
1529
+ mtimeMs = fs.statSync(vecPath).mtimeMs;
1530
+ } catch (_) { continue; }
1531
+ const vector = Array.isArray(emb?.data?.[0]?.embedding) ? emb.data[0].embedding
1532
+ : Array.isArray(emb?.embedding) ? emb.embedding
1533
+ : Array.isArray(emb) ? emb : null;
1534
+ if (!vector) continue;
1535
+ const cos = cosineSim(queryEmbedding, vector);
1536
+ const ageMs = Math.max(0, nowMs - mtimeMs);
1537
+ const recency = RECENCY_FLOOR + (1 - RECENCY_FLOOR) * Math.exp(-ageMs / HALF_LIFE_MS);
1538
+ const score = cos * recency;
1539
+ let text = '';
1540
+ for (const dataDir of dataDirs) {
1541
+ const valuePath = path.join(dataDir, `${key}.json`);
1542
+ if (fs.existsSync(valuePath)) { text = fs.readFileSync(valuePath, 'utf-8'); break; }
1543
+ }
1544
+ scored.push({ key, text, score, cos, recency, namespace: ns });
1421
1545
  }
1422
- scored.push({ key, text, score });
1423
1546
  }
1424
1547
  }
1425
1548
  scored.sort((a, b) => b.score - a.score);
1426
- return writeWasmJson(instanceRef.value, scored.slice(0, k_));
1549
+ const out = [];
1550
+ for (const hit of scored) {
1551
+ let dup = false;
1552
+ for (const kept of out) {
1553
+ if (jaccardOverlap(hit.text, kept.text) >= DEDUP_JACCARD) { dup = true; break; }
1554
+ }
1555
+ if (!dup) out.push(hit);
1556
+ if (out.length >= k_) break;
1557
+ }
1558
+ return writeWasmJson(instanceRef.value, out);
1427
1559
  } catch (e) {
1428
1560
  console.error('[plugkit-wasm] host_vec_search error:', e.message);
1429
1561
  return writeWasmJson(instanceRef.value, []);
@@ -1483,6 +1615,15 @@ function makeHostFunctions(instanceRef) {
1483
1615
  const prefix = level >= 3 ? '[plugkit-wasm:err]' : level >= 2 ? '[plugkit-wasm:warn]' : '[plugkit-wasm]';
1484
1616
  if (level >= 2) console.error(`${prefix} ${msg}`);
1485
1617
  else console.log(`${prefix} ${msg}`);
1618
+ const evtMatch = msg.match(/^evt:\s*(\{.*\})\s*$/);
1619
+ if (evtMatch) {
1620
+ try {
1621
+ const ev = JSON.parse(evtMatch[1]);
1622
+ const eventName = ev.event || 'wasm.event';
1623
+ const { event: _e, ts: _ts, sess: _s, sub: _sub, ...fields } = ev;
1624
+ logEvent(ev.sub || 'plugkit', eventName, fields);
1625
+ } catch (_) {}
1626
+ }
1486
1627
  return 0;
1487
1628
  } catch (e) {
1488
1629
  return 0;
@@ -1519,6 +1660,7 @@ function makeHostFunctions(instanceRef) {
1519
1660
  });
1520
1661
  }
1521
1662
  const pwSessionId = getOrCreateBrowserSession(cwd, sessionId, pw);
1663
+ try { lastBrowserActivityMs = Date.now(); } catch (_) {}
1522
1664
  const r = runBrowserRunner(pw, ['-s', pwSessionId, '--timeout', '14000', '-e', body], 60000);
1523
1665
  return writeWasmJson(instanceRef.value, {
1524
1666
  ok: r.status === 0,
@@ -1822,14 +1964,7 @@ async function runSpoolWatcher(instance, spoolDir) {
1822
1964
  lastActivityMs = Date.now();
1823
1965
  }
1824
1966
 
1825
- function killPidQuiet(pid) {
1826
- if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return false;
1827
- try { process.kill(pid, 'SIGTERM'); } catch (_) {}
1828
- if (process.platform === 'win32') {
1829
- try { spawnSync('taskkill', ['/F', '/T', '/PID', String(pid)], { stdio: 'ignore', timeout: 3000 }); } catch (_) {}
1830
- }
1831
- return true;
1832
- }
1967
+ /* killPidQuiet, purgeProfileLockFiles, gracefulCloseBrowser are module-scope (defined above spool()). */
1833
1968
 
1834
1969
  function teardownAll(reason) {
1835
1970
  try {
@@ -1844,7 +1979,7 @@ async function runSpoolWatcher(instance, spoolDir) {
1844
1979
  const sessionsFile = browserSessionsFile(process.cwd());
1845
1980
  const ports = readJsonFile(portsFile, {});
1846
1981
  for (const [sid, entry] of Object.entries(ports)) {
1847
- if (entry && Number.isFinite(entry.pid)) killPidQuiet(entry.pid);
1982
+ gracefulCloseBrowser(entry, `teardown:${reason}`);
1848
1983
  }
1849
1984
  try { fs.unlinkSync(portsFile); } catch (_) {}
1850
1985
  try { fs.unlinkSync(sessionsFile); } catch (_) {}
@@ -1857,6 +1992,7 @@ async function runSpoolWatcher(instance, spoolDir) {
1857
1992
  pid: process.pid,
1858
1993
  idle_ms: Date.now() - lastActivityMs,
1859
1994
  }));
1995
+ __shutdownReasonWritten = true;
1860
1996
  } catch (_) {}
1861
1997
 
1862
1998
  try { fs.unlinkSync(STATUS_PATH_FOR_TEARDOWN); } catch (_) {}
@@ -1936,6 +2072,32 @@ async function runSpoolWatcher(instance, spoolDir) {
1936
2072
  }
1937
2073
  }, 60_000);
1938
2074
 
2075
+ const BROWSER_IDLE_LIMIT_MS = parseInt(process.env.PLUGKIT_BROWSER_IDLE_LIMIT_MS, 10) || 10 * 60 * 1000;
2076
+ let lastBrowserActivityMs = Date.now();
2077
+ setInterval(() => {
2078
+ try {
2079
+ const browserIdleMs = Date.now() - lastBrowserActivityMs;
2080
+ if (browserIdleMs < BROWSER_IDLE_LIMIT_MS) return;
2081
+ const portsFile = browserPortsFile(process.cwd());
2082
+ const sessionsFile = browserSessionsFile(process.cwd());
2083
+ const ports = readJsonFile(portsFile, {});
2084
+ let closed = 0;
2085
+ for (const [sid, entry] of Object.entries(ports)) {
2086
+ if (entry && Number.isFinite(entry.pid) && isProcessAliveSync(entry.pid)) {
2087
+ try { gracefulCloseBrowser(entry, 'browser-idle'); closed++; } catch (_) {}
2088
+ }
2089
+ }
2090
+ if (closed > 0) {
2091
+ try { fs.unlinkSync(portsFile); } catch (_) {}
2092
+ try { fs.unlinkSync(sessionsFile); } catch (_) {}
2093
+ logEvent('plugkit', 'browser.idle-closed', { count: closed, idle_ms: browserIdleMs });
2094
+ }
2095
+ lastBrowserActivityMs = Date.now();
2096
+ } catch (e) {
2097
+ console.error(`[browser-idle] error: ${e.message}`);
2098
+ }
2099
+ }, 60_000);
2100
+
1939
2101
  setInterval(() => {
1940
2102
  try {
1941
2103
  const idleMs = Date.now() - lastActivityMs;
@@ -1961,20 +2123,48 @@ async function runSpoolWatcher(instance, spoolDir) {
1961
2123
  }
1962
2124
  }, IDLE_CHECK_MS);
1963
2125
 
1964
- function handleSignalShutdown(sig) {
2126
+ const SHUTDOWN_REQUEST_PATH = path.join(spoolDir, '.shutdown-requested');
2127
+ setInterval(() => {
1965
2128
  try {
1966
- fs.writeFileSync(SHUTDOWN_REASON_PATH, JSON.stringify({
1967
- reason: sig.toLowerCase(),
1968
- ts: Date.now(),
1969
- pid: process.pid,
1970
- }));
1971
- } catch (_) {}
1972
- try { clearBootActive(); } catch (_) {}
1973
- try { releaseLock(); } catch (_) {}
1974
- process.exit(0);
2129
+ if (!fs.existsSync(SHUTDOWN_REQUEST_PATH)) return;
2130
+ let reqReason = 'shutdown-requested';
2131
+ try {
2132
+ const raw = fs.readFileSync(SHUTDOWN_REQUEST_PATH, 'utf-8').trim();
2133
+ if (raw) {
2134
+ try { const j = JSON.parse(raw); if (j && j.reason) reqReason = String(j.reason); }
2135
+ catch (_) { reqReason = raw.slice(0, 64); }
2136
+ }
2137
+ } catch (_) {}
2138
+ try { fs.unlinkSync(SHUTDOWN_REQUEST_PATH); } catch (_) {}
2139
+ handleSignalShutdown(reqReason.toUpperCase());
2140
+ } catch (e) {
2141
+ console.error(`[shutdown-request] error: ${e.message}`);
2142
+ }
2143
+ }, 2000);
2144
+
2145
+ let _signalShutdownInFlight = false;
2146
+ function handleSignalShutdown(sig) {
2147
+ if (_signalShutdownInFlight) return;
2148
+ _signalShutdownInFlight = true;
2149
+ try { teardownAll(sig.toLowerCase()); } catch (_) {
2150
+ try {
2151
+ fs.writeFileSync(SHUTDOWN_REASON_PATH, JSON.stringify({
2152
+ reason: sig.toLowerCase(),
2153
+ ts: Date.now(),
2154
+ pid: process.pid,
2155
+ teardown_failed: true,
2156
+ }));
2157
+ __shutdownReasonWritten = true;
2158
+ } catch (__) {}
2159
+ try { clearBootActive(); } catch (__) {}
2160
+ try { releaseLock(); } catch (__) {}
2161
+ process.exit(0);
2162
+ }
1975
2163
  }
1976
2164
  process.on('SIGINT', () => handleSignalShutdown('SIGINT'));
1977
2165
  process.on('SIGTERM', () => handleSignalShutdown('SIGTERM'));
2166
+ process.on('SIGBREAK', () => handleSignalShutdown('SIGBREAK'));
2167
+ process.on('SIGHUP', () => handleSignalShutdown('SIGHUP'));
1978
2168
  process.on('exit', () => { try { clearBootActive(); } catch (_) {} releaseLock(); });
1979
2169
 
1980
2170
  try {
@@ -2012,8 +2202,9 @@ async function runSpoolWatcher(instance, spoolDir) {
2012
2202
  prior_status: _priorStatus,
2013
2203
  prior_status_age_ms: _priorStatus && Number.isFinite(_priorStatus.ts) ? Date.now() - _priorStatus.ts : null,
2014
2204
  };
2015
- const _PLANNED_REASONS = new Set(['idle', 'sigterm', 'version-change', 'wrapper-change', 'peer-stale-takeover', 'version-drift', 'external-planned']);
2016
- const _isPlannedBoot = _priorShutdown && _PLANNED_REASONS.has(_priorShutdown.reason);
2205
+ const _UNPLANNED_REASONS = new Set(['uncaughtexception', 'unhandledrejection', 'wasm-abort', 'wasm-abort-graceful']);
2206
+ const _normalizedShutdownReason = _priorShutdown && _priorShutdown.reason ? String(_priorShutdown.reason).toLowerCase() : null;
2207
+ const _isPlannedBoot = !!_normalizedShutdownReason && !_UNPLANNED_REASONS.has(_normalizedShutdownReason);
2017
2208
  const _isFirstBoot = !_priorShutdown && !_priorStatus;
2018
2209
  const UNPLANNED_RESTART_MARKER = path.join(spoolDir, '.unplanned-restart.json');
2019
2210
  const HEARTBEAT_RECENT_MS = 60_000;
@@ -2054,7 +2245,11 @@ async function runSpoolWatcher(instance, spoolDir) {
2054
2245
  history,
2055
2246
  }, null, 2));
2056
2247
  } catch (_) {}
2057
- console.error(`[plugkit-wasm] UNPLANNED RESTART detected — prior watcher died without writing .shutdown-reason.json. prior_status_age_ms=${restartContext.prior_status_age_ms} boot_reason=${_bootReason}`);
2248
+ if (_isPlannedBoot) {
2249
+ console.log(`[plugkit-wasm] planned restart: prior reason="${_priorShutdown.reason}" boot_reason=${_bootReason}`);
2250
+ } else {
2251
+ console.error(`[plugkit-wasm] UNPLANNED RESTART detected — prior watcher died without writing .shutdown-reason.json. prior_status_age_ms=${restartContext.prior_status_age_ms} boot_reason=${_bootReason}`);
2252
+ }
2058
2253
  }
2059
2254
  try { fs.unlinkSync(SHUTDOWN_REASON_PATH); } catch (_) {}
2060
2255
  logEvent('plugkit', 'watcher.boot', { version: _bootVersion, in_dir: inDir, out_dir: outDir, spool_dir: spoolDir, ...restartContext });