@prismer/runtime 2.0.8 → 2.2.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +3430 -0
  2. package/README.md +34 -12
  3. package/apc/skills/FIELD-DICTIONARY.md +111 -0
  4. package/apc/skills/bug-reproduce/SKILL.md +150 -0
  5. package/apc/skills/bug-reproduce/skill.json +96 -0
  6. package/apc/skills/code-review/SKILL.md +198 -0
  7. package/apc/skills/code-review/skill.json +124 -0
  8. package/apc/skills/design-review/SKILL.md +122 -0
  9. package/apc/skills/design-review/skill.json +88 -0
  10. package/apc/skills/doc-sync/SKILL.md +168 -0
  11. package/apc/skills/doc-sync/skill.json +81 -0
  12. package/apc/skills/env-doctor/SKILL.md +194 -0
  13. package/apc/skills/env-doctor/skill.json +209 -0
  14. package/apc/skills/git-ops/SKILL.md +189 -0
  15. package/apc/skills/git-ops/skill.json +94 -0
  16. package/apc/skills/impact-trace/SKILL.md +168 -0
  17. package/apc/skills/impact-trace/skill.json +104 -0
  18. package/apc/skills/observability/SKILL.md +195 -0
  19. package/apc/skills/observability/skill.json +116 -0
  20. package/apc/skills/release-db-config-sync/SKILL.md +186 -0
  21. package/apc/skills/release-db-config-sync/skill.json +109 -0
  22. package/apc/skills/release-ota-promote/SKILL.md +195 -0
  23. package/apc/skills/release-ota-promote/skill.json +176 -0
  24. package/apc/skills/release-preflight/SKILL.md +174 -0
  25. package/apc/skills/release-preflight/skill.json +175 -0
  26. package/apc/skills/release-rollback/SKILL.md +214 -0
  27. package/apc/skills/release-rollback/skill.json +230 -0
  28. package/apc/skills/release-tag/SKILL.md +194 -0
  29. package/apc/skills/release-tag/skill.json +94 -0
  30. package/apc/skills/releasing-prod/SKILL.md +49 -0
  31. package/apc/skills/releasing-test/SKILL.md +135 -0
  32. package/apc/skills/sdk-release/SKILL.md +200 -0
  33. package/apc/skills/spec-intake/SKILL.md +169 -0
  34. package/apc/skills/spec-intake/skill.json +93 -0
  35. package/apc/skills/test-result-feedback/SKILL.md +239 -0
  36. package/apc/skills/test-result-feedback/skill.json +193 -0
  37. package/apc/skills/test-runner/SKILL.md +169 -0
  38. package/apc/skills/test-runner/skill.json +103 -0
  39. package/apc/skills/ui-align/SKILL.md +209 -0
  40. package/apc/skills/ui-align/skill.json +114 -0
  41. package/apc/skills/ui-canvas/SKILL.md +148 -0
  42. package/apc/skills/ui-canvas/skill.json +127 -0
  43. package/built-in-skills/agent-coordination/SKILL.md +59 -37
  44. package/built-in-skills/agent-meta/SKILL.md +1 -0
  45. package/built-in-skills/assets/SKILL.md +8 -6
  46. package/built-in-skills/browser-use/SKILL.md +93 -0
  47. package/built-in-skills/canvas-design/SKILL.md +1 -0
  48. package/built-in-skills/claim-agent-ownership/SKILL.md +3 -2
  49. package/built-in-skills/claude-api/SKILL.md +1 -0
  50. package/built-in-skills/codebase-design/DEEPENING.md +37 -0
  51. package/built-in-skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
  52. package/built-in-skills/codebase-design/LICENSE +21 -0
  53. package/built-in-skills/codebase-design/SKILL.md +116 -0
  54. package/built-in-skills/conversation-compaction/SKILL.md +114 -0
  55. package/built-in-skills/council-creator/SKILL.md +426 -0
  56. package/built-in-skills/diagnosing-bugs/LICENSE +21 -0
  57. package/built-in-skills/diagnosing-bugs/SKILL.md +136 -0
  58. package/built-in-skills/diagnosing-bugs/scripts/hitl-loop.template.sh +41 -0
  59. package/built-in-skills/doc-coauthoring/SKILL.md +1 -0
  60. package/built-in-skills/document-generation/SKILL.md +105 -0
  61. package/built-in-skills/domain-modeling/ADR-FORMAT.md +47 -0
  62. package/built-in-skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  63. package/built-in-skills/domain-modeling/LICENSE +21 -0
  64. package/built-in-skills/domain-modeling/SKILL.md +76 -0
  65. package/built-in-skills/frontend-design/SKILL.md +1 -0
  66. package/built-in-skills/human-approval/SKILL.md +17 -2
  67. package/built-in-skills/image-generate/SKILL.md +103 -302
  68. package/built-in-skills/image-generate/scripts/generate-and-deliver.mjs +289 -0
  69. package/built-in-skills/ingest/SKILL.md +13 -45
  70. package/built-in-skills/internal-comms/SKILL.md +1 -0
  71. package/built-in-skills/liteparse/SKILL.md +130 -110
  72. package/built-in-skills/mcp-builder/SKILL.md +1 -0
  73. package/built-in-skills/memory/SKILL.md +420 -55
  74. package/built-in-skills/memory-dream/SKILL.md +339 -0
  75. package/built-in-skills/office-artifacts/SKILL.md +17 -4
  76. package/built-in-skills/okr/SKILL.md +154 -0
  77. package/built-in-skills/persona/SKILL.md +81 -0
  78. package/built-in-skills/persona-generator/SKILL.md +296 -0
  79. package/built-in-skills/pkf-svg/SKILL.md +253 -0
  80. package/built-in-skills/pkf-writing/SKILL.md +236 -0
  81. package/built-in-skills/prismer-im-collab/SKILL.md +26 -6
  82. package/built-in-skills/proactivity/SKILL.md +84 -0
  83. package/built-in-skills/remotion/SKILL.md +431 -0
  84. package/built-in-skills/role-builder/SKILL.md +203 -0
  85. package/built-in-skills/role-builder/scripts/author-role.mjs +334 -0
  86. package/built-in-skills/role-builder/scripts/ingest-role.mjs +223 -0
  87. package/built-in-skills/role-builder/scripts/instantiate-and-run.mjs +290 -0
  88. package/built-in-skills/role-builder/scripts/operation-harness.mjs +267 -0
  89. package/built-in-skills/skill-authoring/SKILL.md +110 -100
  90. package/built-in-skills/skill-authoring/skill.json +3 -3
  91. package/built-in-skills/skill-builder/SKILL.md +171 -0
  92. package/built-in-skills/skill-builder/scripts/ingest.mjs +265 -0
  93. package/built-in-skills/skill-creator/SKILL.md +165 -423
  94. package/built-in-skills/skill-creator/references/external-library-import.md +110 -0
  95. package/built-in-skills/skill-creator/scripts/import-library.mjs +475 -0
  96. package/built-in-skills/slack-gif-creator/SKILL.md +20 -0
  97. package/built-in-skills/tasks/SKILL.md +38 -23
  98. package/built-in-skills/tdd/LICENSE +21 -0
  99. package/built-in-skills/tdd/SKILL.md +110 -0
  100. package/built-in-skills/tdd/mocking.md +59 -0
  101. package/built-in-skills/tdd/refactoring.md +10 -0
  102. package/built-in-skills/tdd/tests.md +61 -0
  103. package/built-in-skills/team/SKILL.md +2 -1
  104. package/built-in-skills/web-artifacts-builder/SKILL.md +1 -0
  105. package/built-in-skills/webapp-testing/SKILL.md +1 -0
  106. package/built-in-skills/wechat-pay/SKILL.md +59 -0
  107. package/dist/cli.cjs +71872 -19960
  108. package/dist/cli.js +71803 -19846
  109. package/dist/index.cjs +72010 -19966
  110. package/dist/index.d.cts +4258 -712
  111. package/dist/index.d.ts +4258 -712
  112. package/dist/index.js +72156 -20118
  113. package/package.json +37 -6
  114. package/plugins/memory/prismer/__init__.py +1211 -0
  115. package/plugins/memory/prismer/plugin.yaml +8 -0
  116. package/plugins/memory/prismer/tool-schemas.generated.json +249 -0
  117. package/plugins/tools/prismer-recall/__init__.py +282 -0
  118. package/plugins/tools/prismer-recall/plugin.yaml +15 -0
  119. package/built-in-skills/memory-curation/SKILL.md +0 -135
