prism-mcp-server 20.2.6 → 20.2.7

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.
@@ -7,11 +7,14 @@
7
7
  * This closes the gap where users have to learn tool syntax to query memories.
8
8
  * Instead, they ask plain English questions and get structured results.
9
9
  *
10
- * Pipeline:
10
+ * Parsing pipeline:
11
11
  * 1. Intent classification (decision, todo, file, general)
12
12
  * 2. Key phrase extraction
13
- * 3. Multi-strategy search (keyword + semantic)
14
- * 4. Answer synthesis (optional, uses local LLM)
13
+ * 3. Structured memory-query construction
14
+ *
15
+ * Execution, web fallback, and grounded prism_infer synthesis live in
16
+ * queryMemoryNaturalHandler.ts. Keeping network/model work out of this parser
17
+ * makes intent handling deterministic and independently testable.
15
18
  */
16
19
  import { debugLog } from "./logger.js";
17
20
  // ─── Intent Classification ───────────────────────────────────
@@ -151,34 +154,4 @@ export function parseNLQuery(query, project) {
151
154
  suggestedArgs: args,
152
155
  };
153
156
  }
154
- /**
155
- * Execute a natural language query end-to-end.
156
- *
157
- * Parses the query, executes the appropriate tool, and optionally
158
- * synthesizes an answer using the local LLM.
159
- */
160
- export async function executeNLQuery(query, project, synthesize = false) {
161
- const parsed = parseNLQuery(query, project);
162
- // Execute the search
163
- let sources = [];
164
- let answer = "";
165
- try {
166
- // Return the parsed query structure — the caller (handler) will
167
- // execute the actual tool call using the suggestedTool and suggestedArgs.
168
- answer =
169
- `Intent: ${parsed.intent} (confidence: ${(parsed.confidence * 100).toFixed(0)}%)\n` +
170
- `Keywords: ${parsed.extractedKeywords.join(", ")}\n` +
171
- `Suggested tool: ${parsed.suggestedTool}\n` +
172
- `Search query: "${parsed.searchQuery}"`;
173
- }
174
- catch (err) {
175
- debugLog(`NL query execution failed: ${err}`);
176
- answer = `Unable to parse query: ${err}`;
177
- }
178
- return {
179
- ...parsed,
180
- answer: answer || "No relevant memories found.",
181
- sources,
182
- };
183
- }
184
157
  debugLog("v12.2: Natural language query module loaded");
