@ijfw/memory-server 1.3.0 → 1.4.1

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 (68) hide show
  1. package/README.md +67 -0
  2. package/fixtures/team/book.json +47 -0
  3. package/fixtures/team/business.json +47 -0
  4. package/fixtures/team/content.json +47 -0
  5. package/fixtures/team/design.json +47 -0
  6. package/fixtures/team/mixed.json +59 -0
  7. package/fixtures/team/research.json +47 -0
  8. package/fixtures/team/software.json +47 -0
  9. package/package.json +1 -9
  10. package/src/.registry-meta-key.pem +3 -0
  11. package/src/active-extension-writer.js +142 -0
  12. package/src/blackboard.js +360 -0
  13. package/src/cli-run.js +91 -0
  14. package/src/codex-agents.js +177 -0
  15. package/src/compute/extract.js +3 -0
  16. package/src/compute/fts5.js +4 -4
  17. package/src/compute/graph-lock.js +0 -2
  18. package/src/compute/migrations/003-tier-semantic.js +3 -3
  19. package/src/compute/runner.js +44 -15
  20. package/src/compute/schema.sql +1 -1
  21. package/src/cross-orchestrator-cli.js +974 -13
  22. package/src/cross-orchestrator.js +9 -1
  23. package/src/dashboard-client.html +353 -1
  24. package/src/dashboard-server.js +318 -2
  25. package/src/design-intelligence.js +721 -0
  26. package/src/dispatch/colon-syntax.js +31 -3
  27. package/src/dispatch/domain-manifest.js +251 -0
  28. package/src/dispatch/extension.js +637 -0
  29. package/src/dispatch/override.js +221 -0
  30. package/src/dispatch-planner.js +1 -0
  31. package/src/dream/runner.mjs +3 -3
  32. package/src/extension-installer.js +1269 -0
  33. package/src/extension-manifest-schema.js +301 -0
  34. package/src/extension-permission-check.mjs +79 -0
  35. package/src/extension-registry.js +619 -0
  36. package/src/extension-signer.js +905 -0
  37. package/src/gate-result-formatter.js +95 -0
  38. package/src/gate-result-schema.js +274 -0
  39. package/src/gate-result.js +195 -0
  40. package/src/intent-router.js +2 -0
  41. package/src/lib/npm-view.js +1 -0
  42. package/src/memory/fts5.js +3 -3
  43. package/src/memory/migrations/002-tier-semantic.js +2 -2
  44. package/src/memory/staleness.js +1 -1
  45. package/src/memory/tier-promotion.js +6 -6
  46. package/src/memory/tokenize.js +1 -1
  47. package/src/memory-feedback.js +372 -0
  48. package/src/override-manifest-schema.js +146 -0
  49. package/src/override-resolver.js +699 -0
  50. package/src/override-use-registry.js +307 -0
  51. package/src/overrides/presets/academic.md +101 -0
  52. package/src/overrides/presets/book.md +87 -0
  53. package/src/overrides/presets/campaign.md +95 -0
  54. package/src/overrides/presets/screenplay.md +99 -0
  55. package/src/recovery/checkpoint.js +191 -0
  56. package/src/redactor.js +2 -0
  57. package/src/runtime-mediator.js +207 -0
  58. package/src/sandbox.js +17 -3
  59. package/src/server.js +94 -2
  60. package/src/swarm/dispatch-prompt.js +154 -0
  61. package/src/swarm/planner.js +399 -0
  62. package/src/swarm/review.js +136 -0
  63. package/src/swarm/worktree.js +239 -0
  64. package/src/team/generator.js +119 -0
  65. package/src/team/schemas.js +341 -0
  66. package/src/trident/dispatch.js +47 -0
  67. package/src/update-check.js +1 -1
  68. package/src/vectors.js +7 -8
@@ -20,6 +20,8 @@ import { computeValueDelivered } from './cost/savings.js';
20
20
  import { listMemoryFiles, listKnownProjects } from './memory/reader.js';
21
21
  import { searchMemory } from './memory/search.js';
22
22
  import { buildRecallCounts, mergeRecallCounts, topRecalled } from './memory/recall-counter.js';
23
+ import { PLACEHOLDER_HTML } from './design-companion.js';
24
+ import { listExtensions } from './extension-installer.js';
23
25
 
24
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
25
27
  // REPO_ROOT: IJFW_PROJECT_ROOT override > user's interactive shell cwd (PWD) > process.cwd() fallback.
