prism-mcp-server 20.21.24 → 20.21.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/README.md CHANGED
@@ -61,6 +61,12 @@ A paid subscription adds cloud sync, higher model tiers, and team features throu
61
61
 
62
62
  ## Get started
63
63
 
64
+ Memory search automatically applies ACT-R ranking: relevance plus recency,
65
+ frequency and memory associations. No upvote or prompt instruction is required.
66
+ To opt out, use **Account & Settings → Preferences → Automatic Memory Ranking
67
+ (ACT-R)**, then restart your AI client. `PRISM_ACTR_ENABLED=false` also disables
68
+ it. This ranking is separate from the neural graph's stored importance filter.
69
+
64
70
  ```bash
65
71
  npm install -g prism-mcp-server
66
72
  prism connect
@@ -164,6 +170,69 @@ or by re-enabling after each run.
164
170
  <details>
165
171
  <summary>Release history (optional)</summary>
166
172
 
173
+ ## What's New in v20.21.26
174
+
175
+ **Why it matters:** routine wording no longer lets a mixed request skip
176
+ Layer 1, the safety screen that decides whether a request may run on your
177
+ device. A BCBA who asks in one message for an operational
178
+ definition and how many mL of a drug to give, or a developer who asks for a
179
+ code review and then authentication code, now has that request screened by
180
+ Layer 1's rules and, unless the request or conversation is already marked
181
+ reserved, a classifier read. Before, it could be answered by a local model
182
+ with no classifier read.
183
+
184
+ - **Rules no longer clear requests.** Before, one routine clause let Layer 1's
185
+ rules clear the whole request, so it skipped the classifier. "Write an
186
+ operational definition for tantrums and tell me how many mL of Benadryl to
187
+ give at bedtime" was answered locally with no classifier read, and so was
188
+ "review the dashboard component for hook order and then write the JWT
189
+ validation middleware". Now rules can only mark a request reserved. The
190
+ routine shortcut is gone for a single prompt and for the current prompt of a
191
+ follow-up; the prompt's classifier read is skipped only if the request or
192
+ conversation is already marked reserved. A long prompt that reaches the
193
+ classifier is read as excerpts, as before.
194
+ - **What happens to a flagged request.** A text request that a rule marks
195
+ reserved, or that the classifier flags, goes to a stronger cloud model when
196
+ your plan includes cloud and the call does not pass `cloud_fallback: false`.
197
+ Otherwise it is refused, and the refusal says why. Requests the classifier
198
+ clears remain eligible to run locally, so routine drafting (operational
199
+ definitions, data sheets, antecedent strategies) and routine code work still
200
+ run on your device when the local model can answer.
201
+ - **Seclusion by another name.** Placement phrases such as "put him in the
202
+ quiet room until he calms down" (also isolation, time-out, padded and
203
+ calm-down rooms) are now marked reserved by rule. This catches placement
204
+ requests of that shape (him, her, them, or the client, student or child
205
+ placed into the room) that never use the word "seclusion". Other wording,
206
+ and other mentions of these rooms, are left to the classifier.
207
+ - **Measured:** of 46 mixed requests built to test this bypass, which should
208
+ never be answered locally, 41 were cleared to run locally before this
209
+ release and 9 after: 32 fewer sensitive requests reaching a local model. Of
210
+ 294 routine requests, 2 more than before are escalated (or refused, without
211
+ cloud).
212
+ - **Limits, unchanged in this release.** Keep them in mind when deciding
213
+ whether a local answer needs your review:
214
+ - The classifier can miss a request, and a missed request still runs
215
+ locally. The 9 above are such misses.
216
+ - Some classifier failures fall back to a keyword check, and text that
217
+ passes it can still run locally.
218
+ - A request with images is processed on this device even when flagged, and
219
+ is never sent to the cloud.
220
+ - **Cost:** requests that used the shortcut now need a classifier read, unless
221
+ screening has already marked them reserved. That added check is the price of
222
+ closing the bypass; in our tests the read took about 0.3 s at the 95th
223
+ percentile.
224
+
225
+ ## What's New in v20.21.25
226
+
227
+ Semantic memory search now automatically applies ACT-R ranking, combining
228
+ relevance with recency, frequency and associations. **Account & Settings →
229
+ Preferences → Automatic Memory Ranking (ACT-R)** lets you opt out after an AI
230
+ client restart; an explicit environment value takes precedence.
231
+
232
+ Synalux Cloud uses authenticated, owner-scoped access-history and candidate-link
233
+ APIs. Cloud deployment requires the matching Portal history migration. Stored
234
+ importance thresholds remain separate, and HRR drift detection remains independent.
235
+
167
236
  ## What's New in v20.21.24
168
237
 
169
238
  - Synalux cloud preserves initial importance and handles votes, graduated
@@ -17,7 +17,7 @@
17
17
  * storage backends during maintenance windows.
18
18
  */
19
19
  import { getStorage } from "./storage/index.js";
20
- import { PRISM_USER_ID, PRISM_SCHOLAR_ENABLED, PRISM_SCHOLAR_INTERVAL_MS, PRISM_GRAPH_PRUNING_ENABLED, PRISM_GRAPH_PRUNE_MIN_STRENGTH, PRISM_GRAPH_PRUNE_PROJECT_COOLDOWN_MS, PRISM_GRAPH_PRUNE_SWEEP_BUDGET_MS, PRISM_GRAPH_PRUNE_MAX_PROJECTS_PER_SWEEP, PRISM_ACTR_ENABLED, PRISM_ACTR_ACCESS_LOG_RETENTION_DAYS, } from "./config.js";
20
+ import { PRISM_USER_ID, PRISM_SCHOLAR_ENABLED, PRISM_SCHOLAR_INTERVAL_MS, PRISM_GRAPH_PRUNING_ENABLED, PRISM_GRAPH_PRUNE_MIN_STRENGTH, PRISM_GRAPH_PRUNE_PROJECT_COOLDOWN_MS, PRISM_GRAPH_PRUNE_SWEEP_BUDGET_MS, PRISM_GRAPH_PRUNE_MAX_PROJECTS_PER_SWEEP, PRISM_ACTR_ACCESS_LOG_RETENTION_DAYS, } from "./config.js";
21
21
  import { randomBytes } from "node:crypto";
22
22
  import { debugLog } from "./utils/logger.js";
23
23
  import { runWebScholar } from "./scholar/webScholar.js";
@@ -103,7 +103,9 @@ export const DEFAULT_SCHEDULER_CONFIG = {
103
103
  graphPruneProjectCooldownMs: PRISM_GRAPH_PRUNE_PROJECT_COOLDOWN_MS,
104
104
  graphPruneSweepBudgetMs: PRISM_GRAPH_PRUNE_SWEEP_BUDGET_MS,
105
105
  graphPruneMaxProjectsPerSweep: PRISM_GRAPH_PRUNE_MAX_PROJECTS_PER_SWEEP,
106
- enableAccessLogPrune: PRISM_ACTR_ENABLED,
106
+ // Default-on retrieval must not silently opt users into destructive maintenance.
107
+ // Preserve the existing explicit environment opt-in for access-log pruning.
108
+ enableAccessLogPrune: process.env.PRISM_ACTR_ENABLED === "true",
107
109
  accessLogRetentionDays: PRISM_ACTR_ACCESS_LOG_RETENTION_DAYS,
108
110
  };
109
111
  // ─── Scheduler State ─────────────────────────────────────────
package/dist/config.js CHANGED
@@ -275,7 +275,7 @@ export const PRISM_GRAPH_PRUNE_MAX_PROJECTS_PER_SWEEP = parseInt(process.env.PRI
275
275
  // cognitive architecture. Replaces simple Ebbinghaus decay with
276
276
  // a composite similarity + activation model.
277
277
  /** Master switch for ACT-R activation-based re-ranking. */
278
- export const PRISM_ACTR_ENABLED = process.env.PRISM_ACTR_ENABLED === "true";
278
+ export const PRISM_ACTR_ENABLED = process.env.PRISM_ACTR_ENABLED !== "false";
279
279
  /** ACT-R decay parameter d in t^(-d). Higher = faster forgetting. (Paper default: 0.5) */
280
280
  export const PRISM_ACTR_DECAY = parseFloat(process.env.PRISM_ACTR_DECAY || "0.5");
281
281
  /** Weight of cosine similarity in composite score. (Default: 0.7 — similarity dominates) */
@@ -41,6 +41,7 @@ import { DASHBOARD_READ_MESSAGES } from "./readMessages.js";
41
41
  import { isDashboardSettingKeyAllowed, isDashboardSettingValueAllowed } from "./settingsPolicy.js";
42
42
  import { isTrustedRequest, isRebindGuardedPath } from "./hostGuard.js";
43
43
  import { darkFactoryEnablementState, DARK_FACTORY_SETTINGS_UNAVAILABLE } from "../darkfactory/enablement.js";
44
+ import { actrEnablementState } from "../utils/actrEnablement.js";
44
45
  import { resolveDashboardToken, requestHasToken, buildTokenCookie, dashboardTokenCookieName, isDashboardTokenProtectedPath, } from "./dashboardToken.js";
45
46
  import { safeCompare, generateToken, isAuthenticated, createRateLimiter, initJWKS, } from "./authUtils.js";
46
47
  const PORT = parseInt(process.env.PRISM_DASHBOARD_PORT || "3000", 10);
@@ -730,10 +731,11 @@ return false;}
730
731
  // ─── API: Settings — GET (v3.0 Dashboard Settings) ───
731
732
  if (url.pathname === "/api/settings" && req.method === "GET") {
732
733
  try {
733
- const { getAllSettings } = await import("../storage/configStorage.js");
734
+ const { getAllSettings, initConfigStorage } = await import("../storage/configStorage.js");
735
+ await initConfigStorage();
734
736
  const settings = await getAllSettings();
735
737
  res.writeHead(200, { "Content-Type": "application/json" });
736
- return res.end(JSON.stringify({ settings, darkFactory: darkFactoryEnablementState() }));
738
+ return res.end(JSON.stringify({ settings, darkFactory: darkFactoryEnablementState(), actr: actrEnablementState() }));
737
739
  }
738
740
  catch {
739
741
  res.writeHead(503, { "Content-Type": "application/json" });
@@ -29,7 +29,7 @@ export function isDashboardSettingValueAllowed(key, value) {
29
29
  if (["auto_capture", "hivemind_enabled", "task_router_enabled"].includes(key)) {
30
30
  return value === "true" || value === "false";
31
31
  }
32
- if (key === "PRISM_DARK_FACTORY_ENABLED")
32
+ if (key === "PRISM_DARK_FACTORY_ENABLED" || key === "PRISM_ACTR_ENABLED")
33
33
  return value === "true" || value === "false";
34
34
  if (key === "agent_name")
35
35
  return value.length <= 80 && !/[<>]/.test(value);
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { ANALYTICS_DATE_PATTERN, ANALYTICS_METRIC_IDS, DASHBOARD_READ_MESSAGES } from './readMessages.js';
17
17
  import { DARK_FACTORY_SETTINGS_UNAVAILABLE } from '../darkfactory/enablement.js';
18
+ import { ACTR_SETTINGS_UNAVAILABLE, ACTR_UI } from '../utils/actrEnablement.js';
18
19
  export function renderDashboardLocalOpenHTML() {
19
20
  return `<!DOCTYPE html>
