prism-mcp-server 20.5.2 → 20.6.0

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
@@ -56,11 +56,120 @@ Prism works locally without an account, API key, or cloud subscription. Add a
56
56
  Synalux subscription when you want cloud memory, paid-tier skills, or team
57
57
  features.
58
58
 
59
+ ### What `prism connect` changes about host subagents
60
+
61
+ `connect` steers bounded work to `prism_infer` on your machine rather than to
62
+ host-spawned agents. What it writes differs per host, and **it does not disable
63
+ subagents everywhere** — Claude Code keeps them and is pointed at an economy
64
+ model instead. Prism's local workers stay available over MCP in every case.
65
+
66
+ | Host | Setting written | Effect |
67
+ |---|---|---|
68
+ | Claude Code | `env.CLAUDE_CODE_SUBAGENT_MODEL = "sonnet"` in `~/.claude/settings.json` | Subagents stay **enabled**, pinned to an economy model. Fan-out is discouraged by policy text, not by config |
69
+ | Gemini CLI | `experimental.enableAgents = false` in `~/.gemini/settings.json` | Subagents **off**. Gemini exposes one boolean, so that is all there is to set |
70
+ | Codex | `features.multi_agent = false` in `$CODEX_HOME/config.toml` (default `~/.codex`), plus a bounded fallback: 2 threads, depth 1, cheap subagent model, 900s cap | Subagents **off**, with a bounded profile underneath so a deliberate re-enable lands somewhere sane |
71
+
72
+ Two things worth knowing:
73
+
74
+ - **`experimental` is Gemini's namespace, not ours.** Prism is not enabling
75
+ anything experimental — it writes `false` to a flag Gemini already defines at
76
+ that path. Writing anywhere else would have no effect.
77
+ - **That namespace is by definition temporary.** If Gemini promotes
78
+ `enableAgents` out of `experimental`, Prism keeps writing the old path, Gemini
79
+ reads the new one, and host subagents quietly turn back on. Nothing errors and
80
+ the settings file still looks correct. If you see host subagents running while
81
+ `enableAgents` reads `false`, check whether the key has moved before assuming
82
+ `connect` failed to write it.
83
+
84
+ Both writes are idempotent in the sense that a host already configured this way
85
+ is left untouched — but they are **re-applied on every `prism connect` run**,
86
+ not only on `--refresh`. If you deliberately re-enable host subagents, the next
87
+ `connect` will turn them off again. Keep them on by not re-running `connect`,
88
+ or by re-enabling after each run.
89
+
59
90
  ---
60
91
 
61
92
  <details>
62
93
  <summary>Release history (optional)</summary>
63
94
 