@@ -132,6 +134,24 @@ const PORT_WALK_MAX = 10; // walk up to 37891+PORT_WALK_MAX (37900)
132
134
  const BACKFILL_DEFAULT = 200;
133
135
  const BACKFILL_CAP = 50; // max observations sent on fresh connect (W4.6)
134
136
 
137
+ const DESIGN_LIVE_RELOAD_SCRIPT = `
138
+ <script>
139
+ (function(){
140
+ if (window.__ijfwDesignLiveReload) return;
141
+ window.__ijfwDesignLiveReload = true;
142
+ try {
143
+ var events = new EventSource('/design/stream');
144
+ events.addEventListener('reload', function(){ window.location.reload(); });
145
+ } catch (_) {}
146
+ })();
147
+ </script>`;
148
+
149
+ function injectDesignLiveReload(html) {
150
+ if (typeof html !== 'string' || html.includes('__ijfwDesignLiveReload')) return html;
151
+ if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, DESIGN_LIVE_RELOAD_SCRIPT + '\n</body>');
152
+ return html + DESIGN_LIVE_RELOAD_SCRIPT;
153
+ }
154
+
135
155
  // ---------- integer param validator ----------
136
156
  // Rejects numeric-prefix garbage like "10xyz" that parseInt accepts (W9-M2).
137
157
  // Returns null on invalid input; caller should respond with 400.
@@ -668,6 +688,278 @@ export async function startServer(options = {}) {
668
688
  res.end(JSON.stringify(config));
669
689
  }],
670
690
 