@@ -54,7 +54,11 @@ export function passesQualityGate(stripped, thinkOnly, finishReason, mode) {
54
54
  .replace(/^#{1,6}\s+.*$/gm, "")
55
55
  .replace(/^[\s*-]*\*{1,2}[^*]+\*{1,2}:?\s*$/gm, "");
56
56
  const proseSentences = proseOnly.split(/[.!?\n]+/).map(s => s.trim()).filter(s => s.length > 10);
57
- if (proseSentences.length >= 6) {
57
+ // Plain source code often repeats structural lines across methods. In code
58
+ // mode those are not prose loops, and splitting on "." corrupts member
59
+ // access (`node = self.root` becomes `node = self`). Pass B below still
60
+ // catches egregious repetition at the higher threshold.
61
+ if (mode !== "code" && proseSentences.length >= 6) {
58
62
  const counts = new Map();
59
63
  for (const s of proseSentences) {
60
64
  const key = s.toLowerCase();
@@ -68,7 +72,17 @@ export function passesQualityGate(stripped, thinkOnly, finishReason, mode) {
68
72
  // inside fake code blocks or other structural elements. Higher
69
73
  // threshold avoids false positives on legitimate code patterns
70
74
  // (e.g. `node = self.root` × 4 is fine, × 5 is suspicious).
71
- const allSentences = stripped.split(/[.!?\n]+/).map(s => s.trim()).filter(s => s.length > 10);
75
+ // In code mode, periods are member-access operators, not sentence
76
+ // boundaries. Splitting `input.length` at "." made several different
77
+ // loops share the fragment `while (i < input` and falsely counted it as
78
+ // repeated output. Full-line matching still catches genuinely duplicated
79
+ // source while preserving valid repeated control-flow shapes.
80
+ const fullTextUnits = mode === "code"
81
+ ? stripped.split(/\n+/)
82
+ : stripped.split(/[.!?\n]+/);
83
+ const allSentences = fullTextUnits
84
+ .map(s => s.trim())
85
+ .filter(s => s.length > 10);
72
86
  if (allSentences.length >= 10) {
73
87
  const counts = new Map();
74
88
  for (const s of allSentences) {
@@ -69,7 +69,7 @@ async function portalPost(path, body, timeoutMs = 15_000) {
69
69
  * the shape of performWebSearch() in braveApi.ts.
70
70
  */
71
71
  export async function synaluxWebSearch(query, count = 10) {
72
- debugLog(`[synaluxSearch] web search: q="${query}", limit=${count}`);
72
+ debugLog(`[synaluxSearch] web search: query_chars=${query.length}, limit=${count}`);
73
73
  const data = await portalPost("/api/v1/prism/search", {
74
74
  query,
75
75
  limit: Math.min(count, 20),
@@ -92,7 +92,7 @@ export async function synaluxWebSearch(query, count = 10) {
92
92
  * Used by code-mode handlers that pass raw data to the QuickJS sandbox.
93
93
  */
94
94
  export async function synaluxWebSearchRaw(query, count = 10) {
95
- debugLog(`[synaluxSearch] web search raw: q="${query}", limit=${count}`);
95
+ debugLog(`[synaluxSearch] web search raw: query_chars=${query.length}, limit=${count}`);
96
96
  const data = await portalPost("/api/v1/prism/search", {
97
97
  query,
98
98
  limit: Math.min(count, 20),
@@ -118,7 +118,7 @@ export async function synaluxWebSearchRaw(query, count = 10) {
118
118
  * Returns formatted text matching performLocalSearch() shape.
119
119
  */
120
120
  export async function synaluxLocalSearch(query, count = 5) {
121
- debugLog(`[synaluxSearch] local search: q="${query}", count=${count}`);
121
+ debugLog(`[synaluxSearch] local search: query_chars=${query.length}, count=${count}`);
122
122
  const data = await portalPost("/api/v1/prism/local-search", {
123
123
  query,
124
124
  count: Math.min(count, 20),
@@ -136,7 +136,7 @@ export async function synaluxLocalSearch(query, count = 5) {
136
136
  * Local/POI search raw — returns JSON string for code-mode sandbox.
137
137
  */
138
138
  export async function synaluxLocalSearchRaw(query, count = 5) {
139
- debugLog(`[synaluxSearch] local search raw: q="${query}", count=${count}`);
139
+ debugLog(`[synaluxSearch] local search raw: query_chars=${query.length}, count=${count}`);
140
140
  const data = await portalPost("/api/v1/prism/local-search", {
141
141
  query,
142
142
  count: Math.min(count, 20),
@@ -162,7 +162,7 @@ export async function synaluxLocalSearchRaw(query, count = 5) {
162
162
  * AI-grounded answers via Synalux portal.
163
163
  */
164
164
  export async function synaluxBraveAnswers(query, model) {
165
- debugLog(`[synaluxSearch] answers: q="${query}", model=${model || "default"}`);
165
+ debugLog(`[synaluxSearch] answers: query_chars=${query.length}, model=${model || "default"}`);
166
166
  const body = { query };
167
167
  if (model)
168
168
  body.model = model;
@@ -179,7 +179,7 @@ export async function synaluxBraveAnswers(query, model) {
179
179
  * Scrape a URL via Synalux portal. Returns the extracted content string.
180
180
  */
181
181
  export async function synaluxScrape(url, options) {
182
- debugLog(`[synaluxSearch] scrape: url="${url}"`);
182
+ debugLog(`[synaluxSearch] scrape: url_chars=${url.length}`);
183
183
  const body = { url };
184
184
  if (options?.formats)
185
185
  body.formats = options.formats;
@@ -187,9 +187,13 @@ export async function synaluxScrape(url, options) {
187
187
  body.onlyMainContent = options.onlyMainContent;
188
188
  if (options?.waitFor !== undefined)
189
189
  body.waitFor = options.waitFor;
190
- const data = await portalPost("/api/v1/prism/scrape", body);
190
+ const data = await portalPost("/api/v1/prism/scrape", body, options?.timeoutMs);
191
191
  if (data.status === "error") {
192
192
  throw new Error(`[synaluxSearch] scrape error: ${data.error || "unknown"}`);
193
193
  }
194
- return data.content || "";
194
+ return data.content ||
195
+ data.data?.markdown ||
196
+ data.data?.html ||
197
+ data.data?.rawHtml ||
198
+ "";
195
199
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.2.6",
3
+ "version": "20.2.7",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Prism Coder — Cognitive memory + tool-calling intelligence for AI agents. Mind Palace persistent memory (BFCL Gold Certified, 100% Tool-Call Accuracy, 114 Agent Skills, PHI Guard, Tier Enforcement, Prompt-Based Skill Routing, Zero-Search HDC/HRR retrieval, HRR Semantic Drift Detection across BCBA/Coding/AAC domains, HIPAA-hardened local or subscription-gated Synalux storage, SLERP-optimized GRPO alignment) plus the prism-coder 1.7B–32B open-weights LLM fleet.",
6
6
  "module": "index.ts",
@@ -154,10 +154,11 @@
154
154
  "jsdom": "^29.0.1",
155
155
  "openai": "^6.32.0",
156
156
  "p-limit": "^7.3.0",
157
- "postcss": "8.5.12",
157
+ "postcss": "8.5.23",
158
158
  "quickjs-emscripten": "^0.32.0",
159
159
  "smol-toml": "^1.7.0",
160
160
  "stream-json": "^2.0.0",
161
+ "tldts": "^7.0.27",
161
162
  "turndown": "^7.2.2",
162
163
  "zod": "^4.3.6"
163
164
  }
@@ -1,46 +0,0 @@
1
- /**
2
- * boundaries.ts unit tests
3
- *
4
- * Verifies structural invariants of the safety declaration:
5
- * - BOUNDARIES_VERSION is present and non-empty
6
- * - BOUNDARIES_TEXT covers safety-critical concepts
7
- * - Architecture/routing docs moved to server instructions (not per-call)
8
- */
9
- import { describe, it, expect } from "vitest";
10
- import { BOUNDARIES_VERSION, BOUNDARIES_TEXT } from "../../boundaries/boundaries.js";
11
- describe("BOUNDARIES_VERSION", () => {
12
- it("is a non-empty string", () => {
13
- expect(typeof BOUNDARIES_VERSION).toBe("string");
14
- expect(BOUNDARIES_VERSION.length).toBeGreaterThan(0);
15
- });
16
- });
17
- describe("BOUNDARIES_TEXT structure", () => {
18
- it("is a non-empty string under 500 chars (compressed from ~2000)", () => {
19
- expect(typeof BOUNDARIES_TEXT).toBe("string");
20
- expect(BOUNDARIES_TEXT.length).toBeGreaterThan(50);
21
- expect(BOUNDARIES_TEXT.length).toBeLessThan(600);
22
- });
23
- it("has no leading or trailing whitespace (trim() was applied)", () => {
24
- expect(BOUNDARIES_TEXT).toBe(BOUNDARIES_TEXT.trim());
25
- });
26
- it("references BCBA reserved categories", () => {
27
- expect(BOUNDARIES_TEXT).toMatch(/BCBA reserved/i);
28
- });
29
- it("references fail-closed refusal for reserved content", () => {
30
- expect(BOUNDARIES_TEXT).toMatch(/refused/i);
31
- });
32
- it("states AAC access is never restricted as a consequence", () => {
33
- expect(BOUNDARIES_TEXT).toMatch(/AAC access is never restricted/i);
34
- });
35
- it("references restraint as reserved", () => {
36
- expect(BOUNDARIES_TEXT).toMatch(/restraint/i);
37
- });
38
- it("references crisis/self-harm interception", () => {
39
- expect(BOUNDARIES_TEXT).toMatch(/crisis|self-harm/i);
40
- });
41
- it("does NOT contain architecture/routing docs (those moved to server instructions)", () => {
42
- expect(BOUNDARIES_TEXT).not.toContain("session_save_ledger");
43
- expect(BOUNDARIES_TEXT).not.toContain("inference routing");
44
- expect(BOUNDARIES_TEXT).not.toMatch(/host note/i);
45
- });
46
- });
@@ -1,134 +0,0 @@
1
- /**
2
- * sessionContext.ts unit tests
3
- *
4
- * Covers: markContextLoaded, requireContextLoaded, noteInferenceForSession,
5
- * getSessionState, TTL eviction, and fail-closed behaviour for unknown sessions.
6
- *
7
- * The module uses in-process Map state. Each test imports a fresh module instance
8
- * via vi.resetModules() + dynamic re-import so tests are isolated without needing
9
- * an exported reset function.
10
- */
11
- import { describe, it, expect, beforeEach, vi } from "vitest";
12
- import { BOUNDARIES_VERSION } from "../../boundaries/boundaries.js";
13
- // Reset module registry before each test so the in-memory Map starts empty.
14
- let markContextLoaded;
15
- let requireContextLoaded;
16
- let noteInferenceForSession;
17
- let getSessionState;
18
- beforeEach(async () => {
19
- vi.resetModules();
20
- const mod = await import("../../session/sessionContext.js");
21
- markContextLoaded = mod.markContextLoaded;
22
- requireContextLoaded = mod.requireContextLoaded;
23
- noteInferenceForSession = mod.noteInferenceForSession;
24
- getSessionState = mod.getSessionState;
25
- });
26
- describe("requireContextLoaded — fail-closed defaults", () => {
27
- it("blocks an unknown conversation (never seen)", () => {
28
- const result = requireContextLoaded("never-seen-id");
29
- expect(result).not.toBeNull();
30
- expect(result.blocked).toBe(true);
31
- if (result && result.blocked)
32
- expect(result.error).toContain("context_not_loaded");
33
- });
34
- it("allows (returns null) when conversation_id is undefined — gate is opt-in", () => {
35
- // Callers without a conversation_id (auto-push hosts, resource readers,
36
- // legacy clients) are not gated — they use the session-agnostic interface.
37
- const result = requireContextLoaded(undefined);
38
- expect(result).toBeNull();
39
- });
40
- it("blocks (hard) when conversation_id is empty string — empty string is not opt-in bypass", () => {
41
- // "" is not the same as undefined. An empty string means the caller explicitly
42
- // provided a conversation_id but it's invalid. The gate should block, not bypass.
43
- const result = requireContextLoaded("");
44
- expect(result).not.toBeNull();
45
- expect(result.blocked).toBe(true);
46
- if (result && result.blocked)
47
- expect(result.error).toContain("context_not_loaded");
48
- });
49
- it("blocks a session by unknown id even if noteInference was called for it", () => {
50
- // noteInferenceForSession no longer creates stubs, so an unregistered id
51
- // is still unknown to the gate.
52
- noteInferenceForSession("conv-telemetry-only", { backend: "local", usedCloud: false });
53
- const result = requireContextLoaded("conv-telemetry-only");
54
- expect(result).not.toBeNull();
55
- expect(result.blocked).toBe(true);
56
- });
57
- });
58
- describe("markContextLoaded → requireContextLoaded lifecycle", () => {
59
- it("returns null (pass) after markContextLoaded is called", () => {
60
- markContextLoaded("conv-abc", "project-x", BOUNDARIES_VERSION);
61
- expect(requireContextLoaded("conv-abc")).toBeNull();
62
- });
63
- it("records project and boundariesVersion on the session", () => {
64
- markContextLoaded("conv-meta", "my-project", "42");
65
- const state = getSessionState("conv-meta");
66
- expect(state).not.toBeNull();
67
- expect(state.project).toBe("my-project");
68
- expect(state.boundariesVersion).toBe("42");
69
- expect(state.contextLoaded).toBe(true);
70
- });
71
- it("is idempotent — calling twice does not break state", () => {
72
- // Use the actual BOUNDARIES_VERSION so no drift warning fires.
73
- markContextLoaded("conv-idem", "proj", BOUNDARIES_VERSION);
74
- markContextLoaded("conv-idem", "proj-updated", BOUNDARIES_VERSION);
75
- const state = getSessionState("conv-idem");
76
- expect(state.project).toBe("proj-updated");
77
- expect(state.boundariesVersion).toBe(BOUNDARIES_VERSION);
78
- expect(requireContextLoaded("conv-idem")).toBeNull();
79
- });
80
- it("isolates sessions — loading one does not unblock another", () => {
81
- markContextLoaded("conv-A", "proj", BOUNDARIES_VERSION);
82
- expect(requireContextLoaded("conv-A")).toBeNull();
83
- expect(requireContextLoaded("conv-B")).not.toBeNull();
84
- });
85
- });
86
- describe("noteInferenceForSession", () => {
87
- it("increments inferenceCalls on every call", () => {
88
- markContextLoaded("conv-inf", "proj", BOUNDARIES_VERSION);
89
- noteInferenceForSession("conv-inf", { backend: "local", usedCloud: false });
90
- noteInferenceForSession("conv-inf", { backend: "local", usedCloud: false });
91
- const state = getSessionState("conv-inf");
92
- expect(state.inferenceCalls).toBe(2);
93
- });
94
- it("increments usedCloudCalls only for cloud calls", () => {
95
- markContextLoaded("conv-cloud", "proj", BOUNDARIES_VERSION);
96
- noteInferenceForSession("conv-cloud", { backend: "cloud", usedCloud: true });
97
- noteInferenceForSession("conv-cloud", { backend: "local", usedCloud: false });
98
- const state = getSessionState("conv-cloud");
99
- expect(state.inferenceCalls).toBe(2);
100
- expect(state.usedCloudCalls).toBe(1);
101
- });
102
- it("does NOT create a ghost stub for an unregistered session — only updates existing sessions", () => {
103
- // noteInferenceForSession used to call getOrInit, creating stub entries
104
- // with contextLoaded=false for every conversation_id that infers.
105
- // Ghost stubs accumulate in the LRU and crowd out real sessions.
106
- // The fix: no-op when the session doesn't exist yet.
107
- noteInferenceForSession("conv-new-via-note", { backend: "local", usedCloud: false });
108
- expect(getSessionState("conv-new-via-note")).toBeNull();
109
- });
110
- });
111
- describe("getSessionState", () => {
112
- it("returns null for an unknown session", () => {
113
- expect(getSessionState("does-not-exist")).toBeNull();
114
- });
115
- it("returns the current state object for a known session", () => {
116
- markContextLoaded("conv-get", "proj", BOUNDARIES_VERSION);
117
- const state = getSessionState("conv-get");
118
- expect(state).not.toBeNull();
119
- expect(state.contextLoaded).toBe(true);
120
- });
121
- });
122
- describe("lastSeen update", () => {
123
- it("updates lastSeen on every requireContextLoaded call", async () => {
124
- markContextLoaded("conv-ts", "proj", BOUNDARIES_VERSION);
125
- const before = getSessionState("conv-ts").lastSeen;
126
- // Advance time by mocking Date.now via vi.useFakeTimers
127
- vi.useFakeTimers();
128
- vi.advanceTimersByTime(5000);
129
- requireContextLoaded("conv-ts");
130
- const after = getSessionState("conv-ts").lastSeen;
131
- vi.useRealTimers();
132
- expect(after).toBeGreaterThanOrEqual(before);
133
- });
134
- });