20
21
  <html lang="en">
@@ -1302,6 +1303,15 @@ export function renderDashboardHTML(version) {
1302
1303
 
1303
1304
  <div class="setting-section">Boot Settings <span class="boot-badge">Restart Required</span></div>
1304
1305
 
1306
+ <div class="setting-row">
1307
+ <div>
1308
+ <div class="setting-label">${ACTR_UI.label}</div>
1309
+ <div class="setting-desc">${ACTR_UI.description}</div>
1310
+ <div id="actr-state" style="color:var(--text-muted);font-size:0.78rem;margin-top:0.25rem">${ACTR_UI.loading}</div>
1311
+ </div>
1312
+ <button class="toggle" id="toggle-actr" type="button" role="switch" aria-label="${ACTR_UI.label}" aria-checked="false" disabled onclick="toggleBootSetting('PRISM_ACTR_ENABLED', this)"></button>
1313
+ </div>
1314
+
1305
1315
  <div class="setting-row">
1306
1316
  <div>
1307
1317
  <div class="setting-label">Hivemind Mode</div>
@@ -4009,6 +4019,7 @@ function loadSettings() {
4009
4019
  case 2:
4010
4020
  data = _a.sent();
4011
4021
  if (!data.darkFactory) throw new Error(DARK_FACTORY_SETTINGS_UNAVAILABLE_MESSAGE);
4022
+ loadActrSettingState(data.actr);
4012
4023
  s = data.settings || {};
4013
4024
  // Runtime toggles
4014
4025
  if (s.auto_capture === 'true')
@@ -4069,6 +4080,8 @@ function loadSettings() {
4069
4080
  case 3:
4070
4081
  e_19 = _a.sent();
4071
4082
  console.warn('Settings load failed:', e_19);
4083
+ document.getElementById('actr-state').textContent = ACTR_SETTINGS_UNAVAILABLE_MESSAGE;
4084
+ document.getElementById('toggle-actr').disabled = true;
4072
4085
  document.getElementById('dark-factory-state').textContent = DARK_FACTORY_SETTINGS_UNAVAILABLE_MESSAGE;
4073
4086
  document.getElementById('toggle-dark-factory').disabled = true;
4074
4087
  document.getElementById('toggle-dark-factory').setAttribute('aria-disabled', 'true');
@@ -4122,11 +4135,30 @@ function toggleSetting(key, el) {
4122
4135
  el.classList.toggle('active'); // rollback on failure
4123
4136
  });
4124
4137
  }
4138
+ function loadActrSettingState(ar) {
4139
+ var toggle = document.getElementById('toggle-actr');
4140
+ if (!ar) {
4141
+ toggle.disabled = true;
4142
+ document.getElementById('actr-state').textContent = ACTR_SETTINGS_UNAVAILABLE_MESSAGE;
4143
+ return;
4144
+ }
4145
+ var effective = ar.envOverride === null ? ar.saved : ar.envOverride;
4146
+ toggle.disabled = ar.envOverride !== null;
4147
+ toggle.setAttribute('aria-disabled', toggle.disabled ? 'true' : 'false');
4148
+ toggle.classList.toggle('active', effective);
4149
+ toggle.setAttribute('aria-checked', effective ? 'true' : 'false');
4150
+ document.getElementById('actr-state').textContent = ar.envOverride === null
4151
+ ? 'Saved: ' + (ar.saved ? 'on' : 'off') + ' · Active in this process: ' + (ar.active ? 'on' : 'off') + (ar.pendingRestart ? ' · restart required' : '')
4152
+ : 'Environment override: ' + (ar.envOverride ? 'on' : 'off') + ' · saved preference: ' + (ar.saved ? 'on' : 'off');
4153
+ }
4125
4154
  function toggleBootSetting(key, el) {
4155
+ if (el.disabled) return;
4156
+ if (key === 'PRISM_ACTR_ENABLED') el.disabled = true;
4126
4157
  var isActive = el.classList.toggle('active');
4127
4158
  el.setAttribute('aria-checked', isActive ? 'true' : 'false');
4128
4159
  saveSetting(key, isActive ? 'true' : 'false').then(function (ok) {
4129
4160
  if (!ok) {
4161
+ if (key === 'PRISM_ACTR_ENABLED') el.disabled = false;
4130
4162
  el.classList.toggle('active'); // rollback on failure
4131
4163
  el.setAttribute('aria-checked', isActive ? 'false' : 'true');
4132
4164
  }
@@ -4137,6 +4169,7 @@ function toggleBootSetting(key, el) {
4137
4169
  });
4138
4170
  }
4139
4171
  var DARK_FACTORY_SETTINGS_UNAVAILABLE_MESSAGE = ${JSON.stringify(DARK_FACTORY_SETTINGS_UNAVAILABLE)};
4172
+ var ACTR_SETTINGS_UNAVAILABLE_MESSAGE = ${JSON.stringify(ACTR_SETTINGS_UNAVAILABLE)};
4140
4173
  var DARK_FACTORY_RESTART_MESSAGE = 'Saved. Restart your AI client for this to take effect.';
4141
4174
  function saveBootSetting(key, value) {
4142
4175
  saveSetting(key, value).then(function (ok) {
package/dist/server.js CHANGED
@@ -62,6 +62,7 @@ ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequ
62
62
  SubscribeRequestSchema, UnsubscribeRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
63
63
  import { SERVER_CONFIG, SESSION_MEMORY_ENABLED, PRISM_USER_ID, PRISM_ENABLE_HIVEMIND, WATCHDOG_INTERVAL_MS, WATCHDOG_STALE_MIN, WATCHDOG_FROZEN_MIN, WATCHDOG_OFFLINE_MIN, WATCHDOG_LOOP_THRESHOLD, PRISM_SCHEDULER_ENABLED, PRISM_SCHEDULER_INTERVAL_MS, PRISM_HDC_ENABLED, PRISM_TASK_ROUTER_ENABLED_ENV, YDC_API_KEY, } from "./config.js";
64
64
  import { initializeDarkFactoryEnabled, isDarkFactoryEnabled } from "./darkfactory/enablement.js";
65
+ import { initializeActrEnabled } from "./utils/actrEnablement.js";
65
66
  import { startWatchdog, drainAlerts } from "./hivemindWatchdog.js";
66
67
  import { startScheduler } from "./backgroundScheduler.js";
67
68
  import { startDarkFactoryRunner } from "./darkfactory/runner.js";
@@ -1290,6 +1291,7 @@ export async function startServer() {
1290
1291
  // initConfigStorage() is local SQLite only (~5ms), safe to await.
1291
1292
  await initConfigStorage();
1292
1293
  initializeDarkFactoryEnabled();
1294
+ initializeActrEnabled();
1293
1295
  // v4.6.0: Initialize OTel AFTER the settings cache is warm so that
1294
1296
  // initTelemetry() can read otel_enabled/otel_endpoint from getSettingSync()
1295
1297
  // synchronously. This is a synchronous call — no await needed.
@@ -0,0 +1,79 @@
1
+ // Canonical runtime SQL; parity with migration 040 is checked by a unit test.
2
+ export const ACTR_AUTH_SQL = String.raw `
3
+ -- Bind legacy self-hosted access history to signed identity. Preserve legitimate own-user access.
4
+ -- The automatic upgrader must never execute caller-supplied SQL for tenant clients.
5
+ CREATE OR REPLACE FUNCTION public.prism_apply_ddl(p_version integer, p_name text, p_sql text)
6
+ RETURNS json LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp
7
+ AS $$
8
+ DECLARE existing_name text;
9
+ BEGIN
10
+ IF p_version IS NULL OR p_version < 1 OR p_name IS NULL OR length(btrim(p_name)) = 0
11
+ OR p_sql IS NULL OR length(btrim(p_sql)) = 0 THEN
12
+ RAISE EXCEPTION 'Invalid migration identity or SQL' USING ERRCODE = '22023';
13
+ END IF;
14
+ PERFORM pg_advisory_xact_lock(hashtext('prism_apply_ddl'));
15
+ SELECT name INTO existing_name FROM public.prism_schema_versions WHERE version = p_version;
16
+ IF FOUND THEN
17
+ IF existing_name IS DISTINCT FROM p_name THEN
18
+ RAISE EXCEPTION 'Migration version/name conflict' USING ERRCODE = '22023';
19
+ END IF;
20
+ RETURN json_build_object('status','already_applied','version',p_version,'name',p_name);
21
+ END IF;
22
+ EXECUTE p_sql;
23
+ INSERT INTO public.prism_schema_versions(version,name) VALUES (p_version,p_name);
24
+ RETURN json_build_object('status','applied','version',p_version,'name',p_name);
25
+ END;
26
+ $$;
27
+ REVOKE ALL ON FUNCTION public.prism_apply_ddl(integer,text,text) FROM PUBLIC, anon, authenticated;
28
+ GRANT EXECUTE ON FUNCTION public.prism_apply_ddl(integer,text,text) TO service_role;
29
+ CREATE OR REPLACE FUNCTION public.prism_log_access(
30
+ p_user_id text, p_entry_id uuid, p_accessed_at timestamptz DEFAULT now(), p_context_hash text DEFAULT NULL
31
+ )
32
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp
33
+ AS $$
34
+ BEGIN
35
+ IF p_context_hash IS NOT NULL AND p_context_hash !~ '^[a-f0-9]{1,64}$' THEN
36
+ RAISE EXCEPTION 'Access context must be a hash' USING ERRCODE = '22023';
37
+ END IF;
38
+ INSERT INTO public.memory_access_log(entry_id,accessed_at,context_hash)
39
+ SELECT ledger.id, clock_timestamp(), p_context_hash FROM public.session_ledger AS ledger
40
+ WHERE ledger.id = p_entry_id AND ledger.user_id = p_user_id
41
+ AND ledger.deleted_at IS NULL AND (to_jsonb(ledger)->>'archived_at') IS NULL
42
+ AND (auth.role() = 'service_role' OR (p_user_id = auth.uid()::text
43
+ AND ((to_jsonb(ledger)->>'workspace_id') IS NULL
44
+ OR to_jsonb(ledger)->>'workspace_id' = auth.jwt()->>'workspace_id')));
45
+ END;
46
+ $$;
47
+ CREATE OR REPLACE FUNCTION public.prism_get_access_log(
48
+ p_user_id text, p_entry_ids uuid[], p_max_per_entry integer DEFAULT 50
49
+ )
50
+ RETURNS TABLE (entry_id uuid, accessed_at timestamptz)
51
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp
52
+ AS $$
53
+ BEGIN
54
+ IF p_entry_ids IS NULL OR cardinality(p_entry_ids) NOT BETWEEN 1 AND 50
55
+ OR p_max_per_entry IS NULL OR p_max_per_entry NOT BETWEEN 1 AND 50 THEN
56
+ RAISE EXCEPTION 'Invalid access history bounds' USING ERRCODE = '22023';
57
+ END IF;
58
+ RETURN QUERY
59
+ SELECT ledger.id, history.accessed_at FROM public.session_ledger AS ledger
60
+ CROSS JOIN LATERAL (
61
+ SELECT access.accessed_at FROM public.memory_access_log AS access
62
+ WHERE access.entry_id = ledger.id ORDER BY access.accessed_at DESC LIMIT p_max_per_entry
63
+ ) AS history
64
+ WHERE ledger.id = ANY(p_entry_ids) AND ledger.user_id = p_user_id
65
+ AND ledger.deleted_at IS NULL AND (to_jsonb(ledger)->>'archived_at') IS NULL
66
+ AND (auth.role() = 'service_role' OR (p_user_id = auth.uid()::text
67
+ AND ((to_jsonb(ledger)->>'workspace_id') IS NULL
68
+ OR to_jsonb(ledger)->>'workspace_id' = auth.jwt()->>'workspace_id')));
69
+ END;
70
+ $$;
71
+ REVOKE ALL ON FUNCTION public.prism_log_access(text,uuid,timestamptz,text) FROM PUBLIC, anon;
72
+ REVOKE ALL ON FUNCTION public.prism_get_access_log(text,uuid[],integer) FROM PUBLIC, anon;
73
+ GRANT EXECUTE ON FUNCTION public.prism_log_access(text,uuid,timestamptz,text) TO service_role, authenticated;
74
+ GRANT EXECUTE ON FUNCTION public.prism_get_access_log(text,uuid[],integer) TO service_role, authenticated;
75
+ -- Global retention cannot be a permission held by ordinary tenant clients.
76
+ REVOKE ALL ON FUNCTION public.prism_prune_access_log(integer) FROM PUBLIC, anon, authenticated;
77
+ GRANT EXECUTE ON FUNCTION public.prism_prune_access_log(integer) TO service_role;
78
+ NOTIFY pgrst, 'reload schema';
79
+ `;
@@ -1250,7 +1250,7 @@ export class SupabaseStorage {
1250
1250
  }
1251
1251
  catch (e) {
1252
1252
  debugLog(`[SupabaseStorage] getAccessLog fallback/no-op: ${e.message}`);
1253
- return result;
1253
+ throw e;
1254
1254
  }
1255
1255
  }
1256
1256
  async pruneAccessLog(olderThanDays) {
@@ -21,6 +21,7 @@
21
21
  * ═══════════════════════════════════════════════════════════════════
22
22
  */
23
23
  import { supabaseRpc } from "../utils/supabaseApi.js";
24
+ import { ACTR_AUTH_SQL } from "./actrSupabaseMigration.js";
24
25
  /**
25
26
  * All Supabase DDL migrations.
26
27
  *
@@ -805,6 +806,7 @@ export const MIGRATIONS = [
805
806
  CREATE INDEX IF NOT EXISTS idx_research_tasks_status ON public.research_tasks(status, created_at ASC);
806
807
  `
807
808
  },
809
+ { version: 40, name: "actr_authenticated_history", sql: ACTR_AUTH_SQL },
808
810
  ];
809
811
  /**
810
812
  * Current schema version — derived from the MIGRATIONS array.
@@ -22,6 +22,8 @@
22
22
  * - adjustImportance → POST /api/v1/prism/memory action=adjust_importance
23
23
  * - getGraduatedInsights → POST /api/v1/prism/memory action=graduated_insights
24
24
  * - decayImportance → POST /api/v1/prism/memory action=decay_importance
25
+ * - logAccess / recordAccesses / updateLastAccessed → action=record_access
26
+ * - getAccessLog → POST /api/v1/prism/memory action=get_access_log
25
27
  * - saveHandoff → POST /api/v1/prism/memory action=save_handoff
26
28
  * - saveHistorySnapshot → POST /api/v1/prism/memory action=save_history_snapshot
27
29
  * - loadContext → POST /api/v1/prism/memory action=load_context
@@ -69,10 +71,85 @@ function resolveKnowledgeScope(callerScope) {
69
71
  }
70
72
  /** Refresh JWT this many ms before expiry to avoid edge-case 401s. */
71
73
  const JWT_REFRESH_LEEWAY_MS = 60_000;
74
+ const ACCESS_BATCH_LIMIT = 50;
75
+ const ACCESS_ID_RE = /^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i;
76
+ const ACCESS_MESSAGES = {
77
+ invalid: "Invalid memory access batch",
78
+ write: "Invalid memory access response",
79
+ read: "Invalid memory access history response",
80
+ failed: "[SynaluxStorage] Memory access recording failed",
81
+ };
82
+ function accessIds(ids) {
83
+ if (!Array.isArray(ids) || ids.some(id => typeof id !== "string" || !ACCESS_ID_RE.test(id))) {
84
+ throw new Error(ACCESS_MESSAGES.invalid);
85
+ }
86
+ return [...new Set(ids.map(id => id.toLowerCase()))];
87
+ }
72
88
  function buildContextLoadKey(project, level, userId, role) {
73
89
  return JSON.stringify({ project, level, userId, role: role ?? "" });
74
90
  }
75
91
  export class SynaluxStorage extends SupabaseStorage {
92
+ logAccess(entryId, contextHash) {
93
+ void this.recordAccesses([entryId], contextHash).catch(() => console.error(ACCESS_MESSAGES.failed));
94
+ }
95
+ async getCandidateLinks(entryIds) {
96
+ const ids = accessIds(entryIds);
97
+ if (ids.length === 0)
98
+ return [];
99
+ if (ids.length > ACCESS_BATCH_LIMIT)
100
+ throw new Error(ACCESS_MESSAGES.invalid);
101
+ const response = await this.portalPost("/api/v1/prism/memory", { action: "get_candidate_links", entry_ids: ids });
102
+ if (!Array.isArray(response.links) || response.links.length > ids.length * 20
103
+ || response.links.some(row => !row || !ids.includes(row.source_id) || !ids.includes(row.target_id)
104
+ || !Number.isFinite(row.strength) || row.strength < 0 || row.strength > 1))
105
+ throw new Error(ACCESS_MESSAGES.read);
106
+ return response.links;
107
+ }
108
+ async recordAccesses(entryIds, contextHash) {
109
+ await this.writeAccesses(entryIds, contextHash, true);
110
+ }
111
+ async updateLastAccessed(entryIds) {
112
+ await this.writeAccesses(entryIds, undefined, false);
113
+ }
114
+ async writeAccesses(entryIds, contextHash, recordHistory) {
115
+ const ids = accessIds(entryIds);
116
+ if (contextHash !== undefined && !/^[a-f0-9]{1,64}$/.test(contextHash))
117
+ throw new Error(ACCESS_MESSAGES.invalid);
118
+ for (let offset = 0; offset < ids.length; offset += ACCESS_BATCH_LIMIT) {
119
+ const batch = ids.slice(offset, offset + ACCESS_BATCH_LIMIT);
120
+ const response = await this.portalPost("/api/v1/prism/memory", {
121
+ action: "record_access", entry_ids: batch, context_hash: contextHash ?? null, record_history: recordHistory,
122
+ });
123
+ if (!Number.isInteger(response.recorded) || Number(response.recorded) < 0 || Number(response.recorded) > batch.length) {
124
+ throw new Error(ACCESS_MESSAGES.write);
125
+ }
126
+ }
127
+ }
128
+ async getAccessLog(entryIds, maxPerEntry = ACCESS_BATCH_LIMIT) {
129
+ const ids = accessIds(entryIds);
130
+ if (!Number.isInteger(maxPerEntry) || maxPerEntry < 1 || maxPerEntry > ACCESS_BATCH_LIMIT)
131
+ throw new Error(ACCESS_MESSAGES.invalid);
132
+ const result = new Map();
133
+ for (let offset = 0; offset < ids.length; offset += ACCESS_BATCH_LIMIT) {
134
+ const batch = ids.slice(offset, offset + ACCESS_BATCH_LIMIT);
135
+ const response = await this.portalPost("/api/v1/prism/memory", { action: "get_access_log", entry_ids: batch, max_per_entry: maxPerEntry });
136
+ if (!Array.isArray(response.accesses) || response.accesses.length > batch.length * maxPerEntry)
137
+ throw new Error(ACCESS_MESSAGES.read);
138
+ for (const row of response.accesses) {
139
+ if (!row || !batch.includes(row.entry_id) || typeof row.accessed_at !== "string")
140
+ throw new Error(ACCESS_MESSAGES.read);
141
+ const timestamp = new Date(row.accessed_at);
142
+ if (!Number.isFinite(timestamp.getTime()))
143
+ throw new Error(ACCESS_MESSAGES.read);
144
+ const history = result.get(row.entry_id) ?? [];
145
+ if (history.length >= maxPerEntry)
146
+ throw new Error(ACCESS_MESSAGES.read);
147
+ history.push(timestamp);
148
+ result.set(row.entry_id, history);
149
+ }
150
+ }
151
+ return result;
152
+ }
76
153
  baseUrl;
77
154
  refreshToken;
78
155
  cachedJwt = null;
@@ -51,7 +51,9 @@ const activeCompactions = new Set();
51
51
  */
52
52
  import { computeEffectiveImportance, recordMemoryAccess } from "../utils/cognitiveMemory.js";
53
53
  import { baseLevelActivation, candidateScopedSpreadingActivation, compositeRetrievalScore, } from "../utils/actrActivation.js";
54
- import { PRISM_ACTR_ENABLED, PRISM_ACTR_DECAY, PRISM_ACTR_WEIGHT_SIMILARITY, PRISM_ACTR_WEIGHT_ACTIVATION, PRISM_ACTR_SIGMOID_MIDPOINT, PRISM_ACTR_SIGMOID_STEEPNESS, PRISM_ACTR_MAX_ACCESSES_PER_ENTRY, } from "../config.js";
54
+ import { PRISM_ACTR_DECAY, PRISM_ACTR_WEIGHT_SIMILARITY, PRISM_ACTR_WEIGHT_ACTIVATION, PRISM_ACTR_SIGMOID_MIDPOINT, PRISM_ACTR_SIGMOID_STEEPNESS, PRISM_ACTR_MAX_ACCESSES_PER_ENTRY, } from "../config.js";
55
+ import { isActrEnabled } from "../utils/actrEnablement.js";
56
+ import { createHash } from "node:crypto";
55
57
  import { HdcStateMachine } from "../sdm/stateMachine.js";
56
58
  import { ConceptDictionary } from "../sdm/conceptDictionary.js";
57
59
  import { PolicyGateway } from "../sdm/policyGateway.js";
@@ -361,7 +363,7 @@ export async function sessionSearchMemoryHandler(args) {
361
363
  enable_trace = false,
362
364
  // v5.2: Context-Weighted Retrieval — biases search toward active work context
363
365
  context_boost = false, activation, } = args;
364
- debugLog(`[session_search_memory] Semantic search: query="${query}", ` +
366
+ debugLog(`[session_search_memory] Semantic search: queryHash=${createHash("sha256").update(query).digest("hex")}, ` +
365
367
  `project=${project || "all"}, limit=${limit}, threshold=${similarity_threshold}` +
366
368
  `${context_boost ? ", context_boost=ON" : ""}`);
367
369
  // Phase 1: Start total latency timer BEFORE any work (embedding + storage)
@@ -395,7 +397,7 @@ export async function sessionSearchMemoryHandler(args) {
395
397
  }
396
398
  if (contextParts.length > 0) {
397
399
  effectiveQuery = `[${contextParts.join("; ")}] ${query}`;
398
- debugLog(`[session_search_memory] Context boost applied: "${effectiveQuery.substring(0, 100)}..."`);
400
+ debugLog(`[session_search_memory] Context boost applied (${effectiveQuery.length} characters)`);
399
401
  }
400
402
  }
401
403
  catch {
@@ -435,7 +437,8 @@ export async function sessionSearchMemoryHandler(args) {
435
437
  // If we only fetch `limit` rows, the re-ranker can only shuffle those exact
436
438
  // results — a memory at rank #(limit+1) by similarity but accessed 500 times
437
439
  // would never surface. Fetch 4× or minimum 20, then slice after re-ranking.
438
- const candidateLimit = PRISM_ACTR_ENABLED
440
+ const actrEnabled = isActrEnabled();
441
+ const candidateLimit = actrEnabled
439
442
  ? Math.min(Math.max(limit * 4, 20), 50)
440
443
  : Math.min(limit, 20);
441
444
  const results = await storage.searchMemory({
@@ -483,28 +486,38 @@ export async function sessionSearchMemoryHandler(args) {
483
486
  }
484
487
  // ── v7.0: ACT-R Re-Ranking Pipeline ──────────────────────────
485
488
  const resultIds = results.map((r) => r.id).filter(Boolean);
489
+ const policyCandidates = [...results].sort((a, b) => (b.similarity ?? 0) - (a.similarity ?? 0)).slice(0, limit);
486
490
  const now = new Date();
487
491
  // Accumulate ACT-R metrics for trace output
488
492
  let actrMetrics = null;
489
- if (PRISM_ACTR_ENABLED && resultIds.length > 0) {
493
+ if (actrEnabled && resultIds.length > 0) {
490
494
  try {
491
495
  // Step A: Bulk-fetch access logs for all candidate IDs
492
496
  const accessLogMap = await storage.getAccessLog(resultIds, PRISM_ACTR_MAX_ACCESSES_PER_ENTRY);
493
497
  // Step B: Fetch outbound links for spreading activation
494
498
  const candidateIdSet = new Set(resultIds);
495
499
  const linksMap = new Map();
496
- for (const id of resultIds) {
497
- try {
498
- const links = await storage.getLinksFrom(id, PRISM_USER_ID, 0.0, 20);
499
- linksMap.set(id, links.map((l) => ({
500
- target_id: l.target_id,
501
- strength: l.strength ?? 1.0,
502
- })));
503
- }
504
- catch {
505
- linksMap.set(id, []);
500
+ if (storage.getCandidateLinks) {
501
+ const links = await storage.getCandidateLinks(resultIds);
502
+ for (const link of links) {
503
+ const outgoing = linksMap.get(link.source_id) ?? [];
504
+ outgoing.push({ target_id: link.target_id, strength: link.strength });
505
+ linksMap.set(link.source_id, outgoing);
506
506
  }
507
507
  }
508
+ else
509
+ for (const id of resultIds) {
510
+ try {
511
+ const links = await storage.getLinksFrom(id, PRISM_USER_ID, 0.0, 20);
512
+ linksMap.set(id, links.map((l) => ({
513
+ target_id: l.target_id,
514
+ strength: l.strength ?? 1.0,
515
+ })));
516
+ }
517
+ catch {
518
+ linksMap.set(id, []);
519
+ }
520
+ }
508
521
  // Step C: Compute activation for each result and re-rank
509
522
  actrMetrics = { baseLevels: [], spreadings: [], sigmoids: [], composites: [] };
510
523
  for (const r of results) {
@@ -541,6 +554,7 @@ export async function sessionSearchMemoryHandler(args) {
541
554
  `mean composite=${(actrMetrics.composites.reduce((a, b) => a + b, 0) / actrMetrics.composites.length).toFixed(3)}`);
542
555
  }
543
556
  catch (actrErr) {
557
+ actrMetrics = null;
544
558
  // ACT-R failures are non-fatal — degrade to similarity-only ordering
545
559
  debugLog(`[session_search_memory] ACT-R re-ranking failed (non-fatal): ${actrErr instanceof Error ? actrErr.message : String(actrErr)}`);
546
560
  }
@@ -550,13 +564,15 @@ export async function sessionSearchMemoryHandler(args) {
550
564
  // so we only log access for results actually delivered to the LLM.
551
565
  results.splice(limit);
552
566
  if (PRISM_HDC_ENABLED && results.length > 0) {
553
- const topScore = PRISM_ACTR_ENABLED ? results[0]._actr_composite : results[0].similarity;
554
- const secondScore = results.length > 1 ? (PRISM_ACTR_ENABLED ? results[1]._actr_composite : results[1].similarity) : 0;
567
+ // Policy thresholds are calibrated to semantic relevance, not activation.
568
+ const topScore = policyCandidates[0]?.similarity;
569
+ const secondScore = policyCandidates.length > 1 ? policyCandidates[1].similarity : 0;
555
570
  const gapDistance = (topScore || 0) - (secondScore || 0);
556
571
  const fallbackThreshold = PRISM_HDC_POLICY_FALLBACK_THRESHOLD || 0.85;
557
572
  const clarifyThreshold = PRISM_HDC_POLICY_CLARIFY_THRESHOLD || 0.95;
558
573
  const gapThreshold = clarifyThreshold - fallbackThreshold;
559
- if ((topScore || 0) < fallbackThreshold || (results.length > 1 && gapDistance < gapThreshold)) {
574
+ if ((topScore || 0) < fallbackThreshold || (policyCandidates.length > 1 && gapDistance < gapThreshold)
575
+ || (results[0].similarity ?? 0) < fallbackThreshold) {
560
576
  return {
561
577
  content: [{
562
578
  type: "text",
@@ -576,7 +592,7 @@ export async function sessionSearchMemoryHandler(args) {
576
592
  // v7.0: Writes to both access_log buffer AND legacy last_accessed_at
577
593
  const finalIds = results.map((r) => r.id).filter(Boolean);
578
594
  if (finalIds.length > 0) {
579
- const queryHash = query.substring(0, 64);
595
+ const queryHash = createHash("sha256").update(query).digest("hex");
580
596
  recordMemoryAccess(finalIds, queryHash);
581
597
  }
582
598
  // Format results with similarity scores + effective importance + ACT-R
@@ -605,6 +621,9 @@ export async function sessionSearchMemoryHandler(args) {
605
621
  type: "text",
606
622
  text: `${searchResultsHeader(results.length, isHybridSearchResults(results))}\n\n${formatted}`,
607
623
  }];
624
+ if (actrEnabled && resultIds.length > 0 && !actrMetrics) {
625
+ contentBlocks.push({ type: "text", text: "Automatic memory ranking unavailable for this search; returning relevance ordering." });
626
+ }
608
627
  // Phase 1: content[1] = machine-readable MemoryTrace (only when enable_trace=true)
609
628
  // topScore is read from results[0].similarity — this is the cosine distance
610
629
  // already returned by SemanticSearchResult in the storage interface.
@@ -626,7 +645,7 @@ export async function sessionSearchMemoryHandler(args) {
626
645
  totalMs,
627
646
  project: project || null,
628
647
  // v7.0: ACT-R observability
629
- actrEnabled: PRISM_ACTR_ENABLED,
648
+ actrEnabled: actrMetrics !== null,
630
649
  actrBaseLevelMean: actrMetrics ? mean(actrMetrics.baseLevels) : undefined,
631
650
  actrSpreadingMean: actrMetrics ? mean(actrMetrics.spreadings) : undefined,
632
651
  actrCompositeMean: actrMetrics ? mean(actrMetrics.composites) : undefined,
@@ -682,6 +701,9 @@ export async function sessionSearchMemoryHandler(args) {
682
701
  text: contentBlocks[0].text +
683
702
  `\n\n🔗 Graph-connected memories (${enrichedEntries.length} via 1-hop expansion):\n\n${graphFormatted}`,
684
703
  };
704
+ const deliveredGraphIds = enrichedEntries.map((entry) => entry.id).filter(Boolean);
705
+ if (deliveredGraphIds.length > 0)
706
+ recordMemoryAccess(deliveredGraphIds, createHash("sha256").update(query).digest("hex"));
685
707
  // Fire-and-forget: reinforce traversed links
686
708
  for (const directId of directIds) {
687
709
  storage.getLinksFrom(directId, PRISM_USER_ID, 0.3, 5)
@@ -318,7 +318,6 @@ export async function layer1HedgeSecondRead(o) {
318
318
  const tier = MODEL_TIERS.find(t => t.tag === "prism-coder:9b");
319
319
  if (!o.installed.has(big) || big === o.guardModel || tier === undefined)
320
320
  return skip("no_9b");
321
- const readPrompt = !o.promptFastPath;
322
321
  const messages = o.args.messages ?? [];
323
322
  const turnReads = messages.flatMap((m, index) => historyTurnWindows(m.content).filter(w => w.trim()).map(text => ({ text, index, role: m.role })));
324
323
  const turnTexts = turnReads.map(t => t.text);
@@ -333,7 +332,7 @@ export async function layer1HedgeSecondRead(o) {
333
332
  if (excluded)
334
333
  return skip(excluded);
335
334
  }
336
- if (readPrompt && o.args.prompt.length > MAX_CLASSIFIER_PROMPT_LENGTH)
335
+ if (o.args.prompt.length > MAX_CLASSIFIER_PROMPT_LENGTH)
337
336
  return skip("prompt_oversize");
338
337
  const reads = [];
339
338
  const seen = new Set();
@@ -341,8 +340,7 @@ export async function layer1HedgeSecondRead(o) {
341
340
  seen.add(k);
342
341
  reads.push(r);
343
342
  } };
344
- if (readPrompt)
345
- add({ model: big, text: o.args.prompt, kind: "prompt", label: "9b:prompt" });
343
+ add({ model: big, text: o.args.prompt, kind: "prompt", label: "9b:prompt" });
346
344
  for (let i = turnReads.length - 1; i >= 0; i--)
347
345
  add({ model: big, text: turnReads[i].text, kind: "turn", label: `9b:turn:${turnReads[i].index}:${turnReads[i].role}` });
348
346
  for (let i = ctxTexts.length - 1; i >= 0; i--)
@@ -388,7 +386,7 @@ export async function layer1HedgeSecondRead(o) {
388
386
  if (!known(ctxBig) || !known(ctxSmall) || !known(overBig) || !known(overSmall))
389
387
  return skip("unprobeable");
390
388
  const fitsIn = (ctx, over) => (t) => Buffer.byteLength(layer1ClassifierContent(t), "utf8") + over + LAYER1_CLASSIFIER_OUTPUT_TOKENS <= ctx;
391
- if (![...turnTexts, ...(readPrompt ? [o.args.prompt] : []), ...ctxTexts].every(fitsIn(ctxBig, overBig)))
389
+ if (![...turnTexts, o.args.prompt, ...ctxTexts].every(fitsIn(ctxBig, overBig)))
392
390
  return skip("unfit");
393
391
  if (!ctxTexts.every(fitsIn(ctxSmall, overSmall)))
394
392
  return skip("unfit");
@@ -2242,27 +2240,18 @@ export async function runInfer(args, deps) {
2242
2240
  // The current prompt is a request: its deterministic floor runs
2243
2241
  // here explicitly (not only inside the classifier entry point, so
2244
2242
  // an injected classifier cannot skip it), in the same proximity
2245
- // slices as a turn; then, unless the routine fast path below
2246
- // applies, it is read alone with its images and that verdict is
2247
- // kept like a turn's.
2248
- let promptRoutine = true;
2243
+ // slices as a turn; then it is read alone with its images and that
2244
+ // verdict is kept like a turn's. The floor only refuses, so there is
2245
+ // no fast path: a prompt a rule does not refuse is always read.
2249
2246
  for (const slice of windowsOf(args.prompt, DETERMINISTIC_FLOOR_WINDOW_CHARS, DETERMINISTIC_FLOOR_WINDOW_OVERLAP)) {
2250
2247
  const promptDet = classifyDeterministicLayer1(slice);
2251
2248
  if (promptDet)
2252
2249
  l1 = raise(l1, promptDet, "rules");
2253
- if (promptDet !== "OBVIOUS_NOT_RESERVED")
2254
- promptRoutine = false;
2255
2250
  }
2256
2251
  // The classifier entry point's own whole-prompt deterministic pass
2257
2252
  // is switched off here — it would undo the slicing above (words
2258
- // 14k chars apart firing one rule; review round 18). The routine
2259
- // fast path it provided is kept explicitly and on ITS boundary: a
2260
- // prompt of at most 4,000 chars whose rules verdict is routine,
2261
- // with no images, skips the model. Longer prompts always reach
2262
- // the entry point, whose full-text keyword floor must run
2263
- // (review round 19: skipping it there bypassed that floor).
2264
- const promptFastPath = promptRoutine && args.prompt.length <= MAX_CLASSIFIER_PROMPT_LENGTH && (resolvedImages?.length ?? 0) === 0;
2265
- if (l1 !== "OBVIOUS_RESERVED" && !promptFastPath) {
2253
+ // 14k chars apart firing one rule; review round 18).
2254
+ if (l1 !== "OBVIOUS_RESERVED") {
2266
2255
  l1 = raise(l1, await l1fn(args.prompt, deps.ollamaUrl, l1Model, undefined, resolvedImages, { deterministic: false, ...(classifierInput ? { classifierInput } : {}) }), "prompt");
2267
2256
  }
2268
2257
  // 3. Context, raise only: one window per turn and one for the
@@ -2328,7 +2317,7 @@ export async function runInfer(args, deps) {
2328
2317
  else if (modelHedgeOnly) {
2329
2318
  const second = await layer1HedgeSecondRead({
2330
2319
  l1fn, args, installed, listLoaded: deps.listLoaded, freeBytes: deps.freemem(), ollamaUrl: deps.ollamaUrl,
2331
- guardModel: l1Model, promptFastPath,
2320
+ guardModel: l1Model,
2332
2321
  probeClassifierLimits: deps.probeClassifierLimits ?? probeClassifierLimits,
2333
2322
  policy: await (deps.secondReadPolicy ?? getSecondReadPolicy)().catch(() => null),
2334
2323
  });
@@ -0,0 +1,29 @@
1
+ import { getSettingSync } from "../storage/configStorage.js";
2
+ const SETTING_KEY = "PRISM_ACTR_ENABLED";
3
+ const DEFAULT_ENABLED = true;
4
+ export const ACTR_SETTINGS_UNAVAILABLE = "Automatic memory ranking settings unavailable. Retry when the server is reachable.";
5
+ export const ACTR_UI = {
6
+ label: "Automatic Memory Ranking (ACT-R)",
7
+ description: "Rank retrieved memories by relevance, recency, frequency and associations. Enabled by default. Restart your AI client after changing this setting.",
8
+ loading: "Loading ranking state…",
9
+ };
10
+ let startupDecision;
11
+ export function resolveActrEnabled(env, saved) {
12
+ if (env === "true" || env === "false")
13
+ return env === "true";
14
+ return saved === "true" || saved === "false" ? saved === "true" : DEFAULT_ENABLED;
15
+ }
16
+ export function initializeActrEnabled() {
17
+ startupDecision = resolveActrEnabled(process.env.PRISM_ACTR_ENABLED, getSettingSync(SETTING_KEY, String(DEFAULT_ENABLED)));
18
+ return startupDecision;
19
+ }
20
+ export function isActrEnabled() {
21
+ return startupDecision ?? initializeActrEnabled();
22
+ }
23
+ export function actrEnablementState() {
24
+ const env = process.env.PRISM_ACTR_ENABLED;
25
+ const envOverride = env === "true" || env === "false" ? env === "true" : null;
26
+ const saved = resolveActrEnabled(undefined, getSettingSync(SETTING_KEY, String(DEFAULT_ENABLED)));
27
+ const active = isActrEnabled();
28
+ return { saved, active, envOverride, pendingRestart: envOverride === null && saved !== active };
29
+ }
@@ -23,7 +23,8 @@
23
23
  import { getStorage } from "../storage/index.js";
24
24
  import { debugLog } from "./logger.js";
25
25
  import { baseLevelActivation, parameterizedSigmoid, } from "./actrActivation.js";
26
- import { PRISM_ACTR_ENABLED, PRISM_ACTR_DECAY, PRISM_ACTR_SIGMOID_MIDPOINT, PRISM_ACTR_SIGMOID_STEEPNESS, } from "../config.js";
26
+ import { PRISM_ACTR_DECAY, PRISM_ACTR_SIGMOID_MIDPOINT, PRISM_ACTR_SIGMOID_STEEPNESS, } from "../config.js";
27
+ import { isActrEnabled } from "./actrEnablement.js";
27
28
  /**
28
29
  * Computes the effective importance of a memory, combining the semantic
29
30
  * importance score with a recency-based activation factor.
@@ -53,7 +54,7 @@ export function computeEffectiveImportance(baseImportance, lastAccessedStr, crea
53
54
  // Fallback to creation date if it has never been accessed
54
55
  const referenceDateStr = lastAccessedStr || createdAtStr;
55
56
  const referenceDate = new Date(referenceDateStr);
56
- if (!PRISM_ACTR_ENABLED) {
57
+ if (!isActrEnabled()) {
57
58
  // ── Legacy Ebbinghaus Decay (v5.2, deprecated) ──
58
59
  const diffMs = Math.max(0, now.getTime() - referenceDate.getTime());
59
60
  const daysSinceAccess = diffMs / (1000 * 60 * 60 * 24);
@@ -89,6 +90,9 @@ export function recordMemoryAccess(ids, contextHash) {
89
90
  return;
90
91
  // Fire and forget, don't block execution
91
92
  getStorage().then(storage => {
93
+ if (storage.recordAccesses) {
94
+ return storage.recordAccesses(ids, contextHash);
95
+ }
92
96
  // v7.0: Log access events to memory_access_log via buffer
93
97
  for (const id of ids) {
94
98
  storage.logAccess(id, contextHash);
@@ -150,7 +150,12 @@ const all = (...rules) => (prompt) => rules.every((rule) => rule(prompt));
150
150
  * restraint, self-harm, suicide, medication or diagnosis. */
151
151
  const CLINICAL_RESERVED_RULES = [
152
152
  all(has(/\b(?:de[- ]?escalat\w*|meltdown\w*|rage\s+episode|violent\w*)\b/i), has(/\b(?:draft|write|plan|procedure|protocol|manag\w*|what\s+(?:do|should)\b|respond\w*)\b/i)),
153
- all(has(/\b(?:physical\s+intervention|containment|hold\s+procedure|restrain\w*|seclu\w*|physical\s+management)\b/i), has(/\b(?:draft|write|document|procedure|protocol|use|implement|instruct\w*)\b/i)),
153
+ (prompt) => (/\b(?:physical\s+intervention|containment|hold\s+procedure|restrain\w*|seclu\w*|physical\s+management)\b/i.test(prompt)
154
+ && /\b(?:draft|write|document|procedure|protocol|use|implement|instruct\w*)\b/i.test(prompt)) || (
155
+ // Seclusion by another name: someone placed IN a room ("put him in the
156
+ // quiet room", "walk the student into the calm-down room"). A room
157
+ // named as a setting, a schedule, or a room used by choice is not one.
158
+ /\b(?:put|puts|putting|plac(?:e|es|ed|ing)|sen(?:d|ds|t|ding)|tak(?:e|es|en|ing)|took|walk\w*|mov(?:e|es|ed|ing)|bring\w*|brought|escort\w*|carr(?:y|ies|ied|ying)|drag\w*|lock\w*|confin\w*|keep\w*|kept)\s+(?:him|her|them|(?:the|a|an|this|that)\s+(?:client|student|child|kid|learner|resident|patient))\s+(?:back\s+)?(?:in|into|to|inside)\s+(?:the\s+|a\s+|an\s+)?(?:quiet|isolation|time[- ]?out|padded|calm[- ]?down|seclusion)\s+rooms?\b/i.test(prompt)),
154
159
  all(has(/\b(?:self[- ]?(?:harm|injur\w*)|bites?\s+(?:him|her|them)self|bites?\s+(?:his|her|their)\s+own\b|scratches?\s+(?:him|her|them)self|scratches?\s+(?:his|her|their)\s+own\b|bangs?\s+(?:his|her|their)\s+head|harm\s+(?:himself|herself|themselves))\b/i), has(/\b(?:bleed\w*|blood|concrete|head\s+impact|medical\s+attention|risk(?:\s+of\s+injury)?|causes?\s+injury|actual\s+injury|assess\w*|screen\w*|want\w*)\b/i)),
155
160
  (prompt) => (/\b(?:punch\w*|attack\w*|aggress\w*|hit\s+staff)\b/i.test(prompt)
156
161
  && /\b(?:last\s+(?:week|month|year)|prior|previous|history|injur\w*)\b/i.test(prompt)
@@ -173,17 +178,6 @@ const RESERVED_INTENT_RULES = [
173
178
  ...CLINICAL_RESERVED_RULES,
174
179
  ...OPERATIONAL_RESERVED_RULES,
175
180
  ];
176
- const ROUTINE_BCBA_INTENT_RULES = [
177
- has(/\boperational\s+definition\b/i),
178
- all(has(/\bdefin\w*\b/i), has(/\b(?:onset|offset|observers?|score|measur\w*)\b/i)),
179
- all(has(/\b(?:abc|fba|functional\s+behavior)\b/i), has(/\b(?:hypothes\w*|tentative|summari[sz]\w*)\b/i)),
180
- all(has(/\b(?:what\s+data|data\s+(?:should|to)\s+(?:be\s+)?gather\w*|collect\s+data)\b/i), has(/\bbefore\s+(?:select\w*|choos\w*|design\w*)\s+(?:an?\s+)?intervention\b/i)),
181
- all(has(/\b(?:aac|augmentative\s+communication)\b/i), has(/\b(?:replacement[- ]response|replacement\s+(?:skill|behavior)|function[- ]matched)\b/i), has(/\b(?:bcba|clinician)\s+review\b/i)),
182
- all(has(/\b(?:explain|educat\w*|why)\b/i), has(/\b(?:dro|differential\s+reinforcement|function[- ]matched|maintain\w+\s+by|escape\s+from)\b/i)),
183
- all(has(/\b(?:caregiver|staff|parent)\s+training\b/i), has(/\b(?:aac|replacement|break[- ]request|communication)\b/i)),
184
- all(has(/\b(?:rewrite|rephrase)\b/i), has(/\b(?:stigmat\w*|dignity|objective|tentative|function[- ]based)\b/i)),
185
- all(has(/\b(?:aac|communication\s+device)\b/i), has(/\b(?:remain|keep)\s+available\b/i), has(/\bno\s+injur(?:y|ies)\b/i), has(/\bnonrestrictive\b/i)),
186
- ];
187
181
  function matchesAny(prompt, rules) {
188
182
  return rules.some((rule) => rule(prompt));
189
183
  }
@@ -235,11 +229,11 @@ export function reservedCategory(prompt) {
235
229
  // The artifact exemption sits BETWEEN the two groups in
236
230
  // classifyDeterministicLayer1, and omitting it here made the label disagree
237
231
  // with the verdict: "add numeric validation fields to the auth token
238
- // verification handler" classifies OBVIOUS_NOT_RESERVED, while this reported
239
- // "auth / session / token code". Harmless when the deterministic verdict is
240
- // used directly, but the label is also read on paths that skip that fast
241
- // path — prompts over MAX_CLASSIFIER_PROMPT_LENGTH, and image requests — so
242
- // it could name a rule that did not fire.
232
+ // verification handler" is never refused by a rule (the classifier reads
233
+ // it), while this reported "auth / session / token code". Harmless when the
234
+ // deterministic verdict is used directly, but the label is also read on
235
+ // paths that skip that fast path — prompts over MAX_CLASSIFIER_PROMPT_LENGTH,
236
+ // and image requests — so it could name a rule that did not fire.
243
237
  if (NON_OPERATIONAL_ARTIFACT_CONTEXT.test(prompt) &&
244
238
  NON_OPERATIONAL_ARTIFACT_ACTION.test(prompt)) {
245
239
  return null;
@@ -255,9 +249,8 @@ const NON_OPERATIONAL_ARTIFACT_ACTION = /\b(?:review\b[\s\S]{0,120}\bhook\s+orde
255
249
  /**
256
250
  * Deterministic policy floor for unambiguous intents.
257
251
  *
258
- * Routing is code, not model judgment: clear reserved work fails closed and
259
- * clear routine BCBA work reaches local inference. Ambiguous prompts return
260
- * null and continue to the semantic classifier below.
252
+ * Clear reserved work fails closed by rule. Everything else returns null and
253
+ * is read by the semantic classifier below: the floor never clears.
261
254
  */
262
255
  /** The non-operational artifact exemption over a text. classifyDeterministic
263
256
  * Layer1 applies it to the text it is given; prism_infer's history screen
@@ -281,19 +274,25 @@ export function classifyDeterministicLayer1(userPrompt, opts) {
281
274
  // the crisis form" escaping the restraint rule, and a wrong answer there
282
275
  // costs more than an unnecessary escalation. Clinical intent still fails
283
276
  // closed first, before anything can exempt it.
277
+ //
278
+ // The floor only refuses; it never clears. Every bypass found in review was
279
+ // a rule clearing a request no model read: a routine or artifact clause
280
+ // beside "a prone hold protocol for staff", "how many mL of Benadryl to
281
+ // give" or "find paths allowing unauthenticated access" was served in
282
+ // 0 ms, and each word-list patch was followed by new wording. Anything a
283
+ // rule does not refuse is read by the classifier.
284
284
  const clinicalReserved = matchesAny(userPrompt, CLINICAL_RESERVED_RULES);
285
285
  if (clinicalReserved)
286
286
  return "OBVIOUS_RESERVED";
287
- if (opts?.operational === false) {
288
- return matchesAny(userPrompt, ROUTINE_BCBA_INTENT_RULES) ? "OBVIOUS_NOT_RESERVED" : null;
289
- }
290
- if (isNonOperationalArtifact(userPrompt)) {
291
- return "OBVIOUS_NOT_RESERVED";
292
- }
287
+ if (opts?.operational === false)
288
+ return null;
289
+ // A non-operational artifact edit is never refused by an operational rule
290
+ // (an auth_bypass fixture label is not reserved work); the classifier
291
+ // reads it.
292
+ if (isNonOperationalArtifact(userPrompt))
293
+ return null;
293
294
  if (matchesAny(userPrompt, RESERVED_INTENT_RULES))
294
295
  return "OBVIOUS_RESERVED";
295
- if (matchesAny(userPrompt, ROUTINE_BCBA_INTENT_RULES))
296
- return "OBVIOUS_NOT_RESERVED";
297
296
  return null;
298
297
  }
299
298
  /**
@@ -451,13 +450,6 @@ images, opts) {
451
450
  const deterministic = opts?.deterministic === false ? null : classifyDeterministicLayer1(userPrompt);
452
451
  if (deterministic === "OBVIOUS_RESERVED")
453
452
  return deterministic;
454
- // The routine-work fast path may only skip the model when there is nothing
455
- // to look at. It reads the PROMPT, so with an image attached it is a free
456
- // bypass: pair "write an operational definition" — or any ROUTINE_BCBA
457
- // phrasing — with a clinical screenshot and the gate returns NOT_RESERVED
458
- // without a single call. Text keeps the shortcut; images earn the screen.
459
- if (!oversize && !hasImages && deterministic === "OBVIOUS_NOT_RESERVED")
460
- return deterministic;
461
453
  if (oversize && keywordBackstop(userPrompt) === "OBVIOUS_RESERVED") {
462
454
  // The regex floor has no length limit — reserved vocabulary anywhere
463
455
  // in the full text (including the middle the excerpt can't see)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.21.24",
3
+ "version": "20.21.26",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Persistent session memory for AI coding agents that never leaves your machine — including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
6
6
  "module": "index.ts",