95
+ ## What's New in v20.6.0
96
+
97
+ ### Delivery Is Not a Suggestion
98
+
99
+ An audit of a real incident (an agent wiped demo data after *announcing* the
100
+ wipe — with the ask-first rule committed, bundled, and absent from what any
101
+ agent actually received) found the protected floor had outgrown every delivery
102
+ budget: "unprotected" had quietly come to mean "never delivered".
103
+
104
+ - **`ask-first` and `feature-preservation` join the protected floor** (14 → 16).
105
+ Protected skills are always inlined; these two now reach every session.
106
+ - **Sync conflicts are loud and named.** Startup used to say "· 2 local
107
+ conflicts preserved" while safety skills sat months stale; it now names each
108
+ frozen skill and states how to resume updates.
109
+ - **`--storage` accepts `auto` and `synalux`** — the CLI rejected its own
110
+ documented default and the production backend.
111
+ - **Disclosure:** skill delivery informs; it does not gate. A live probe showed
112
+ a host agent still edit unverified source with the rule loaded. If your
113
+ threat model includes an agent acting against a loaded rule under task
114
+ pressure, pair this package with mechanical gates (hooks, permissions,
115
+ least-privilege roles). True of every prior release; stated from this one.
116
+
117
+ ## What's New in v20.5.3
118
+
119
+ ### Grounding Evidence Carries Its Age
120
+
121
+ Memory-grounded answers labelled their sources but never dated them, so a
122
+ two-year-old note and yesterday's reached the model identically. Nothing in the
123
+ evidence let it discount the stale one. Prompted by an external review naming
124
+ the right risk for local-first memory: *the data stays local, but bad grounding
125
+ becomes permanent* — storing everything on your machine removes the outside
126
+ pressure that would otherwise surface a stale note.
127
+
128
+ Evidence now reads:
129
+
130
+ ```
131
+ [SOURCE 1: ledger:8286581d (recorded 2025-05-29, 431 days ago)]
132
+ ```
133
+
134
+ The date already existed in storage and was being dropped at the snippet layer,
135
+ so this is plumbing rather than new data collection. Zone-less SQLite
136
+ timestamps are normalised to UTC — read as local, a ten-minute-old record
137
+ parsed hours into the future and its age was suppressed entirely, meaning the
138
+ feature silently did nothing on the freshest memories. An absent or unparseable
139
+ date renders as nothing rather than defaulting to now; defaulting would make
140
+ the oldest memories, the ones most likely to be stale, appear freshest.
141
+
142
+ `tests/integration/grounding-staleness.test.ts` runs the reviewer's own probe —
143
+ seed a deliberately outdated note beside a contradicting fresh one and assert
144
+ the model receives both, visibly dated. Anyone can run it.
145
+
146
+ **Not solved, and not claimed:** retrieval does not weight recency. A stale note
147
+ shown *beside* a fresh one is the easy case — the model sees both dates and can
148
+ weigh them. The hard case is a stale note retrieved *alone*, because ranking is
149
+ by keyword match and an old store returns old results; then the age label is the
150
+ only defence and there is no fresher record to compare against. Tracked as
151
+ `TECH_DEBT.md` #4.
152
+
153
+ ## What's New in v20.5.0 – v20.5.2
154
+
155
+ ### The First Message Never Leaves Your Machine
156
+
157
+ Symptom-triggered skills — the rules that fire on "can't see X", "no rows",
158
+ "the list is empty" — are meant to load on the turn an incident report arrives.
159
+ They never did: every host template called `session_bootstrap` with `{}`, so
160
+ there was no prompt to match against.
161
+
162
+ Fixing that raised the question of where matching happens. It now happens
163
+ locally. The 28 keyword rules are already public, so there was nothing a local
164
+ match could not compute, and `callPortal()` has no `prompt` parameter at all —
165
+ the guarantee is structural, not a promise. The portal request carries the
166
+ project and role only.
167
+
168
+ A matched rule now arrives as **content**, not as a name. Native hosts outside
169
+ the skill-file mirror had no way to read a rule they were only told about, so
170
+ the rule body is inlined into the startup display, bounded and sized against
171
+ the real per-project budget.
172
+
64
173
  ## What's New in v20.4.0
65
174
 
66
175
  ### An Explicitly Named Cloud Backend Fails Loud
package/dist/cli.js CHANGED
@@ -393,7 +393,11 @@ program
393
393
  // v9.2.2: --storage flag overrides PRISM_STORAGE env var to prevent
394
394
  // split-brain when CLI environment differs from MCP server config.