691
+ // ---------- extensions: installed (B9) ----------
692
+ // Enumerate ~/.ijfw/state-org/extension-registry.json,
693
+ // ~/.ijfw/state-user/extension-registry.json, and project-scope registry.
694
+ // Returns JSON list with name, scope, version, publisher_keyId, permissions,
695
+ // last_activated_time. Path-traversal defence: resolve + assert under HOME.
696
+ ['/api/extensions/installed', async (req, res) => {
697
+ try {
698
+ const home = homedir();
699
+ // realpath both sides — on macOS /var/folders -> /private/var/folders is a symlink,
700
+ // so the registry's realpathed path won't show as under un-realpathed HOME.
701
+ let homeCanon;
702
+ try { homeCanon = realpathSync(home); } catch { homeCanon = home; }
703
+ function isUnderHome(p) {
704
+ try {
705
+ const canon = realpathSync(p);
706
+ const rel = relative(homeCanon, canon);
707
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
708
+ } catch { return false; }
709
+ }
710
+
711
+ const REGISTRY_FILENAME = 'extension-registry.json';
712
+ const registryPaths = [
713
+ { scope: 'org', path: join(home, '.ijfw', 'state-org', REGISTRY_FILENAME) },
714
+ { scope: 'user', path: join(home, '.ijfw', 'state-user', REGISTRY_FILENAME) },
715
+ { scope: 'project', path: join(REPO_ROOT, '.ijfw', 'state', REGISTRY_FILENAME) },
716
+ ];
717
+
718
+ const seen = new Map();
719
+ for (const { scope, path: regPath } of registryPaths) {
720
+ // Path-traversal check on each registry path.
721
+ const resolvedReg = resolve(regPath);
722
+ const underHome = isUnderHome(resolvedReg) ||
723
+ resolvedReg.startsWith(resolve(REPO_ROOT));
724
+ if (!underHome) continue;
725
+ if (!existsSync(resolvedReg)) continue;
726
+ let registry;
727
+ try {
728
+ const raw = readFileSync(resolvedReg, 'utf8');
729
+ const parsed = JSON.parse(raw);
730
+ registry = Array.isArray(parsed.extensions) ? parsed.extensions : [];
731
+ } catch { continue; }
732
+
733
+ for (const e of registry) {
734
+ if (!e || !e.name || !e.version) continue;
735
+ const key = `${e.name}@${e.version}`;
736
+ if (seen.has(key)) continue;
737
+ const manifest = (e.manifest && typeof e.manifest === 'object') ? e.manifest : null;
738
+ seen.set(key, {
739
+ name: e.name,
740
+ scope,
741
+ version: e.version,
742
+ publisher_keyId: manifest ? (manifest.publisher_keyId || null) : null,
743
+ permissions: manifest ? (manifest.permissions || null) : null,
744
+ last_activated_time: e.last_activated_time || null,
745
+ });
746
+ }
747
+ }
748
+ res.writeHead(200, { 'Content-Type': 'application/json' });
749
+ res.end(JSON.stringify({ extensions: Array.from(seen.values()) }));
750
+ } catch (err) {
751
+ res.writeHead(200, { 'Content-Type': 'application/json' });
752
+ res.end(JSON.stringify({ extensions: [], error: err.message }));
753
+ }
754
+ }],
755
+
756
+ // ---------- extensions: active (B9) ----------
757
+ ['/api/extensions/active', async (req, res) => {
758
+ try {
759
+ const home = homedir();
760
+ const activePath = join(home, '.ijfw', 'state', 'active-extension.json');
761
+ const resolvedActive = resolve(activePath);
762
+ // Return {active:null} BEFORE realpath — realpathSync throws on non-existent paths.
763
+ if (!existsSync(resolvedActive)) {
764
+ res.writeHead(200, { 'Content-Type': 'application/json' });
765
+ res.end(JSON.stringify({ active: null }));
766
+ return;
767
+ }
768
+ // Path-traversal: realpath BOTH home and target so macOS /private symlinks
769
+ // (e.g. /var/folders -> /private/var/folders) resolve correctly AND symlinks
770
+ // pointing outside HOME are rejected.
771
+ let homeCanon;
772
+ try { homeCanon = realpathSync(home); } catch { homeCanon = home; }
773
+ let activeCanon;
774
+ try {
775
+ activeCanon = realpathSync(resolvedActive);
776
+ } catch {
777
+ res.writeHead(403, { 'Content-Type': 'application/json' });
778
+ res.end(JSON.stringify({ error: 'path traversal rejected' }));
779
+ return;
780
+ }
781
+ const rel = relative(homeCanon, activeCanon);
782
+ if (rel.startsWith('..') || isAbsolute(rel)) {
783
+ res.writeHead(403, { 'Content-Type': 'application/json' });
784
+ res.end(JSON.stringify({ error: 'path traversal rejected' }));
785
+ return;
786
+ }
787
+ const raw = readFileSync(activeCanon, 'utf8');
788
+ const parsed = JSON.parse(raw);
789
+ res.writeHead(200, { 'Content-Type': 'application/json' });
790
+ res.end(JSON.stringify({ active: parsed }));
791
+ } catch (err) {
792
+ res.writeHead(200, { 'Content-Type': 'application/json' });
793
+ res.end(JSON.stringify({ active: null, error: err.message }));
794
+ }
795
+ }],
796
+
797
+ // ---------- extensions: events (B9) ----------
798
+ // Tail-streams ~/.ijfw/state/permission-events.jsonl with optional filters.
799
+ // Allowlisted query params: limit, extension, tool, denied.
800
+ // SSE mode: Accept: text/event-stream. JSON array otherwise.
801
+ // Never reads full file into memory: streams line-by-line.
802
+ ['/api/extensions/events', async (req, res, url) => {
803
+ const ALLOWED_FILTER_KEYS = new Set(['limit', 'extension', 'tool', 'denied']);
804
+ // Reject any non-allowlisted filter key.
805
+ for (const key of url.searchParams.keys()) {
806
+ if (!ALLOWED_FILTER_KEYS.has(key)) {
807
+ res.writeHead(400, { 'Content-Type': 'application/json' });
808
+ res.end(JSON.stringify({ error: `unknown filter parameter: ${key}` }));
809
+ return;
810
+ }
811
+ }
812
+
813
+ const home = homedir();
814
+ const eventsPath = join(home, '.ijfw', 'state', 'permission-events.jsonl');
815
+
816
+ const rawLimit = url.searchParams.get('limit');
817
+ let limit = 200;
818
+ if (rawLimit !== null) {
819
+ const n = safeIntegerParam(rawLimit, 10_000);
820
+ if (n === null) {
821
+ res.writeHead(400, { 'Content-Type': 'application/json' });
822
+ res.end(JSON.stringify({ error: 'invalid limit' }));
823
+ return;
824
+ }
825
+ limit = n;
826
+ }
827
+ const filterExtension = url.searchParams.get('extension') || null;
828
+ const filterTool = url.searchParams.get('tool') || null;
829
+ const filterDenied = url.searchParams.has('denied')
830
+ ? (url.searchParams.get('denied') !== 'false')
831
+ : null;
832
+
833
+ // Stream-tail: read only the last TAIL_CHUNK bytes, never slurp the full file.
834
+ // For permission-events.jsonl which rotates at 10_000 lines, 2MB covers
835
+ // many thousands of events without loading the entire file into memory.
836
+ const TAIL_CHUNK = 2 * 1024 * 1024; // 2MB
837
+ function tailEvents() {
838
+ if (!existsSync(eventsPath)) return [];
839
+ let st;
840
+ try { st = statSync(eventsPath); } catch { return []; }
841
+ if (st.size === 0) return [];
842
+ let lines = [];
843
+ try {
844
+ const fullBuf = readFileSync(eventsPath);
845
+ const slice = fullBuf.subarray(Math.max(0, fullBuf.length - TAIL_CHUNK));
846
+ const text = slice.toString('utf8');
847
+ lines = text.split('\n').filter(Boolean);
848
+ // If we sliced mid-line, the first element may be truncated — drop it.
849
+ if (fullBuf.length > TAIL_CHUNK) lines = lines.slice(1);
850
+ } catch { return []; }
851
+
852
+ const results = [];
853
+ for (let i = lines.length - 1; i >= 0 && results.length < limit; i--) {
854
+ let obj;
855
+ try { obj = JSON.parse(lines[i]); } catch { continue; }
856
+ if (filterExtension !== null && obj.extension !== filterExtension) continue;
857
+ if (filterTool !== null && obj.tool !== filterTool) continue;
858
+ if (filterDenied !== null && Boolean(!obj.allowed) !== filterDenied) continue;
859
+ results.unshift(obj);
860
+ }
861
+ return results;
862
+ }
863
+
864
+ const isSSE = (req.headers['accept'] || '').includes('text/event-stream');
865
+ if (isSSE) {
866
+ res.writeHead(200, {
867
+ 'Content-Type': 'text/event-stream',
868
+ 'Cache-Control': 'no-cache',
869
+ 'Connection': 'keep-alive',
870
+ 'X-Accel-Buffering': 'no',
871
+ });
872
+ res.write(': connected\n\n');
873
+ // Send current tail as initial batch.
874
+ const initial = tailEvents();
875
+ for (const evt of initial) {
876
+ try { res.write(`data: ${JSON.stringify(evt)}\n\n`); } catch { break; }
877
+ }
878
+ // Watch for new events. lastLineCount must match what the watcher
879
+ // measures (tail-chunk lines), NOT the limited initial batch length —
880
+ // mismatching them causes a replay storm when the file is bigger than
881
+ // the limit.
882
+ let evtWatcher = null;
883
+ let lastLineCount = 0;
884
+ try {
885
+ const buf0 = readFileSync(eventsPath);
886
+ const slice0 = buf0.subarray(Math.max(0, buf0.length - TAIL_CHUNK));
887
+ let lines0 = slice0.toString('utf8').split('\n').filter(Boolean);
888
+ if (buf0.length > TAIL_CHUNK) lines0 = lines0.slice(1);
889
+ lastLineCount = lines0.length;
890
+ } catch { /* eventsPath missing or unreadable — start from 0 */ }
891
+ try {
892
+ evtWatcher = watch(existsSync(eventsPath) ? eventsPath : join(home, '.ijfw', 'state'), () => {
893
+ if (!existsSync(eventsPath)) return;
894
+ try {
895
+ // Use the tail-chunk reader (bounded read) rather than slurping the
896
+ // full file. At 10K lines × ~1-2KB each = 10-20MB sync read per watch
897
+ // event, which is unacceptable for a long-lived SSE connection.
898
+ try { statSync(eventsPath); } catch { return; }
899
+ const buf = (() => {
900
+ try { return readFileSync(eventsPath); } catch { return null; }
901
+ })();
902
+ if (!buf) return;
903
+ const slice = buf.subarray(Math.max(0, buf.length - TAIL_CHUNK));
904
+ const text = slice.toString('utf8');
905
+ let lines = text.split('\n').filter(Boolean);
906
+ if (buf.length > TAIL_CHUNK) lines = lines.slice(1);
907
+ if (lines.length > lastLineCount) {
908
+ const newLines = lines.slice(lastLineCount);
909
+ lastLineCount = lines.length;
910
+ for (const line of newLines) {
911
+ let obj; try { obj = JSON.parse(line); } catch { continue; }
912
+ if (filterExtension !== null && obj.extension !== filterExtension) continue;
913
+ if (filterTool !== null && obj.tool !== filterTool) continue;
914
+ if (filterDenied !== null && Boolean(!obj.allowed) !== filterDenied) continue;
915
+ try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {}
916
+ }
917
+ }
918
+ } catch {}
919
+ });
920
+ if (evtWatcher) evtWatcher.on('error', () => {});
921
+ } catch {}
922
+ req.on('close', () => {
923
+ if (evtWatcher) { try { evtWatcher.close(); } catch {} }
924
+ });
925
+ return;
926
+ }
927
+
928
+ // JSON array response.
929
+ const events = tailEvents();
930
+ res.writeHead(200, { 'Content-Type': 'application/json' });
931
+ res.end(JSON.stringify(events));
932
+ }],
933
+
934
+ // ---------- extensions health (W3/t15) ----------
935
+ // Reads .ijfw/state/extension-registry.json (project) plus org/user via
936
+ // listExtensions(). Missing or malformed registry yields {extensions: []}
937
+ // (day-1 protection). ENOENT and JSON.parse errors are swallowed inside
938
+ // readRegistry(); this handler adds an outer try/catch as a final safety
939
+ // net so a malformed registry can never crash the dashboard.
940
+ ['/api/extensions/health', async (req, res) => {
941
+ try {
942
+ const extensions = await listExtensions(REPO_ROOT);
943
+ // Strip embedded manifest blobs -- the dashboard only needs the
944
+ // status summary fields, not the full manifest.
945
+ const out = (Array.isArray(extensions) ? extensions : []).map((e) => ({
946
+ name: e.name,
947
+ version: e.version,
948
+ scope: e.scope,
949
+ installed_at: e.installed_at || null,
950
+ status: e.status || 'stale',
951
+ last_trident_verdict: e.last_trident_verdict ?? null,
952
+ }));
953
+ res.writeHead(200, { 'Content-Type': 'application/json' });
954
+ res.end(JSON.stringify({ extensions: out }));
955
+ } catch (err) {
956
+ // ENOENT / missing / malformed -> 200 with empty list (day-1).
957
+ process.stderr.write(`[ijfw-mcp] /api/extensions/health: ${err && err.message ? err.message : err}\n`);
958
+ res.writeHead(200, { 'Content-Type': 'application/json' });
959
+ res.end(JSON.stringify({ extensions: [], error: 'malformed registry' }));
960
+ }
961
+ }],
962
+
671
963
  ['/api/value-delivered', (req, res, url) => {
672
964
  try {
673
965
  const platform = url.searchParams.get('platform') || 'claude';
@@ -699,6 +991,29 @@ export async function startServer(options = {}) {
699
991
  }],
700
992
 
701
993
  // ---------- design companion ----------
994
+ [/^\/design\/files\/[^/]+\.html$/, async (req, res, url) => {
995
+ const contentDir = join(homedir(), '.ijfw', 'design-companion', 'content');
996
+ mkdirSync(contentDir, { recursive: true });
997
+ const name = url.pathname.split('/').pop();
998
+ const filePath = join(contentDir, name);
999
+ let html = null;
1000
+ try {
1001
+ if (existsSync(filePath)) html = readFileSync(filePath, 'utf8');
1002
+ } catch {}
1003
+ if (!html) {
1004
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
1005
+ res.end('404 Not Found');
1006
+ return;
1007
+ }
1008
+ html = injectDesignLiveReload(html);
1009
+ res.writeHead(200, {
1010
+ 'Content-Type': 'text/html; charset=utf-8',
1011
+ 'Cache-Control': 'no-store',
1012
+ 'Content-Security-Policy': "default-src 'self' https: data:; style-src 'self' 'unsafe-inline' https:; script-src 'self' 'unsafe-inline' https:; img-src 'self' data: https:; font-src 'self' data: https:; connect-src 'self'",
1013
+ });
1014
+ res.end(html);
1015
+ }],
1016
+
702
1017
  ['/design', async (req, res) => {
703
1018
  const contentDir = join(homedir(), '.ijfw', 'design-companion', 'content');
704
1019
  mkdirSync(contentDir, { recursive: true });
@@ -714,12 +1029,13 @@ export async function startServer(options = {}) {
714
1029
  }
715
1030
  } catch {}
716
1031
  if (!html) {
717
- html = `<!doctype html><html><body><pre>Design companion active. Push a design with: ijfw design push file.html</pre></body></html>`;
1032
+ html = PLACEHOLDER_HTML;
718
1033
  }
1034
+ html = injectDesignLiveReload(html);
719
1035
  res.writeHead(200, {
720
1036
  'Content-Type': 'text/html; charset=utf-8',
721
1037
  'Cache-Control': 'no-store',
722
- 'Content-Security-Policy': "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'",
1038
+ 'Content-Security-Policy': "default-src 'self' https: data:; style-src 'self' 'unsafe-inline' https:; script-src 'self' 'unsafe-inline' https:; img-src 'self' data: https:; font-src 'self' data: https:; connect-src 'self'",
723
1039
  });
724
1040
  res.end(html);
725
1041
  }],