@@ -0,0 +1,1211 @@
1
+ """Prismer Cloud memory provider — MemoryProvider ABC thin shell.
2
+
3
+ Desktop-202 doc 18 §4 — the **main正门** for Hermes memory integration. This is
4
+ a ~200-line protocol-translation shell: all memory logic lives in the Prismer
5
+ daemon (TypeScript). Every method here is a thin HTTP call to the daemon's
6
+ local loopback endpoints (``127.0.0.1:$PRISMER_DAEMON_PORT``, doc 18 §4c). The
7
+ daemon owns the SQLite FTS5 store, the cloud sync, the ACL predicate, and the
8
+ extract pipeline — the shell only speaks the Hermes ABC and forwards.
9
+
10
+ Activating this provider (``memory.provider: prismer`` in the profile config,
11
+ written by the daemon's ``ensureService``) **structurally excludes Honcho**:
12
+ Hermes allows exactly one external provider (doc 18 §4b#1). The four悬案 of the
13
+ double-memory-stack (envelope.recent redundancy / L2 double-compress /
14
+ user-profiling ownership) are resolved by ceding the whole layer.
15
+
16
+ Verb mapping (doc 18 §3a/§4c, v8.1 召回哲学):
17
+ - prefetch() → SHADOW only. Returns "" (NO injection). Archive recall
18
+ is agent-driven via tools, not per-turn auto-inject;
19
+ prefetch fires a shadow-observe ping the daemon counts.
20
+ - get_tool_schemas() → recall-tools正门 (P0): memory_search / memory_load,
21
+ registered with the model so the AGENT decides when to
22
+ recall. handle_tool_call forwards to the daemon store.
23
+ memory_curate (MVP4 phase-1) is also registered here —
24
+ a knowledge-base maintenance verb that forwards to the
25
+ CLOUD curation endpoints (orchestrator-gated cloud-side).
26
+ - sync_turn() → in-process automatic-extraction TRIGGER (memory203/14):
27
+ POSTs the turn to the daemon's post_llm_call hook intake
28
+ with at-least-once delivery + a chain trace_id
29
+ (memory203/18 R9.1/R8.1). The extraction LLM runs in the
30
+ agent's own pod runtime — never the cloud (§0.5).
31
+ - on_pre_compress() → NO-OP (memory203/13 §0.5). Same reason — no cloud extract
32
+ forward; in-runtime auto-extraction is the replacement.
33
+ - on_memory_write() → built-in MEMORY.md收编: mirror agent add/replace/remove
34
+ into the substrate (agent-private files).
35
+ - system_prompt_block() → the C channel (core-inject) is delivered by the
36
+ daemon writing the MEMORY.md managed section directly;
37
+ this block carries only a one-line provider status.
38
+
39
+ Config (profile .env, written by the daemon — user-invisible):
40
+ PRISMER_DAEMON_PORT — daemon loopback port (default 7878)
41
+ PRISMER_DAEMON_TOKEN — optional bearer for the local data-plane (doc 11 §3a)
42
+ PRISMER_WORKSPACE_ID — the workspace this profile's agent is bound to
43
+
44
+ NOTE: ``/local/memory/search`` + ``/local/memory/load`` + ``/local/memory/write``
45
+ + ``/local/memory/mirror`` are the daemon loopback routes this shell uses. The
46
+ shell degrades gracefully (logs, no-throw) if the daemon predates them, per the
47
+ "降级不中断" failure mode (doc 18 §4c). The old ``/extract-turn`` /
48
+ ``/extract-compress`` extract-forwarding is retired (memory203/13 §0.5) — this
49
+ shell no longer calls it (sync_turn / on_pre_compress are no-ops).
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import json
55
+ import logging
56
+ import os
57
+ import re
58
+ import sys
59
+ import time
60
+ import urllib.error
61
+ import urllib.parse
62
+ import urllib.request
63
+ import uuid
64
+ from typing import Any, Dict, List, Optional
65
+
66
+ from agent.memory_provider import MemoryProvider
67
+
68
+ logger = logging.getLogger(__name__)
69
+
70
+ # memory203 doc 10 §3 — the daemon's real loopback port (7878 in agent-rt;
71
+ # local-server.ts publishes it to PRISMER_DAEMON_PORT). The daemon writes that
72
+ # env into the profile .env, so this fallback only fires when the env is unset.
73
+ _DEFAULT_PORT = "7878"
74
+ _RECALL_TIMEOUT = 5
75
+ _WRITE_TIMEOUT = 8
76
+ # Curation forwards to cloud (page-dream / promote / supersede / rebuild) which
77
+ # can do real work; give it a wider budget than a local recall/write.
78
+ _CURATE_TIMEOUT = 30
79
+ # Web search/load forwards to the cloud Load API (Exa search + per-URL
80
+ # compression) via the daemon; the daemon's own forward timeout is 120s
81
+ # (live-measured: a cold 3-result query took 59s), so the shell waits slightly
82
+ # longer — the daemon, not the shell, is the timeout authority (a shell
83
+ # timeout would drop the daemon's structured error).
84
+ _WEB_TIMEOUT = 130
85
+ # Native PKF operations are local CPU work over a bounded 5 MiB source.
86
+ _PKF_TIMEOUT = 20
87
+ _PKF_BUNDLE_TIMEOUT = 125
88
+
89
+ # Hermes gateways are long-running per-profile processes while cap v2 lasts
90
+ # only 15 minutes. Keep the currently renewed token in process memory; never
91
+ # write it to profile config or logs. The initial value still comes from the
92
+ # daemon-spawned environment.
93
+ _memory_cap = (os.environ.get("PRISMER_MEMORY_CAP") or "").strip()
94
+
95
+
96
+ def _daemon_base() -> str:
97
+ port = (os.environ.get("PRISMER_DAEMON_PORT") or _DEFAULT_PORT).strip() or _DEFAULT_PORT
98
+ return f"http://127.0.0.1:{port}"
99
+
100
+
101
+ def _headers() -> Dict[str, str]:
102
+ h = {"Content-Type": "application/json"}
103
+ token = (os.environ.get("PRISMER_DAEMON_TOKEN") or "").strip()
104
+ if token:
105
+ h["Authorization"] = f"Bearer {token}"
106
+ # memory203 doc 08 §2 (F4) — per-agent scoped memory capability. The daemon
107
+ # injects PRISMER_MEMORY_CAP into this gateway process env at spawn; forward
108
+ # it on every memory RPC so the daemon can enforce workspace scope. Absent →
109
+ # omit (daemon treats as unscoped/legacy under enforce-off).
110
+ cap = _memory_cap or (os.environ.get("PRISMER_MEMORY_CAP") or "").strip()
111
+ if cap:
112
+ h["x-prismer-memory-cap"] = cap
113
+ # Public PKF routes use a domain-neutral actor hint. This value comes from
114
+ # the per-profile Runtime spawn environment, never from model tool args.
115
+ agent_im_user_id = (os.environ.get("PRISMER_AGENT_IM_USER_ID") or "").strip()
116
+ if agent_im_user_id:
117
+ h["X-Prismer-Agent"] = agent_im_user_id
118
+ return h
119
+
120
+
121
+ def _decode_json(raw: bytes) -> Any:
122
+ if not raw:
123
+ return {}
124
+ try:
125
+ return json.loads(raw.decode("utf-8"))
126
+ except Exception: # noqa: BLE001 — transport helper returns opaque failure
127
+ return None
128
+
129
+
130
+ def _refresh_memory_cap() -> bool:
131
+ """Exchange a stale signed v2 cap for current-authority claims once.
132
+
133
+ The daemon accepts renewal only when the old token is authentic for this
134
+ daemon boot and the current Cloud authority snapshot still grants its
135
+ actor/workspace. Revocation, lease expiry, tampering, and restart remain
136
+ fail-closed. This function never logs or persists either token.
137
+ """
138
+ global _memory_cap
139
+ presented = _memory_cap or (os.environ.get("PRISMER_MEMORY_CAP") or "").strip()
140
+ if not presented:
141
+ return False
142
+ url = _daemon_base() + "/local/memory/cap/refresh"
143
+ req = urllib.request.Request(
144
+ url,
145
+ data=b"{}",
146
+ headers={"Content-Type": "application/json", "x-prismer-memory-cap": presented},
147
+ method="POST",
148
+ )
149
+ try:
150
+ with urllib.request.urlopen(req, timeout=_RECALL_TIMEOUT) as resp:
151
+ payload = _decode_json(resp.read())
152
+ except Exception: # noqa: BLE001 — caller preserves the original failure
153
+ return False
154
+ renewed = payload.get("cap") if isinstance(payload, dict) else None
155
+ if not isinstance(renewed, str) or not renewed.strip():
156
+ return False
157
+ _memory_cap = renewed.strip()
158
+ return True
159
+
160
+
161
+ def _request_with_status(path: str, method: str, body: Optional[dict], timeout: int) -> Dict[str, Any]:
162
+ """Run one daemon request and renew+retry once on cap expiry."""
163
+ url = _daemon_base() + path
164
+ data = json.dumps(body).encode("utf-8") if body is not None else None
165
+
166
+ def once() -> Dict[str, Any]:
167
+ req = urllib.request.Request(url, data=data, headers=_headers(), method=method)
168
+ try:
169
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
170
+ return {"status": resp.status, "body": _decode_json(resp.read())}
171
+ except urllib.error.HTTPError as exc:
172
+ return {"status": exc.code, "body": _decode_json(exc.read())}
173
+ except Exception as exc: # noqa: BLE001 — degrade-not-break boundary
174
+ return {"status": 0, "body": None, "error": str(exc)}
175
+
176
+ result = once()
177
+ payload = result.get("body")
178
+ if (
179
+ result.get("status") == 401
180
+ and isinstance(payload, dict)
181
+ and payload.get("error") == "memory_cap_invalid"
182
+ and _refresh_memory_cap()
183
+ ):
184
+ return once()
185
+ return result
186
+
187
+
188
+ def _http_get(path: str, timeout: int) -> Optional[dict]:
189
+ result = _request_with_status(path, "GET", None, timeout)
190
+ if 200 <= result.get("status", 0) < 300:
191
+ return result.get("body")
192
+ logger.debug("prismer provider GET %s failed: status=%s", path, result.get("status", 0))
193
+ return None
194
+
195
+
196
+ def _http_get_with_status(path: str, timeout: int) -> Dict[str, Any]:
197
+ """GET that preserves daemon status/body for model-visible tool calls.
198
+
199
+ Shadow prefetch intentionally keeps the degrade-not-break ``_http_get``
200
+ behavior. Explicit tools must not turn a fail-closed 401/403 into an empty
201
+ result or the transport-only ``daemon_unreachable`` error.
202
+ """
203
+ return _request_with_status(path, "GET", None, timeout)
204
+
205
+
206
+ def _http_post(path: str, body: dict, timeout: int) -> Optional[dict]:
207
+ result = _request_with_status(path, "POST", body, timeout)
208
+ if 200 <= result.get("status", 0) < 300:
209
+ return result.get("body")
210
+ logger.debug("prismer provider POST %s failed: status=%s", path, result.get("status", 0))
211
+ return None
212
+
213
+
214
+ def _http_post_with_status(path: str, body: dict, timeout: int) -> Dict[str, Any]:
215
+ """POST that preserves the daemon's status + JSON body even on non-2xx.
216
+
217
+ Unlike ``_http_post`` (which swallows HTTP errors and returns ``None`` for
218
+ the degrade-not-break recall/write paths), curation needs the daemon's
219
+ error body to flow back to the agent — specifically the cloud's
220
+ ``orchestrator_only`` 403 passthrough — so a non-orchestrator agent sees a
221
+ clear "you are not the orchestrator" rather than a silent no-op. Returns
222
+ ``{"status": int, "body": dict|None}``; a transport failure (daemon
223
+ unreachable) yields ``status=0``.
224
+ """
225
+ return _request_with_status(path, "POST", body, timeout)
226
+
227
+
228
+ _ATTACHED_ASSETS_BLOCK_RE = re.compile(r"<attached_assets>(.*?)</attached_assets>", re.S)
229
+ _ASSET_ID_RE = re.compile(r'<asset\s[^>]*?\bid="([^"]+)"')
230
+
231
+
232
+ def _parse_attached_asset_ids(user_content: str, max_ids: int = 20) -> List[str]:
233
+ """memory203/20 §2.1 — recover attached asset ids from the turn text.
234
+
235
+ The dispatch composer (daemon/conversation-context.ts) stamps
236
+ ``<attached_assets><asset id="…" …/></attached_assets>`` into the rendered
237
+ user message; Hermes hands that same text back to ``sync_turn``. Parsing the
238
+ ids out here lets the daemon extraction prompt list the real
239
+ ``prismer://asset/<id>`` pointer URIs. Deduped, order-preserving, bounded.
240
+ """
241
+ out: List[str] = []
242
+ seen = set()
243
+ for block in _ATTACHED_ASSETS_BLOCK_RE.findall(user_content or ""):
244
+ for asset_id in _ASSET_ID_RE.findall(block):
245
+ asset_id = asset_id.strip()
246
+ if not asset_id or asset_id in seen:
247
+ continue
248
+ seen.add(asset_id)
249
+ out.append(asset_id)
250
+ if len(out) >= max_ids:
251
+ return out
252
+ return out
253
+
254
+
255
+ # ─── Memory tool schemas — GENERATED, single source (memory211/01 W5 轴 H) ───
256
+ #
257
+ # The five memory tool schemas are NOT written here any more. They are
258
+ # generated from the FROZEN TS spec (`sdk/prismer/src/adapters/memory-tools.ts`,
259
+ # the single source of truth) into `tool-schemas.generated.json`, and this shell
260
+ # LOADS that file at runtime — so a hand-edited plugin schema can no longer
261
+ # drift from the tool spec. This is the structural extinction of the D1 class of
262
+ # defect (the same parameter spelled `parentHubPath` on one surface,
263
+ # `parent_hub_path` on another, and taught as a third spelling in the skill).
264
+ #
265
+ # Regenerate after changing the TS spec:
266
+ # npx tsx scripts/memory211/generate-memory-tool-contract.ts
267
+ #
268
+ # The contract test (scripts/__tests__/memory211-tool-contract.test.ts) asserts
269
+ # the committed artifact matches a regeneration AND that every declared
270
+ # parameter is actually forwarded by handle_tool_call below.
271
+
272
+ from pathlib import Path as _Path
273
+
274
+ _TOOL_SCHEMAS_PATH = _Path(__file__).with_name("tool-schemas.generated.json")
275
+
276
+
277
+ def _load_memory_tool_schemas() -> Dict[str, Dict[str, Any]]:
278
+ try:
279
+ data = json.loads(_TOOL_SCHEMAS_PATH.read_text(encoding="utf-8"))
280
+ except OSError as exc: # a truncated / missing artifact must be LOUD
281
+ raise RuntimeError(
282
+ f"memory tool schemas missing at {_TOOL_SCHEMAS_PATH} — regenerate with "
283
+ "`npx tsx scripts/memory211/generate-memory-tool-contract.ts`"
284
+ ) from exc
285
+ return {tool["name"]: tool for tool in data["memoryTools"]}
286
+
287
+
288
+ _MEMORY_TOOL_SCHEMAS = _load_memory_tool_schemas()
289
+ _MEMORY_SEARCH_SCHEMA = _MEMORY_TOOL_SCHEMAS["memory_search"]
290
+ _MEMORY_LOAD_SCHEMA = _MEMORY_TOOL_SCHEMAS["memory_load"]
291
+ _MEMORY_BROWSE_SCHEMA = _MEMORY_TOOL_SCHEMAS["memory_browse"]
292
+ _MEMORY_WRITE_SCHEMA = _MEMORY_TOOL_SCHEMAS["memory_write"]
293
+ _MEMORY_CURATE_SCHEMA = _MEMORY_TOOL_SCHEMAS["memory_curate"]
294
+
295
+
296
+
297
+ # ─── Workspace WEB tools (NOT memory ops) ────────────────────────────────────
298
+ #
299
+ # web_search / web_load are workspace-CONTEXT tools, not memory verbs. They
300
+ # cohabit this provider shell because get_tool_schemas() is the established
301
+ # seam for registering daemon-backed tools with the Hermes model — the same
302
+ # path the 5 memory tools ride. Rationale (release203 web-capability fix, user
303
+ # ruling "we ARE the search backend"):
304
+ # - Hermes' native web toolset is schema-dropped in agent-rt pods (all 7
305
+ # upstream search backends unconfigured → check_fn removes web_search /
306
+ # web_extract), so agents scripted raw HTTP via execute_code+subprocess.
307
+ # - We do NOT install third-party search keys/packages into pods. The cloud
308
+ # Load API (POST /api/context/load: search + cache + compress + deposit,
309
+ # Exa server-side) is the workspace's search backend; the daemon holds the
310
+ # cloud credential and forwards (daemon/web/rpc.ts → /local/web/*).
311
+ #
312
+ # Name note: `workspace_web_search`, NOT `web_search` — Hermes v0.17 guards
313
+ # provider tools that shadow RESERVED CORE tool names even when the core tool
314
+ # itself is check_fn-dropped ("Memory provider 'prismer' tool 'web_search'
315
+ # shadows a reserved core tool name; registration ignored. Core tools always
316
+ # win" — live-hit 2026-07-03). The event-stream mapper (tool-call-mapper.ts
317
+ # SEARCH_TOOLS) maps this name onto the first-class `web_search` search row,
318
+ # so the UI surface is unchanged. `web_load` has no core counterpart and
319
+ # keeps the plain name.
320
+ _WEB_SEARCH_SCHEMA = {
321
+ "name": "workspace_web_search",
322
+ "description": (
323
+ "Search the web through the workspace cloud (cached, billed to your "
324
+ "workspace). Returns ranked results with compressed page content. Use "
325
+ "this for ANY web research instead of scripting HTTP calls with "
326
+ "execute_code/subprocess."
327
+ ),
328
+ "parameters": {
329
+ "type": "object",
330
+ "properties": {
331
+ "query": {"type": "string", "description": "Web search query.", "minLength": 1},
332
+ "limit": {
333
+ "type": "integer",
334
+ "minimum": 1,
335
+ "maximum": 10,
336
+ "default": 5,
337
+ "description": "Max results to return (default 5).",
338
+ },
339
+ },
340
+ "required": ["query"],
341
+ },
342
+ }
343
+
344
+ _WEB_LOAD_SCHEMA = {
345
+ "name": "web_load",
346
+ "description": (
347
+ "Load web pages OR workspace assets/files (prismer://asset/… URIs) "
348
+ "through the workspace cloud (cached, billed to your workspace). "
349
+ "Returns compressed content. Use this to read a specific URL instead "
350
+ "of scripting HTTP calls with execute_code/subprocess — and use it "
351
+ "instead of re-reading raw sources when memory already references "
352
+ "them by a prismer:// pointer. Accepts http(s) and prismer:// URIs."
353
+ ),
354
+ "parameters": {
355
+ "type": "object",
356
+ "properties": {
357
+ "url": {"type": "string", "description": "Single http(s) or prismer:// URI to load."},
358
+ "urls": {
359
+ "type": "array",
360
+ "items": {"type": "string"},
361
+ "maxItems": 5,
362
+ "description": "Batch of http(s)/prismer:// URIs to load (max 5).",
363
+ },
364
+ },
365
+ },
366
+ }
367
+
368
+
369
+ # ─── Native PKF tools ────────────────────────────────────────────────────────
370
+ # These are function-call tools, not shell binaries. The provider forwards the
371
+ # source to the signed Runtime's loopback endpoints, which use the bundled
372
+ # @prismer/pkf-core. This prevents an agent from probing PATH or inventing a
373
+ # Python validator when following pkf-writing.
374
+ _PKF_SOURCE_PROPERTY = {
375
+ "type": "string",
376
+ "description": "Full PKF source (maximum 5 MiB).",
377
+ }
378
+
379
+ _PKF_MINT_SIDS_SCHEMA = {
380
+ "name": "pkf_mint_sids",
381
+ "description": (
382
+ "Mint canonical stable PKF section ids. Call once before authoring "
383
+ "sections and preserve the returned ids; never write a generator script."
384
+ ),
385
+ "parameters": {
386
+ "type": "object",
387
+ "properties": {
388
+ "count": {"type": "integer", "minimum": 1, "maximum": 50, "default": 1},
389
+ },
390
+ "additionalProperties": False,
391
+ },
392
+ }
393
+
394
+ _PKF_VALIDATE_SCHEMA = {
395
+ "name": "pkf_validate",
396
+ "description": "Validate PKF with the signed Runtime core. Native function tool; never run it in a terminal.",
397
+ "parameters": {
398
+ "type": "object",
399
+ "properties": {
400
+ "source": _PKF_SOURCE_PROPERTY,
401
+ "level": {"type": "string", "enum": ["structure", "resolved"], "default": "structure"},
402
+ "workspaceId": {"type": "string"},
403
+ },
404
+ "required": ["source"],
405
+ "additionalProperties": False,
406
+ },
407
+ }
408
+
409
+ # pkf209/07 §5 — controlled-svg authoring loop. Mirrors the TS
410
+ # PKF_SVG_CHECK_TOOL input schema 1:1 (parity is test-locked).
411
+ _PKF_SVG_CHECK_SCHEMA = {
412
+ "name": "pkf_svg_check",
413
+ "description": (
414
+ "Validate one controlled svg markup in-process (frozen whitelist + quality "
415
+ "floors; stable svg-* codes with fix-oriented diagnostics). Offline — never "
416
+ "shells out. Check-then-fix loop for prismer-svg authoring; a fail means "
417
+ "the svg must be repaired before persisting."
418
+ ),
419
+ "parameters": {
420
+ "type": "object",
421
+ "properties": {
422
+ "svg": {
423
+ "type": "string",
424
+ "description": (
425
+ "Complete controlled svg markup (the <svg>…</svg> inner block "
426
+ "of a prismer-svg element)."
427
+ ),
428
+ },
429
+ },
430
+ "required": ["svg"],
431
+ "additionalProperties": False,
432
+ },
433
+ }
434
+
435
+ # pkf209 — mechanical inline-PKF delivery. Mirrors the TS
436
+ # PKF_REPLY_INLINE_TOOL input schema 1:1 (parity is test-locked). The model
437
+ # passes a FILE PATH only; the daemon validates the bytes against the calling
438
+ # agent's in-flight dispatch scope and the dispatch terminal state attaches
439
+ # the inline ContentBlock. This is the answer to the 2026-08-20 matrix gap:
440
+ # weak models completed every step but the last (pasting sentinel-wrapped
441
+ # bytes into the reply) — that burden is now the Runtime's.
442
+ _PKF_REPLY_INLINE_SCHEMA = {
443
+ "name": "pkf_reply_inline",
444
+ "description": (
445
+ "Deliver your final report as the message-inline PKF ContentBlock, "
446
+ "mechanically. Write the complete PKF v1.1 to a file in your task "
447
+ "scratch dir, pass pkf_validate on it, then call this tool ONCE with "
448
+ "the file path — the Runtime re-validates the file and attaches the "
449
+ "inline ContentBlock to your reply for you. Never paste sentinel "
450
+ "comments into the reply; never attach the .pkf as a file. Keep the "
451
+ "reply text as the readable markdown projection."
452
+ ),
453
+ "parameters": {
454
+ "type": "object",
455
+ "properties": {
456
+ "path": {
457
+ "type": "string",
458
+ "description": (
459
+ "Path of the validated .pkf file — relative to your task "
460
+ "scratch dir (e.g. \"memo.pkf\") or absolute inside the "
461
+ "task scratch/workdir."
462
+ ),
463
+ "minLength": 1,
464
+ },
465
+ },
466
+ "required": ["path"],
467
+ "additionalProperties": False,
468
+ },
469
+ }
470
+
471
+ _PKF_OUTLINE_SCHEMA = {
472
+ "name": "pkf_outline",
473
+ "description": "Return a bounded semantic outline of PKF source. Native function tool, not a shell command.",
474
+ "parameters": {
475
+ "type": "object",
476
+ "properties": {
477
+ "source": _PKF_SOURCE_PROPERTY,
478
+ "documentUri": {"type": "string"},
479
+ "revisionId": {"type": "string"},
480
+ "cursor": {"type": "string"},
481
+ },
482
+ "required": ["source"],
483
+ "additionalProperties": False,
484
+ },
485
+ }
486
+
487
+ _PKF_SEARCH_SCHEMA = {
488
+ "name": "pkf_search",
489
+ "description": "Search PKF source with bounded snippets. Native function tool, not a shell command.",
490
+ "parameters": {
491
+ "type": "object",
492
+ "properties": {
493
+ "source": _PKF_SOURCE_PROPERTY,
494
+ "documentUri": {"type": "string"},
495
+ "revisionId": {"type": "string"},
496
+ "query": {"type": "string", "minLength": 1},
497
+ "limit": {"type": "integer", "minimum": 1},
498
+ },
499
+ "required": ["source", "query"],
500
+ "additionalProperties": False,
501
+ },
502
+ }
503
+
504
+ _PKF_READ_SCHEMA = {
505
+ "name": "pkf_read",
506
+ "description": "Read one bounded PKF section. Native function tool, not a shell command.",
507
+ "parameters": {
508
+ "type": "object",
509
+ "properties": {
510
+ "source": _PKF_SOURCE_PROPERTY,
511
+ "documentUri": {"type": "string"},
512
+ "revisionId": {"type": "string"},
513
+ "anchorSlug": {"type": "string"},
514
+ "sectionSid": {"type": "string"},
515
+ "maxBytes": {"type": "integer", "minimum": 1, "maximum": 65536},
516
+ },
517
+ "required": ["source"],
518
+ "additionalProperties": False,
519
+ },
520
+ }
521
+
522
+ _PKF_BUNDLE_COMMIT_SCHEMA = {
523
+ "name": "pkf_bundle_commit",
524
+ "description": (
525
+ "Atomically commit one logical PKF root with all JS/CSS/media/CSV or other "
526
+ "dependencies, then verify the canonical Cloud readback. Reuse one stable "
527
+ "idempotencyKey on retry; never upload dependencies separately or claim "
528
+ "relationships in prose."
529
+ ),
530
+ "parameters": {
531
+ "type": "object",
532
+ "properties": {
533
+ "idempotencyKey": {"type": "string", "minLength": 1, "maxLength": 191},
534
+ "root": {
535
+ "type": "object",
536
+ "properties": {
537
+ "filename": {"type": "string", "minLength": 1, "maxLength": 191},
538
+ "source": {"type": "string", "minLength": 1},
539
+ "sourceHash": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
540
+ },
541
+ "required": ["filename", "source", "sourceHash"],
542
+ "additionalProperties": False,
543
+ },
544
+ "resources": {
545
+ "type": "array",
546
+ "minItems": 1,
547
+ "maxItems": 256,
548
+ "items": {
549
+ "type": "object",
550
+ "properties": {
551
+ "path": {"type": "string", "minLength": 1, "maxLength": 500},
552
+ "fromPath": {"type": "string", "minLength": 1, "maxLength": 500},
553
+ "bytesBase64": {"type": "string", "minLength": 1},
554
+ "contentHash": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
555
+ "integrity": {"type": "string", "pattern": "^sha256-[A-Za-z0-9+/]+={0,2}$"},
556
+ "mime": {"type": "string", "minLength": 1, "maxLength": 191},
557
+ "usage": {
558
+ "type": "string",
559
+ "enum": [
560
+ "harness-manifest", "harness-script", "harness-style",
561
+ "harness-resource", "image", "video", "audio", "file", "data",
562
+ ],
563
+ },
564
+ },
565
+ "required": ["path", "bytesBase64", "contentHash", "integrity", "mime", "usage"],
566
+ "additionalProperties": False,
567
+ },
568
+ },
569
+ },
570
+ "required": ["idempotencyKey", "root", "resources"],
571
+ "additionalProperties": False,
572
+ },
573
+ }
574
+
575
+
576
+ class PrismerMemoryProvider(MemoryProvider):
577
+ """Hermes MemoryProvider that delegates to the Prismer daemon (local-first)."""
578
+
579
+ def __init__(self) -> None:
580
+ self._workspace_id: str = ""
581
+ self._agent_context: str = "primary"
582
+ self._session_id: str = ""
583
+ # Successful direct writes are authoritative already. Hold their
584
+ # structured receipts until sync_turn delivers the terminal snapshot,
585
+ # so Runtime can classify the explicit lane without extracting and
586
+ # creating a duplicate Page revision.
587
+ self._pending_explicit_receipts: List[Dict[str, Any]] = []
588
+
589
+ @property
590
+ def name(self) -> str:
591
+ return "prismer"
592
+
593
+ def is_available(self) -> bool:
594
+ # Config-only check (no network, per ABC). The daemon endpoint is the
595
+ # backend; a workspace binding is the minimum needed to scope recall.
596
+ return bool((os.environ.get("PRISMER_WORKSPACE_ID") or "").strip())
597
+
598
+ def initialize(self, session_id: str, **kwargs: Any) -> None:
599
+ self._session_id = session_id or ""
600
+ self._workspace_id = (os.environ.get("PRISMER_WORKSPACE_ID") or "").strip()
601
+ # scratch/eval isolation (doc 18 §4c): non-primary contexts are read-only.
602
+ self._agent_context = str(kwargs.get("agent_context") or "primary")
603
+ logger.info(
604
+ "prismer memory provider initialized session=%s workspace=%s context=%s",
605
+ session_id, self._workspace_id, self._agent_context,
606
+ )
607
+
608
+ def system_prompt_block(self) -> str:
609
+ # The C channel (core-inject) is delivered out-of-band by the daemon
610
+ # writing the MEMORY.md managed section directly (≤1,800 chars). Here we
611
+ # only declare the recall affordance so the model knows the tools exist.
612
+ return (
613
+ "Workspace memory is available via the memory_search / memory_load "
614
+ "tools (Prismer local store). Call memory_search when a task needs "
615
+ "prior decisions, preferences, or knowledge you don't already have."
616
+ )
617
+
618
+ def prefetch(self, query: str, *, session_id: str = "") -> str:
619
+ # v8.1: per-turn auto-injection is RETIRED → shadow observe only. We do
620
+ # NOT return recall context (archive recall is agent-driven via tools).
621
+ # Fire a best-effort shadow ping the daemon counts (shadowFiredCount).
622
+ if self._is_readonly() or not query.strip() or not self._workspace_id:
623
+ return ""
624
+ params = urllib.parse.urlencode({
625
+ "workspaceId": self._workspace_id,
626
+ "q": query[:240],
627
+ "shadow": "1",
628
+ })
629
+ _http_get(f"/local/memory/search?{params}", _RECALL_TIMEOUT)
630
+ return ""
631
+
632
+ def get_tool_schemas(self) -> List[Dict[str, Any]]:
633
+ # recall-tools正门 (P0): the agent self-recalls archive via these. The
634
+ # third tool (memory_curate, MVP4 phase-1) enacts knowledge-base
635
+ # maintenance — offered to every agent, but orchestrator-gated cloud-side.
636
+ # memory_browse (memory203/18 R6.2) is the write-time structure view —
637
+ # browse → decide placement → memory_write(parentHubPath) # W5 轴H: camelCase, per the generated schema.
638
+ # web_search / web_load are workspace-context tools (NOT memory ops —
639
+ # see the schema block comment); registered here because this shell is
640
+ # the daemon-backed tool-registration seam.
641
+ return [
642
+ _MEMORY_SEARCH_SCHEMA,
643
+ _MEMORY_LOAD_SCHEMA,
644
+ _MEMORY_BROWSE_SCHEMA,
645
+ _MEMORY_WRITE_SCHEMA,
646
+ _MEMORY_CURATE_SCHEMA,
647
+ _WEB_SEARCH_SCHEMA,
648
+ _WEB_LOAD_SCHEMA,
649
+ _PKF_MINT_SIDS_SCHEMA,
650
+ _PKF_VALIDATE_SCHEMA,
651
+ _PKF_OUTLINE_SCHEMA,
652
+ _PKF_SEARCH_SCHEMA,
653
+ _PKF_READ_SCHEMA,
654
+ _PKF_BUNDLE_COMMIT_SCHEMA,
655
+ _PKF_SVG_CHECK_SCHEMA,
656
+ _PKF_REPLY_INLINE_SCHEMA,
657
+ ]
658
+
659
+ def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs: Any) -> str:
660
+ # PKF query/validation is pure local CPU work. It deliberately precedes
661
+ # the Memory workspace guard: a cold agent, evaluator, or self-check can
662
+ # validate an inline document before any workspace has been bound.
663
+ if tool_name == "pkf_mint_sids":
664
+ count = args.get("count", 1)
665
+ if not isinstance(count, int) or isinstance(count, bool) or count < 1 or count > 50:
666
+ return json.dumps({"ok": False, "error": "pkf_sid_count_invalid", "min": 1, "max": 50})
667
+ result = _http_post_with_status(
668
+ "/local/pkf/mint-sids", {"count": count}, _PKF_TIMEOUT
669
+ )
670
+ status = result.get("status", 0)
671
+ payload = result.get("body")
672
+ if status == 0:
673
+ return json.dumps({"ok": False, "error": "daemon_unreachable"})
674
+ if not (200 <= status < 300):
675
+ return json.dumps(
676
+ payload if isinstance(payload, dict) else {"ok": False, "error": f"pkf_mint_sids_failed_{status}"}
677
+ )
678
+ return json.dumps(payload or {"ok": True, "sids": []})
679
+
680
+ if tool_name in ("pkf_validate", "pkf_outline", "pkf_search", "pkf_read"):
681
+ source = args.get("source")
682
+ if not isinstance(source, str) or not source:
683
+ return json.dumps({"ok": False, "error": "pkf_source_required"})
684
+ operation = tool_name.removeprefix("pkf_")
685
+ allowed: Dict[str, tuple[str, ...]] = {
686
+ "validate": ("source", "level", "workspaceId"),
687
+ "outline": ("source", "documentUri", "revisionId", "cursor"),
688
+ "search": ("source", "documentUri", "revisionId", "query", "limit"),
689
+ "read": ("source", "documentUri", "revisionId", "anchorSlug", "sectionSid", "maxBytes"),
690
+ }
691
+ body = {key: args[key] for key in allowed[operation] if key in args}
692
+ result = _http_post_with_status(f"/local/pkf/{operation}", body, _PKF_TIMEOUT)
693
+ status = result.get("status", 0)
694
+ payload = result.get("body")
695
+ if status == 0:
696
+ return json.dumps({"ok": False, "error": "daemon_unreachable"})
697
+ if not (200 <= status < 300):
698
+ return json.dumps(
699
+ payload if isinstance(payload, dict) else {"ok": False, "error": f"pkf_{operation}_failed_{status}"}
700
+ )
701
+ return json.dumps(payload or {"ok": True})
702
+
703
+ if tool_name == "pkf_svg_check":
704
+ # pkf209/07 §5 — controlled-svg check loop. Takes `svg` (not
705
+ # `source`); the daemon route runs the same local audit as the TS
706
+ # in-process tool.
707
+ svg = args.get("svg")
708
+ if not isinstance(svg, str) or not svg.strip():
709
+ return json.dumps({"ok": False, "error": "pkf_svg_required"})
710
+ result = _http_post_with_status("/local/pkf/svg-check", {"svg": svg}, _PKF_TIMEOUT)
711
+ status = result.get("status", 0)
712
+ payload = result.get("body")
713
+ if status == 0:
714
+ return json.dumps({"ok": False, "error": "daemon_unreachable"})
715
+ if not (200 <= status < 300):
716
+ return json.dumps(
717
+ payload if isinstance(payload, dict) else {"ok": False, "error": f"pkf_svg_check_failed_{status}"}
718
+ )
719
+ return json.dumps(payload or {"ok": False, "error": "pkf_svg_check_empty_result"})
720
+
721
+ if tool_name == "pkf_reply_inline":
722
+ # pkf209 — mechanical inline-PKF delivery. Takes `path` only; the
723
+ # daemon resolves the calling agent's in-flight dispatch scope (the
724
+ # X-Prismer-Agent header this shell already forwards), validates the
725
+ # file bytes, and writes the per-task marker the dispatch terminal
726
+ # state turns into the inline ContentBlock.
727
+ path = args.get("path")
728
+ if not isinstance(path, str) or not path.strip():
729
+ return json.dumps({"ok": False, "error": "pkf_reply_inline_path_required"})
730
+ result = _http_post_with_status(
731
+ "/local/pkf/reply-inline", {"path": path}, _PKF_TIMEOUT
732
+ )
733
+ status = result.get("status", 0)
734
+ payload = result.get("body")
735
+ if status == 0:
736
+ return json.dumps({"ok": False, "error": "daemon_unreachable"})
737
+ if not (200 <= status < 300):
738
+ return json.dumps(
739
+ payload if isinstance(payload, dict) else {"ok": False, "error": f"pkf_reply_inline_failed_{status}"}
740
+ )
741
+ return json.dumps(payload or {"ok": False, "error": "pkf_reply_inline_empty_result"})
742
+
743
+ if tool_name == "pkf_bundle_commit":
744
+ if not self._workspace_id:
745
+ return json.dumps({"ok": False, "error": "pkf_bundle_workspace_unbound"})
746
+ allowed = ("idempotencyKey", "root", "resources")
747
+ body = {key: args[key] for key in allowed if key in args}
748
+ result = _http_post_with_status(
749
+ "/local/pkf/bundle-commit", body, _PKF_BUNDLE_TIMEOUT
750
+ )
751
+ status = result.get("status", 0)
752
+ payload = result.get("body")
753
+ if status == 0:
754
+ return json.dumps({"ok": False, "error": "daemon_unreachable"})
755
+ if not (200 <= status < 300):
756
+ return json.dumps(
757
+ payload if isinstance(payload, dict) else {"ok": False, "error": f"pkf_bundle_commit_failed_{status}"}
758
+ )
759
+ return json.dumps(payload or {"ok": False, "error": "pkf_bundle_commit_empty_receipt"})
760
+
761
+ if not self._workspace_id:
762
+ return json.dumps({"error": "no workspace bound"})
763
+ if tool_name == "memory_search":
764
+ # memory211/01 轴C W1a 遗留补齐 — the batch `queries` param travels
765
+ # JSON-encoded in ONE `queries` param (the RPC is a GET). `q` is
766
+ # still sent so an OLDER daemon without batch support keeps working
767
+ # with just the first query.
768
+ search: Dict[str, str] = {
769
+ "workspaceId": self._workspace_id,
770
+ "q": str(args.get("query", "")),
771
+ "topK": str(int(args.get("limit", 5) or 5)),
772
+ }
773
+ queries = args.get("queries")
774
+ if isinstance(queries, list) and queries:
775
+ search["queries"] = json.dumps([str(x) for x in queries[:8] if str(x).strip()])
776
+ page_types = args.get("pageType")
777
+ if isinstance(page_types, list) and page_types:
778
+ search["pageType"] = str(page_types[0])
779
+ params = urllib.parse.urlencode(search)
780
+ result = _http_get_with_status(f"/local/memory/search?{params}", _RECALL_TIMEOUT)
781
+ status = result.get("status", 0)
782
+ res = result.get("body")
783
+ if status == 0:
784
+ return json.dumps({"ok": False, "error": "daemon_unreachable"})
785
+ if not (200 <= status < 300):
786
+ return json.dumps(
787
+ res if isinstance(res, dict) else {"ok": False, "error": f"memory_search_failed_{status}"}
788
+ )
789
+ return json.dumps(res or {"results": []})
790
+ if tool_name == "memory_load":
791
+ q: Dict[str, str] = {"workspaceId": self._workspace_id}
792
+ if args.get("uri"):
793
+ q = {"uri": str(args["uri"])}
794
+ elif args.get("path"):
795
+ q["path"] = str(args["path"])
796
+ result = _http_get_with_status(
797
+ f"/local/memory/load?{urllib.parse.urlencode(q)}", _RECALL_TIMEOUT
798
+ )
799
+ status = result.get("status", 0)
800
+ res = result.get("body")
801
+ if status == 0:
802
+ return json.dumps({"ok": False, "error": "daemon_unreachable"})
803
+ if not (200 <= status < 300):
804
+ return json.dumps(
805
+ res if isinstance(res, dict) else {"ok": False, "error": f"memory_load_failed_{status}"}
806
+ )
807
+ return json.dumps(res or {"error": "not found"})
808
+ if tool_name == "memory_browse":
809
+ # memory203/18 R6.2 — write-time structure view. Same shape the
810
+ # daemon's own extraction leg sees: {index, hubs[], nearest[]};
811
+ # hub rows carry children+updatedAt and hubsByRecent[] is the
812
+ # recency sequence (memory211/03 §7 B1).
813
+ q = {"workspaceId": self._workspace_id}
814
+ if args.get("query"):
815
+ q["q"] = str(args["query"])
816
+ result = _http_get_with_status(
817
+ f"/local/memory/place-context?{urllib.parse.urlencode(q)}", _RECALL_TIMEOUT
818
+ )
819
+ status = result.get("status", 0)
820
+ res = result.get("body")
821
+ if status == 0:
822
+ return json.dumps({"ok": False, "error": "daemon_unreachable"})
823
+ if not (200 <= status < 300):
824
+ return json.dumps(
825
+ res if isinstance(res, dict) else {"ok": False, "error": f"memory_browse_failed_{status}"}
826
+ )
827
+ return json.dumps(
828
+ res or {"index": None, "hubs": [], "hubsByRecent": [], "nearest": []}
829
+ )
830
+ if tool_name == "memory_write":
831
+ path = str(args.get("path", "")).strip()
832
+ content = str(args.get("content", ""))
833
+ if not path or not content:
834
+ return json.dumps({"ok": False, "error": "memory_write requires path + content"})
835
+ # memory203/13 §0.5: DIRECT write of the page the AGENT authored. The
836
+ # daemon (POST /local/memory/write → handleWrite) does store.write +
837
+ # outbox up-sync; the cloud materialize path anchors it to INDEX. NO
838
+ # cloud LLM, NO extraction queue. actorImUserId / actorKind are filled
839
+ # daemon-side from the verified cap.
840
+ body: Dict[str, Any] = {
841
+ "workspaceId": self._workspace_id,
842
+ "path": path,
843
+ "content": content,
844
+ "actorKind": "agent",
845
+ }
846
+ # Bind this direct write to the exact provider session registered by
847
+ # Runtime. The daemon resolves the opaque session server-side and
848
+ # only emits a canonical durability commit key when workspace,
849
+ # actor and active Cloud run all match; a forged/stale id remains a
850
+ # normal local-first write and cannot claim another turn.
851
+ if self._session_id:
852
+ body["providerSessionId"] = self._session_id
853
+ body["durabilityReceiptIndex"] = len(self._pending_explicit_receipts)
854
+ if args.get("title"):
855
+ body["title"] = str(args["title"])
856
+ # memory203/18 R1.1 — structural placement pass-through. The daemon
857
+ # (handleWrite) mirrors the auto-extract leg and emits the page→hub
858
+ # edge as a memory.link.upsert graph event.
859
+ # memory211/01 W5 轴 H — camelCase names EXACTLY as the frozen tool
860
+ # spec declares them (was `parent_hub_path`, a D1-class divergence
861
+ # from the skill's `parentHubPath`).
862
+ if args.get("parentHubPath"):
863
+ body["parentHubPath"] = str(args["parentHubPath"])
864
+ body["relation"] = str(args.get("relation") or "child-of")
865
+ if args.get("visibility"):
866
+ body["visibility"] = str(args["visibility"])
867
+ # memory203/18 R6.4 — section-op pass-through (RPC body is camelCase
868
+ # `op`/`section`; the tool arg names match).
869
+ if args.get("op"):
870
+ body["op"] = str(args["op"])
871
+ if args.get("section"):
872
+ body["section"] = str(args["section"])
873
+ # memory203/18 R6.3 — status-preserving POST: a structured daemon
874
+ # rejection (e.g. 422 `placement_required` with hub candidates, or a
875
+ # section-op 404/503) must reach the MODEL verbatim so it can pick a
876
+ # hub / fall back — _http_post would swallow it into None →
877
+ # "daemon_unreachable", which teaches the agent nothing.
878
+ result = _http_post_with_status("/local/memory/write", body, _WRITE_TIMEOUT)
879
+ status = result.get("status", 0)
880
+ res = result.get("body")
881
+ if status == 0:
882
+ return json.dumps({"ok": False, "error": "daemon_unreachable"})
883
+ if not (200 <= status < 300):
884
+ return json.dumps(
885
+ res
886
+ if isinstance(res, dict)
887
+ else {"ok": False, "error": f"memory_write_failed_{status}"}
888
+ )
889
+ # Direct write → daemon returns `{ page: {...} }`. (A 202 `{ queued }`
890
+ # shape would only come from a legacy daemon still on the retired
891
+ # extract lane — surface it as ok too so an older daemon never breaks.)
892
+ if isinstance(res, dict) and res.get("queued"):
893
+ return json.dumps({"ok": True, "queued": True, "path": path})
894
+ page = res.get("page") if isinstance(res, dict) else None
895
+ page_id = (page or {}).get("id") if isinstance(page, dict) else None
896
+ page_path = (page or {}).get("path") if isinstance(page, dict) else None
897
+ page_version = (page or {}).get("version") if isinstance(page, dict) else None
898
+ content_hash = (page or {}).get("contentHash") if isinstance(page, dict) else None
899
+ authority = res.get("authority") if isinstance(res, dict) else None
900
+ authority_event_id = res.get("outboxEventId") if isinstance(res, dict) else None
901
+ if (
902
+ isinstance(page_id, str) and page_id
903
+ and isinstance(page_path, str) and page_path
904
+ and isinstance(page_version, int) and page_version > 0
905
+ and isinstance(content_hash, str) and content_hash
906
+ ):
907
+ receipt = {
908
+ "pageId": page_id,
909
+ "path": page_path,
910
+ "version": page_version,
911
+ "contentHash": content_hash,
912
+ }
913
+ if authority in ("cloud", "outbox"):
914
+ receipt["authority"] = authority
915
+ if isinstance(authority_event_id, str) and authority_event_id:
916
+ receipt["authorityEventId"] = authority_event_id
917
+ if receipt not in self._pending_explicit_receipts:
918
+ self._pending_explicit_receipts.append(receipt)
919
+ return json.dumps({
920
+ "ok": True,
921
+ "path": page_path or path,
922
+ "pageId": page_id,
923
+ **({"version": page_version} if isinstance(page_version, int) else {}),
924
+ **({"contentHash": content_hash} if isinstance(content_hash, str) else {}),
925
+ })
926
+ if tool_name == "memory_curate":
927
+ op = str(args.get("op", ""))
928
+ # candidates = the READ half of convergence. It is NOT a write verb, so
929
+ # it routes to the daemon's /local/memory/health (GET), not
930
+ # /local/memory/curate (which only accepts the 3 write verbs). Without
931
+ # this branch the orchestrator has no native way to READ orphans /
932
+ # duplicates / stale and cannot start convergence (the live failure:
933
+ # it replied CURATE_UNAVAILABLE because the only enactment surface it
934
+ # had was write-only).
935
+ if op == "candidates":
936
+ q: Dict[str, str] = {"workspaceId": self._workspace_id}
937
+ if args.get("kind"):
938
+ q["kind"] = str(args["kind"])
939
+ if args.get("limit") is not None:
940
+ q["limit"] = str(int(args["limit"]))
941
+ result = _http_get_with_status(
942
+ f"/local/memory/health?{urllib.parse.urlencode(q)}", _RECALL_TIMEOUT
943
+ )
944
+ status = result.get("status", 0)
945
+ res = result.get("body")
946
+ if status == 0:
947
+ return json.dumps({"ok": False, "op": op, "error": "daemon_unreachable"})
948
+ if not (200 <= status < 300):
949
+ return json.dumps(
950
+ res
951
+ if isinstance(res, dict)
952
+ else {"ok": False, "op": op, "error": f"memory_health_failed_{status}"}
953
+ )
954
+ return json.dumps(res or {"ok": True, "op": op, "candidates": []})
955
+ body: Dict[str, Any] = {"workspaceId": self._workspace_id, "op": op}
956
+ if args.get("pageId"):
957
+ body["pageId"] = str(args["pageId"])
958
+ if args.get("reason"):
959
+ body["reason"] = str(args["reason"])
960
+ # memory203/18 R1.3 — promote_to_hub 挂子 pass-through.
961
+ if isinstance(args.get("childPaths"), list):
962
+ body["childPaths"] = [str(p) for p in args["childPaths"] if p]
963
+ # memory211/01 W5 轴 G — section-level verbs, forwarded verbatim;
964
+ # the daemon shape-checks, the cloud owns authority. Unrolled (not a
965
+ # tuple loop) so the contract test can see each forwarding.
966
+ if args.get("section"):
967
+ body["section"] = str(args["section"])
968
+ if args.get("targetSection"):
969
+ body["targetSection"] = str(args["targetSection"])
970
+ if args.get("sourcePageId"):
971
+ body["sourcePageId"] = str(args["sourcePageId"])
972
+ if args.get("sourceSection"):
973
+ body["sourceSection"] = str(args["sourceSection"])
974
+ if args.get("mergedContent"):
975
+ body["mergedContent"] = str(args["mergedContent"])
976
+ if args.get("supersededByPageId"):
977
+ body["supersededByPageId"] = str(args["supersededByPageId"])
978
+ if args.get("supersededBySection"):
979
+ body["supersededBySection"] = str(args["supersededBySection"])
980
+ if args.get("linkId"):
981
+ body["linkId"] = str(args["linkId"])
982
+ if args.get("toPageId"):
983
+ body["toPageId"] = str(args["toPageId"])
984
+ if args.get("toPath"):
985
+ body["toPath"] = str(args["toPath"])
986
+ if args.get("toSection"):
987
+ body["toSection"] = str(args["toSection"])
988
+ result = _http_post_with_status("/local/memory/curate", body, _CURATE_TIMEOUT)
989
+ status = result.get("status", 0)
990
+ payload = result.get("body")
991
+ if status == 403:
992
+ # Cloud orchestrator_only passthrough — surface cleanly so the
993
+ # agent learns it is not the orchestrator (not a crash).
994
+ return json.dumps(
995
+ payload
996
+ or {
997
+ "ok": False,
998
+ "op": op,
999
+ "error": "orchestrator_only",
1000
+ "message": "This curation op is orchestrator-only — you are not the workspace orchestrator.",
1001
+ }
1002
+ )
1003
+ if status == 0:
1004
+ return json.dumps({"ok": False, "op": op, "error": "daemon_unreachable"})
1005
+ if not (200 <= status < 300):
1006
+ return json.dumps(
1007
+ payload or {"ok": False, "op": op, "error": f"memory_curate_failed_{status}"}
1008
+ )
1009
+ return json.dumps(payload or {"ok": True, "op": op})
1010
+ if tool_name == "workspace_web_search":
1011
+ # Workspace web search (NOT a memory op) — forwards to the daemon's
1012
+ # /local/web/search, which calls the cloud Load API with the
1013
+ # daemon's own credential ([web-tool] logs land daemon-side).
1014
+ query = str(args.get("query", "")).strip()
1015
+ if not query:
1016
+ return json.dumps({"ok": False, "error": "workspace_web_search requires a non-empty query"})
1017
+ body: Dict[str, Any] = {"query": query}
1018
+ if args.get("limit") is not None:
1019
+ body["limit"] = int(args["limit"])
1020
+ result = _http_post_with_status("/local/web/search", body, _WEB_TIMEOUT)
1021
+ status = result.get("status", 0)
1022
+ payload = result.get("body")
1023
+ if status == 0:
1024
+ return json.dumps({"ok": False, "error": "daemon_unreachable"})
1025
+ if not (200 <= status < 300):
1026
+ return json.dumps(
1027
+ payload if isinstance(payload, dict) else {"ok": False, "error": f"web_search_failed_{status}"}
1028
+ )
1029
+ return json.dumps(payload or {"ok": True, "results": []})
1030
+ if tool_name == "web_load":
1031
+ # Workspace web page load (NOT a memory op) — same daemon lane as
1032
+ # web_search. Scheme allowlist http(s) | prismer:// (memory203/20
1033
+ # §2.2 / 19 B9: memory pages point at assets/files by prismer://
1034
+ # URI; agents load them here instead of re-reading raw sources).
1035
+ # Validated shell-side FIRST so a bad input never even hits the
1036
+ # daemon (it re-validates anyway).
1037
+ raw = args.get("urls") if isinstance(args.get("urls"), list) else None
1038
+ urls = [str(u).strip() for u in raw if str(u).strip()] if raw is not None else []
1039
+ if not urls and args.get("url"):
1040
+ urls = [str(args["url"]).strip()]
1041
+ urls = [u for u in urls if u]
1042
+ if not urls:
1043
+ return json.dumps({"ok": False, "error": "web_load requires `url` or `urls`"})
1044
+ _ok_schemes = ("http://", "https://", "prismer://")
1045
+ bad = [u for u in urls if not u.lower().startswith(_ok_schemes)]
1046
+ if bad:
1047
+ return json.dumps({
1048
+ "ok": False,
1049
+ "error": "invalid_url",
1050
+ "message": f"web_load only accepts http(s) or prismer:// URIs (got: {bad[0][:120]})",
1051
+ })
1052
+ result = _http_post_with_status("/local/web/load", {"urls": urls}, _WEB_TIMEOUT)
1053
+ status = result.get("status", 0)
1054
+ payload = result.get("body")
1055
+ if status == 0:
1056
+ return json.dumps({"ok": False, "error": "daemon_unreachable"})
1057
+ if not (200 <= status < 300):
1058
+ return json.dumps(
1059
+ payload if isinstance(payload, dict) else {"ok": False, "error": f"web_load_failed_{status}"}
1060
+ )
1061
+ return json.dumps(payload or {"ok": True})
1062
+ raise NotImplementedError(f"prismer provider does not handle tool {tool_name}")
1063
+
1064
+ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
1065
+ # memory203/14 (2026-07-01) — IN-PROCESS automatic-extraction TRIGGER.
1066
+ #
1067
+ # Background (why this is no longer a NO-OP): §0.5 retired CLOUD extraction
1068
+ # and assumed Hermes's native background_review would do the automatic leg
1069
+ # — but that forks with skip_memory=True, so our memory_write tool is never
1070
+ # injected and nothing reaches the PKF wiki. The daemon instead owns the
1071
+ # extraction (post_llm_call → runBackgroundExtraction: local recall-context
1072
+ # → IN-POD gateway-LLM extract → direct write; cloud does ZERO LLM). That
1073
+ # was triggered by a config.yaml shell hook, but a long-running gateway
1074
+ # never registers a hook added after startup, so it never fired on the
1075
+ # sessions dispatch path.
1076
+ #
1077
+ # sync_turn runs IN-PROCESS and conversation_loop invokes it reliably after
1078
+ # every turn — so it is the correct trigger. We fire-and-forget a POST to
1079
+ # the daemon's post_llm_call hook intake (same loopback the shell hook
1080
+ # targeted); the daemon resolves our agent+workspace identity from the body
1081
+ # and runs the in-pod extraction detached (responds 204 immediately, so this
1082
+ # never stalls the turn). This is §0.5-compliant: the extraction LLM runs in
1083
+ # THIS agent's pod runtime, never the cloud.
1084
+ if self._is_readonly() or not self._workspace_id:
1085
+ return
1086
+ if not (user_content.strip() or assistant_content.strip()):
1087
+ return
1088
+ agent_im_user_id = (os.environ.get("PRISMER_AGENT_IM_USER_ID") or "").strip()
1089
+ if not agent_im_user_id:
1090
+ return # cannot resolve which agent's wiki to extract into
1091
+ # memory203/18 R8.1 — chain trace id: session id + a short random suffix.
1092
+ # The daemon threads it through extraction into the outbox envelopes and
1093
+ # every [memory-trace] stage log, so one grep follows the whole chain.
1094
+ trace_id = f"{(session_id or 'turn')}-{uuid.uuid4().hex[:8]}"
1095
+ body = {
1096
+ "session_id": session_id or "",
1097
+ "extra": {
1098
+ "user_message": user_content,
1099
+ "assistant_response": assistant_content,
1100
+ "agent_im_user_id": agent_im_user_id,
1101
+ "workspace_id": self._workspace_id,
1102
+ "trace_id": trace_id,
1103
+ },
1104
+ }
1105
+ # memory203/20 §2.1 — ADDITIVE field: asset ids attached to this turn.
1106
+ # The dispatch composer stamps `<attached_assets><asset id="…"/></…>`
1107
+ # into the rendered user message; parse the ids back out so the daemon
1108
+ # extraction prompt can reference the REAL prismer://asset/<id> pointer
1109
+ # URIs (short description + rel="derived-from" pointer, never a copy).
1110
+ asset_ids = _parse_attached_asset_ids(user_content)
1111
+ if asset_ids:
1112
+ body["extra"]["attached_asset_ids"] = asset_ids
1113
+ # Direct memory_write is already durable. Attach its receipts to the
1114
+ # same terminal snapshot so all explicit/pre-reply/async observations
1115
+ # converge on one canonical commit key. Keep them pending across the
1116
+ # local retry; clear only after a successful hook acknowledgement.
1117
+ explicit_receipts = list(self._pending_explicit_receipts)
1118
+ if explicit_receipts:
1119
+ body["extra"]["explicit_memory_receipts"] = explicit_receipts
1120
+ # memory203/18 R9.1 — at-least-once delivery instead of the old bare
1121
+ # `except: pass` black hole (the G1 root fix's observability half): one
1122
+ # local retry after ~1s, and the terminal outcome — delivered or FAILED
1123
+ # (with the exception class) — always lands as ONE structured stderr
1124
+ # line the R8.2 counters/trace can corroborate. Still fire-and-forget
1125
+ # semantics: nothing here ever raises into the turn.
1126
+ last_exc: Optional[BaseException] = None
1127
+ for attempt in range(2):
1128
+ try:
1129
+ url = _daemon_base() + "/v1/hooks/post_llm_call"
1130
+ data = json.dumps(body).encode("utf-8")
1131
+ # No profile query param → the daemon resolves context from the
1132
+ # body's agent+workspace identity (memory203/14 resolveContext
1133
+ # fallback). urllib is used directly (not _http_post) because the
1134
+ # generic helper swallows the exception we need to classify.
1135
+ req = urllib.request.Request(url, data=data, headers=_headers(), method="POST")
1136
+ with urllib.request.urlopen(req, timeout=_WRITE_TIMEOUT):
1137
+ pass
1138
+ sys.stderr.write(
1139
+ f"[memory-trace] sync_turn delivered session={session_id} trace={trace_id}\n"
1140
+ )
1141
+ if explicit_receipts:
1142
+ sent = {
1143
+ (r.get("pageId"), r.get("path"), r.get("version"), r.get("contentHash"))
1144
+ for r in explicit_receipts
1145
+ }
1146
+ self._pending_explicit_receipts = [
1147
+ r for r in self._pending_explicit_receipts
1148
+ if (r.get("pageId"), r.get("path"), r.get("version"), r.get("contentHash")) not in sent
1149
+ ]
1150
+ return
1151
+ except Exception as exc: # noqa: BLE001 — classified below, never raised
1152
+ last_exc = exc
1153
+ if attempt == 0:
1154
+ time.sleep(1.0)
1155
+ try:
1156
+ sys.stderr.write(
1157
+ f"[memory-trace] sync_turn delivery FAILED err={type(last_exc).__name__}: {last_exc}\n"
1158
+ )
1159
+ except Exception: # noqa: BLE001 — even the log line must never raise
1160
+ pass
1161
+ return
1162
+
1163
+ def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
1164
+ # memory203/13 §0.5 — NO-OP. The 压缩前抢救 rescue used to forward the
1165
+ # soon-to-be-summarized turns to the daemon extract route (→ cloud LLM).
1166
+ # Cloud extraction is retired; in-runtime auto-extraction (Hermes
1167
+ # background_review → memory_write) is the replacement, so we no longer
1168
+ # forward to the cloud. Returns "" (never contributed to the summary).
1169
+ return ""
1170
+
1171
+ def on_memory_write(
1172
+ self,
1173
+ action: str,
1174
+ target: str,
1175
+ content: str,
1176
+ metadata: Optional[Dict[str, Any]] = None,
1177
+ ) -> None:
1178
+ # 内置 MEMORY.md收编 (doc 18 §4b#3): mirror the agent's built-in memory
1179
+ # edits into the substrate (agent-private files). Direction is one-way
1180
+ # (Hermes → substrate); the reverse only writes the managed section.
1181
+ if self._is_readonly() or not self._workspace_id:
1182
+ return
1183
+ _http_post(
1184
+ "/local/memory/mirror",
1185
+ {
1186
+ "workspaceId": self._workspace_id,
1187
+ "action": action,
1188
+ "target": target,
1189
+ "content": content,
1190
+ "metadata": metadata or {},
1191
+ },
1192
+ _WRITE_TIMEOUT,
1193
+ )
1194
+
1195
+ def get_config_schema(self) -> List[Dict[str, Any]]:
1196
+ # Daemon writes everything via ensureService → all env-var, user-invisible.
1197
+ return [
1198
+ {"key": "daemon_port", "description": "Prismer daemon loopback port",
1199
+ "secret": False, "required": False, "default": _DEFAULT_PORT,
1200
+ "env_var": "PRISMER_DAEMON_PORT"}, # default 7878 (doc 10 §3)
1201
+ {"key": "daemon_token", "description": "Prismer daemon local bearer token",
1202
+ "secret": True, "required": False, "env_var": "PRISMER_DAEMON_TOKEN"},
1203
+ {"key": "workspace_id", "description": "Bound workspace id",
1204
+ "secret": False, "required": True, "env_var": "PRISMER_WORKSPACE_ID"},
1205
+ ]
1206
+
1207
+ def shutdown(self) -> None:
1208
+ logger.info("prismer memory provider shutdown")
1209
+
1210
+ def _is_readonly(self) -> bool:
1211
+ return self._agent_context not in ("primary", "")