395
395
  if (storage) {
396
- const validStorages = ['local', 'supabase'];
396
+ // Must mirror the PRISM_STORAGE union in config.ts. This list shipped
397
+ // as ['local','supabase'] — rejecting 'auto' (the documented default in
398
+ // this very command's --storage help text) and 'synalux' (the actual
399
+ // production backend). Found 2026-08-03; the config type is the truth.
400
+ const validStorages = ['auto', 'local', 'supabase', 'synalux'];
397
401
  if (!validStorages.includes(storage)) {
398
402
  console.error(`Error: Invalid storage "${storage}". Must be one of: ${validStorages.join(', ')}`);
399
403
  process.exit(1);
@@ -512,7 +516,11 @@ saveCmd
512
516
  try {
513
517
  // Storage override
514
518
  if (options.storage) {
515
- const validStorages = ['local', 'supabase'];
519
+ // Must mirror the PRISM_STORAGE union in config.ts. This list shipped
520
+ // as ['local','supabase'] — rejecting 'auto' (the documented default in
521
+ // this very command's --storage help text) and 'synalux' (the actual
522
+ // production backend). Found 2026-08-03; the config type is the truth.
523
+ const validStorages = ['auto', 'local', 'supabase', 'synalux'];
516
524
  if (!validStorages.includes(options.storage)) {
517
525
  console.error(`Error: Invalid storage "${options.storage}". Must be one of: ${validStorages.join(', ')}`);
518
526
  process.exit(1);
@@ -565,7 +573,11 @@ saveCmd
565
573
  try {
566
574
  // Storage override
567
575
  if (options.storage) {
568
- const validStorages = ['local', 'supabase'];
576
+ // Must mirror the PRISM_STORAGE union in config.ts. This list shipped
577
+ // as ['local','supabase'] — rejecting 'auto' (the documented default in
578
+ // this very command's --storage help text) and 'synalux' (the actual
579
+ // production backend). Found 2026-08-03; the config type is the truth.
580
+ const validStorages = ['auto', 'local', 'supabase', 'synalux'];
569
581
  if (!validStorages.includes(options.storage)) {
570
582
  console.error(`Error: Invalid storage "${options.storage}". Must be one of: ${validStorages.join(', ')}`);
571
583
  process.exit(1);
package/dist/connect.js CHANGED
@@ -60,15 +60,48 @@ const LEGACY_CLAUDE_PROJECT_PRISM_ENTRY = {
60
60
  };
61
61
  const INSTALLATION_RECEIPT_SCHEMA_VERSION = 1;
62
62
  const INSTALLATION_RECEIPT_OWNER = "prism-connect";
63
+ /**
64
+ * Economy subagent model per host.
65
+ *
66
+ * The local-first policy already permits exactly this — "a host-native subagent
67
+ * is a last resort: at most one, no nesting, using the configured economy
68
+ * model" — but only Claude was configured that way. Gemini and Codex banned
69
+ * subagents outright, which is stricter than the policy Prism publishes.
70
+ *
71
+ * The stated reason for Gemini was "native/remote subagents". That does not
72
+ * survive checking: Gemini CLI subagents execute locally, in-process, with
73
+ * optional local container sandboxes. The model is remote, which is equally
74
+ * true of Claude and Codex. There was no egress difference to justify the
75
+ * asymmetry.
76
+ *
77
+ * Cursor is deliberately absent. Its Background Agents really do run in remote
78
+ * cloud sandboxes with repository access — a different risk class — and no
79
+ * subagent-model setting was found for it.
80
+ */
63
81
  const CLAUDE_FALLBACK_SUBAGENT_MODEL = "sonnet";
82
+ /** Fastest tier of the GPT-5.6 line (Sol > Terra > Luna) for simple work. */
83
+ const CODEX_ECONOMY_SUBAGENT_MODEL = "gpt-5.6-luna";
84
+ /**
85
+ * Gemini's speed tier for agentic work.
86
+ *
87
+ * Confirmed working in practice. An earlier revision changed this to
88
+ * `gemini-3-flash-preview` because `gemini-3.6-flash` appears in none of the
89
+ * 116 files of gemini-cli 0.49.0 — but the CLI does not validate Gemini model
90
+ * names at all. Its only ALLOWED_MODELS list gates Whisper speech binaries.
91
+ * Model names are passed through to the API, so absence from the bundle says
92
+ * nothing about resolvability. That reversion was a regression to an older ID.
93
+ */
94
+ const GEMINI_ECONOMY_SUBAGENT_MODEL = "gemini-3.6-flash";
64
95
  const CODEX_LOCAL_FIRST_POLICY = {
65
96
  features: {
66
- multi_agent: false,
97
+ // Subagents permitted, but bounded: the ceiling below is what keeps this
98
+ // from becoming fan-out. Previously false, which contradicted the policy.
99
+ multi_agent: true,
67
100
  },
68
101
  agents: {
69
- max_threads: 2,
70
- max_depth: 1,
71
- default_subagent_model: "gpt-5.6-terra",
102
+ max_threads: 1, // "at most one" — was 2
103
+ max_depth: 1, // "no nesting"
104
+ default_subagent_model: CODEX_ECONOMY_SUBAGENT_MODEL,
72
105
  default_subagent_reasoning_effort: "low",
73
106
  job_max_runtime_seconds: 900,
74
107
  },
@@ -827,7 +860,21 @@ export function configureClaudeAgentPolicy(homeDir = homedir(), dryRun = false,
827
860
  return true;
828
861
  }, dryRun, beforeCommit);
829
862
  }
830
- /** Disable Gemini's native/remote subagents; Prism local workers remain available over MCP. */
863
+ /**
864
+ * Pin Gemini's subagents to the economy model rather than banning them.
865
+ *
866
+ * Previously this wrote `enableAgents = false`, which is stricter than the
867
+ * local-first policy Prism publishes — that policy allows "at most one, no
868
+ * nesting, using the configured economy model", and Claude was configured
869
+ * exactly that way. The stated reason was "native/remote subagents", which does
870
+ * not hold: Gemini CLI subagents execute locally, in-process, with optional
871
+ * local container sandboxes. Only the model is remote, as it is for every host.
872
+ *
873
+ * `agents.overrides.<name>.modelConfig.model` is the documented shape. Only
874
+ * `codebase_investigator` is pinned: it is the built-in whose name is published,
875
+ * and inventing names for the others would write dead config. Unpinned
876
+ * subagents inherit the session model, which is the pre-existing behaviour.
877
+ */
831
878
  export function configureGeminiAgentPolicy(homeDir = homedir(), dryRun = false, beforeCommit) {
832
879
  const configPath = join(homeDir, ".gemini", "settings.json");
833
880
  return configureJsonAgentPolicy(configPath, "Gemini agent settings", (config) => {
@@ -835,11 +882,42 @@ export function configureGeminiAgentPolicy(homeDir = homedir(), dryRun = false,
835
882
  if (currentExperimental !== undefined && !isJsonObject(currentExperimental)) {
836
883
  throw new Error('"experimental" must be a JSON object');
837
884
  }
885
+ const currentAgents = config.agents;
886
+ if (currentAgents !== undefined && !isJsonObject(currentAgents)) {
887
+ throw new Error('"agents" must be a JSON object');
888
+ }
838
889
  const experimental = (currentExperimental ?? {});
839
- if (experimental.enableAgents === false)
890
+ const agents = (currentAgents ?? {});
891
+ const currentOverrides = agents.overrides;
892
+ if (currentOverrides !== undefined && !isJsonObject(currentOverrides)) {
893
+ throw new Error('"agents.overrides" must be a JSON object');
894
+ }
895
+ const overrides = (currentOverrides ?? {});
896
+ const investigator = (isJsonObject(overrides.codebase_investigator)
897
+ ? overrides.codebase_investigator
898
+ : {});
899
+ const modelConfig = (isJsonObject(investigator.modelConfig)
900
+ ? investigator.modelConfig
901
+ : {});
902
+ // Respect a deliberate opt-out. Prism previously forced enableAgents=false
903
+ // on every run; flipping that to an unconditional `true` would repeat the
904
+ // same mistake pointing the other way, and switching something ON that a
905
+ // user turned OFF is the more intrusive direction. Only enable when the
906
+ // key is absent — pin the model either way, so a user who keeps subagents
907
+ // off still gets the economy default if they later turn them on.
908
+ const userDisabledDeliberately = experimental.enableAgents === false;
909
+ const alreadyApplied = (userDisabledDeliberately || experimental.enableAgents === true)
910
+ && modelConfig.model === GEMINI_ECONOMY_SUBAGENT_MODEL;
911
+ if (alreadyApplied)
840
912
  return false;
841
- experimental.enableAgents = false;
913
+ if (!userDisabledDeliberately)
914
+ experimental.enableAgents = true;
915
+ modelConfig.model = GEMINI_ECONOMY_SUBAGENT_MODEL;
916
+ investigator.modelConfig = modelConfig;
917
+ overrides.codebase_investigator = investigator;
918
+ agents.overrides = overrides;
842
919
  config.experimental = experimental;
920
+ config.agents = agents;
843
921
  return true;
844
922
  }, dryRun, beforeCommit);
845
923
  }
@@ -933,7 +1011,12 @@ export function configureCodexAgentPolicy(homeDir, dryRun = false, beforeCommit,
933
1011
  : undefined;
934
1012
  if (!parsedFeatures
935
1013
  || !parsedAgents
936
- || !isDeepStrictEqual(parsedFeatures.multi_agent, false)
1014
+ // Validate against the declared policy, not a literal. Hardcoding `false`
1015
+ // here meant the guard rejected any change to CODEX_LOCAL_FIRST_POLICY
1016
+ // rather than verifying the write matched it — a guard pinned to a value
1017
+ // instead of to the invariant it exists to protect.
1018
+ || !Object.entries(CODEX_LOCAL_FIRST_POLICY.features)
1019
+ .every(([key, value]) => isDeepStrictEqual(parsedFeatures[key], value))
937
1020
  || !Object.entries(CODEX_LOCAL_FIRST_POLICY.agents)
938
1021
  .every(([key, value]) => isDeepStrictEqual(parsedAgents[key], value))) {
939
1022
  throw new Error("Generated Codex local-first policy failed validation");
@@ -268,6 +268,16 @@ export async function knowledgeSearchHandler(args) {
268
268
  if (data.results && Array.isArray(data.results) && data.results.length > 0) {
269
269
  const evidenceSnippets = data.results.map((r, i) => ({
270
270
  source: `knowledge_search:${r.id ?? i}`,
271
+ // Carry the record date. Without it a two-year-old note and yesterday's
272
+ // are indistinguishable in the grounding evidence, so a stale memory is
273
+ // cited with the same confidence as a fresh one — local storage removes
274
+ // the external correction pressure that would otherwise catch it.
275
+ // session_date first: it is when the work HAPPENED, which is what a
276
+ // staleness signal needs. created_at is when the row was written and is
277
+ // assigned on save — it cannot be backdated, so an import would stamp
278
+ // every migrated memory as brand new, failing silently toward "fresh".
279
+ // sqlite.ts already treats session_date as authoritative.
280
+ recorded: r.session_date ?? r.created_at ?? r.updated_at ?? r.timestamp ?? undefined,
271
281
  content: (r.content ?? r.summary ?? r.text ?? "").slice(0, 1000),
272
282
  })).filter((s) => s.content);
273
283
  if (evidenceSnippets.length > 0) {
@@ -624,6 +634,7 @@ export async function sessionSearchMemoryHandler(args) {
624
634
  {
625
635
  const evidenceSnippets = results.map((r, i) => ({
626
636
  source: `session_search_memory:${r.id ?? i}`,
637
+ recorded: r.session_date ?? r.created_at ?? r.updated_at ?? r.timestamp ?? undefined,
627
638
  content: (r.summary ?? "").slice(0, 1000),
628
639
  })).filter((s) => s.content);
629
640
  if (evidenceSnippets.length > 0) {
@@ -276,8 +276,20 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
276
276
  const superSkills = snapshot.names.filter((name) => name.endsWith("-super-skill"));
277
277
  const superSkillAliases = superSkills.map((name) => `${name.slice(0, -"-super-skill".length)} (${name})`);
278
278
  const otherTierSkills = snapshot.names.filter((name) => !coreSkillSet.has(name) && !name.endsWith("-super-skill"));
279
+ // Conflicts must be LOUD, named, and actionable. This used to render as
280
+ // "· 2 local conflicts preserved" — a count with benign phrasing, so two
281
+ // safety skills (ask-first among them) sat 4 months stale on disk while
282
+ // every sync silently skipped them and nobody was ever told which ones.
279
283
  const conflictSuffix = snapshot.conflicts.length > 0
280
- ? ` · ${snapshot.conflicts.length} local conflict${snapshot.conflicts.length === 1 ? "" : "s"} preserved`
284
+ ? ` · ${snapshot.conflicts.length} conflict${snapshot.conflicts.length === 1 ? "" : "s"} — see warning`
285
+ : "";
286
+ const conflictWarning = snapshot.conflicts.length > 0
287
+ ? `\n> - ⚠️ **SKILLS NOT UPDATING (local copy has no Prism ownership marker):** ` +
288
+ `${formatBoundedSkillNames([...snapshot.conflicts].sort(), "blocked")}. ` +
289
+ `Each named skill is frozen at whatever version is on disk — updates are ` +
290
+ `withheld to protect local edits. To resume updates: move the skill's ` +
291
+ `directory out of the native skills folder and rerun \`prism connect\` ` +
292
+ `(or a session bootstrap) to reinstall the managed copy.`
281
293
  : "";
282
294
  if (snapshot.source === "validated-partial") {
283
295
  return `> **Prism System Ready**\n>\n` +
@@ -287,14 +299,16 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
287
299
  `> - 🧩 **Super-skill entitlements:** ${formatBoundedSkillNames(superSkillAliases, "entitled")}\n` +
288
300
  `> - 🛠️ **Other tier entitlements:** ${formatBoundedSkillNames(otherTierSkills, "entitled")}\n` +
289
301
  `> - 🧠 **Context depth:** ${depth}\n` +
290
- `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · native materialization incomplete${conflictSuffix}`;
302
+ `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · native materialization incomplete${conflictSuffix}` +
303
+ conflictWarning;
291
304
  }
292
305
  if (snapshot.source === "tier-fallback") {
293
306
  return `> **Prism System Ready**\n>\n` +
294
307
  `> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
295
308
  `> - 🛡️ **Fallback skill names:** ${formatBoundedSkillNames(snapshot.names, "fallback")}\n` +
296
309
  `> - 🧠 **Context depth:** ${depth}\n` +
297
- `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · no committed manifest${conflictSuffix}`;
310
+ `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · no committed manifest${conflictSuffix}` +
311
+ conflictWarning;
298
312
  }
299
313
  return `> **Prism System Ready**\n>\n` +
300
314
  `> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
@@ -303,7 +317,8 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
303
317
  `> - 🧩 **Super-skills provisioned:** ${formatBoundedSkillNames(superSkillAliases, "provisioned")}\n` +
304
318
  `> - 🛠️ **Other tier skills provisioned:** ${formatBoundedSkillNames(otherTierSkills, "provisioned")}\n` +
305
319
  `> - 🧠 **Context depth:** ${depth}\n` +
306
- `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · committed manifest${conflictSuffix}`;
320
+ `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · committed manifest${conflictSuffix}` +
321
+ conflictWarning;
307
322
  }
308
323
  function capNativeStartupText(text, level, requestedMaxChars, suffix = "") {
309
324
  const maxChars = effectiveNativeBudget(level, requestedMaxChars);
@@ -55,7 +55,7 @@ const GROUNDED_SYNTHESIS_SYSTEM = "Answer the user's question using only the sup
55
55
  "For clinical or behavioral topics, provide educational candidates for credentialed review, " +
56
56
  "not individualized treatment instructions or professional sign-off. " +
57
57
  "If the evidence is insufficient, say exactly what is missing instead of guessing.";
58
- function extractMemorySources(result) {
58
+ export function extractMemorySources(result) {
59
59
  if (result.isError) {
60
60
  throw new Error(result.content[0]?.text || "knowledge_search failed");
61
61
  }
@@ -82,6 +82,7 @@ function extractMemorySources(result) {
82
82
  sources.push({
83
83
  type: "memory",
84
84
  source: snippet.source,
85
+ recorded: typeof snippet.recorded === "string" ? snippet.recorded : undefined,
85
86
  content: snippet.content.slice(0, MAX_EVIDENCE_CHARS),
86
87
  });
87
88
  }
@@ -224,13 +225,49 @@ const DEFAULT_DEPS = {
224
225
  function toEvidence(sources) {
225
226
  return sources.map(({ source, content }) => ({ source, content }));
226
227
  }
227
- function buildGroundedEvidenceContext(sources) {
228
+ /**
229
+ * SQLite `CURRENT_TIMESTAMP` writes "YYYY-MM-DD HH:MM:SS" in UTC with no zone,
230
+ * and `Date.parse` reads a zone-less stamp as LOCAL time. West of UTC that puts
231
+ * a ten-minute-old record hours in the FUTURE, which the guard above then hid
232
+ * entirely — the age disappeared exactly when the memory was freshest. Formats
233
+ * are not uniform across tables (semantic_knowledge writes ISO+Z, memory_links
234
+ * does not), so normalise rather than assume.
235
+ */
236
+ function parseRecordedAt(raw) {
237
+ const zoneless = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?$/;
238
+ return Date.parse(zoneless.test(raw) ? `${raw.replace(" ", "T")}Z` : raw);
239
+ }
240
+ /**
241
+ * " (recorded 2026-08-02, 431 days ago)" — or "" when the store gave no date.
242
+ * Never guesses: an absent date stays absent rather than defaulting to now,
243
+ * which would make the oldest memories look freshest.
244
+ */
245
+ export function describeAge(source) {
246
+ const recorded = source.type === "memory" ? source.recorded : undefined;
247
+ if (!recorded)
248
+ return "";
249
+ const at = parseRecordedAt(recorded);
250
+ if (Number.isNaN(at))
251
+ return "";
252
+ const elapsed = Date.now() - at;
253
+ // Tolerate clock skew between machines: slightly-future stamps are "today",
254
+ // not silence. Only a stamp more than a day ahead is treated as bad data.
255
+ if (elapsed < -86_400_000)
256
+ return "";
257
+ const days = Math.max(0, Math.floor(elapsed / 86_400_000));
258
+ const day = recorded.slice(0, 10);
259
+ return days === 0 ? ` (recorded ${day}, today)` : ` (recorded ${day}, ${days} days ago)`;
260
+ }
261
+ export function buildGroundedEvidenceContext(sources) {
228
262
  let remaining = MAX_SYNTHESIS_EVIDENCE_CHARS;
229
263
  const evidenceBlocks = [];
230
264
  for (const [index, source] of sources.entries()) {
231
265
  if (remaining <= 0)
232
266
  break;
233
- const label = `[SOURCE ${index + 1}: ${source.source}]`;
267
+ // Age belongs in the label, not just the payload. An undated fragment
268
+ // cannot be reasoned about; a fragment labelled two years old can be
269
+ // weighed against fresher evidence or challenged outright.
270
+ const label = `[SOURCE ${index + 1}: ${source.source}${describeAge(source)}]`;
234
271
  const availableForContent = Math.max(0, remaining - label.length - 1);
235
272
  if (availableForContent === 0)
236
273
  break;
@@ -46,6 +46,13 @@ export const REQUIRED_PROTECTED_SKILL_NAMES = [
46
46
  // so the two sources disagreed. A portal test now asserts they match.
47
47
  'data-before-code',
48
48
  'critical-thinking-debug',
49
+ // Added 2026-08-03. The floor had grown past every budget tranche, so an
50
+ // unprotected universal skill could NEVER inline — including the destruction
51
+ // gate, during the very audit of a demo-venue wipe it should have prevented.
52
+ // If these two are absent, nothing tells the agent that announcing a wipe is
53
+ // not the same as being allowed to run it.
54
+ 'ask-first',
55
+ 'feature-preservation',
49
56
  ];
50
57
  /**
51
58
  * Native skills that paid subscription tiers receive through `prism connect`.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.5.2",
3
+ "version": "20.6.0",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
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.",
5
+ "description": "Prism Coder \u2014 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\u201332B open-weights LLM fleet.",
6
6
  "module": "index.ts",
7
7
  "type": "module",
8
8
  "main": "dist/server.js",