@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.
- package/CHANGELOG.md +3430 -0
- package/README.md +34 -12
- package/apc/skills/FIELD-DICTIONARY.md +111 -0
- package/apc/skills/bug-reproduce/SKILL.md +150 -0
- package/apc/skills/bug-reproduce/skill.json +96 -0
- package/apc/skills/code-review/SKILL.md +198 -0
- package/apc/skills/code-review/skill.json +124 -0
- package/apc/skills/design-review/SKILL.md +122 -0
- package/apc/skills/design-review/skill.json +88 -0
- package/apc/skills/doc-sync/SKILL.md +168 -0
- package/apc/skills/doc-sync/skill.json +81 -0
- package/apc/skills/env-doctor/SKILL.md +194 -0
- package/apc/skills/env-doctor/skill.json +209 -0
- package/apc/skills/git-ops/SKILL.md +189 -0
- package/apc/skills/git-ops/skill.json +94 -0
- package/apc/skills/impact-trace/SKILL.md +168 -0
- package/apc/skills/impact-trace/skill.json +104 -0
- package/apc/skills/observability/SKILL.md +195 -0
- package/apc/skills/observability/skill.json +116 -0
- package/apc/skills/release-db-config-sync/SKILL.md +186 -0
- package/apc/skills/release-db-config-sync/skill.json +109 -0
- package/apc/skills/release-ota-promote/SKILL.md +195 -0
- package/apc/skills/release-ota-promote/skill.json +176 -0
- package/apc/skills/release-preflight/SKILL.md +174 -0
- package/apc/skills/release-preflight/skill.json +175 -0
- package/apc/skills/release-rollback/SKILL.md +214 -0
- package/apc/skills/release-rollback/skill.json +230 -0
- package/apc/skills/release-tag/SKILL.md +194 -0
- package/apc/skills/release-tag/skill.json +94 -0
- package/apc/skills/releasing-prod/SKILL.md +49 -0
- package/apc/skills/releasing-test/SKILL.md +135 -0
- package/apc/skills/sdk-release/SKILL.md +200 -0
- package/apc/skills/spec-intake/SKILL.md +169 -0
- package/apc/skills/spec-intake/skill.json +93 -0
- package/apc/skills/test-result-feedback/SKILL.md +239 -0
- package/apc/skills/test-result-feedback/skill.json +193 -0
- package/apc/skills/test-runner/SKILL.md +169 -0
- package/apc/skills/test-runner/skill.json +103 -0
- package/apc/skills/ui-align/SKILL.md +209 -0
- package/apc/skills/ui-align/skill.json +114 -0
- package/apc/skills/ui-canvas/SKILL.md +148 -0
- package/apc/skills/ui-canvas/skill.json +127 -0
- package/built-in-skills/agent-coordination/SKILL.md +59 -37
- package/built-in-skills/agent-meta/SKILL.md +1 -0
- package/built-in-skills/assets/SKILL.md +8 -6
- package/built-in-skills/browser-use/SKILL.md +93 -0
- package/built-in-skills/canvas-design/SKILL.md +1 -0
- package/built-in-skills/claim-agent-ownership/SKILL.md +3 -2
- package/built-in-skills/claude-api/SKILL.md +1 -0
- package/built-in-skills/codebase-design/DEEPENING.md +37 -0
- package/built-in-skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
- package/built-in-skills/codebase-design/LICENSE +21 -0
- package/built-in-skills/codebase-design/SKILL.md +116 -0
- package/built-in-skills/conversation-compaction/SKILL.md +114 -0
- package/built-in-skills/council-creator/SKILL.md +426 -0
- package/built-in-skills/diagnosing-bugs/LICENSE +21 -0
- package/built-in-skills/diagnosing-bugs/SKILL.md +136 -0
- package/built-in-skills/diagnosing-bugs/scripts/hitl-loop.template.sh +41 -0
- package/built-in-skills/doc-coauthoring/SKILL.md +1 -0
- package/built-in-skills/document-generation/SKILL.md +105 -0
- package/built-in-skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/built-in-skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/built-in-skills/domain-modeling/LICENSE +21 -0
- package/built-in-skills/domain-modeling/SKILL.md +76 -0
- package/built-in-skills/frontend-design/SKILL.md +1 -0
- package/built-in-skills/human-approval/SKILL.md +17 -2
- package/built-in-skills/image-generate/SKILL.md +103 -302
- package/built-in-skills/image-generate/scripts/generate-and-deliver.mjs +289 -0
- package/built-in-skills/ingest/SKILL.md +13 -45
- package/built-in-skills/internal-comms/SKILL.md +1 -0
- package/built-in-skills/liteparse/SKILL.md +130 -110
- package/built-in-skills/mcp-builder/SKILL.md +1 -0
- package/built-in-skills/memory/SKILL.md +420 -55
- package/built-in-skills/memory-dream/SKILL.md +339 -0
- package/built-in-skills/office-artifacts/SKILL.md +17 -4
- package/built-in-skills/okr/SKILL.md +154 -0
- package/built-in-skills/persona/SKILL.md +81 -0
- package/built-in-skills/persona-generator/SKILL.md +296 -0
- package/built-in-skills/pkf-svg/SKILL.md +253 -0
- package/built-in-skills/pkf-writing/SKILL.md +236 -0
- package/built-in-skills/prismer-im-collab/SKILL.md +26 -6
- package/built-in-skills/proactivity/SKILL.md +84 -0
- package/built-in-skills/remotion/SKILL.md +431 -0
- package/built-in-skills/role-builder/SKILL.md +203 -0
- package/built-in-skills/role-builder/scripts/author-role.mjs +334 -0
- package/built-in-skills/role-builder/scripts/ingest-role.mjs +223 -0
- package/built-in-skills/role-builder/scripts/instantiate-and-run.mjs +290 -0
- package/built-in-skills/role-builder/scripts/operation-harness.mjs +267 -0
- package/built-in-skills/skill-authoring/SKILL.md +110 -100
- package/built-in-skills/skill-authoring/skill.json +3 -3
- package/built-in-skills/skill-builder/SKILL.md +171 -0
- package/built-in-skills/skill-builder/scripts/ingest.mjs +265 -0
- package/built-in-skills/skill-creator/SKILL.md +165 -423
- package/built-in-skills/skill-creator/references/external-library-import.md +110 -0
- package/built-in-skills/skill-creator/scripts/import-library.mjs +475 -0
- package/built-in-skills/slack-gif-creator/SKILL.md +20 -0
- package/built-in-skills/tasks/SKILL.md +38 -23
- package/built-in-skills/tdd/LICENSE +21 -0
- package/built-in-skills/tdd/SKILL.md +110 -0
- package/built-in-skills/tdd/mocking.md +59 -0
- package/built-in-skills/tdd/refactoring.md +10 -0
- package/built-in-skills/tdd/tests.md +61 -0
- package/built-in-skills/team/SKILL.md +2 -1
- package/built-in-skills/web-artifacts-builder/SKILL.md +1 -0
- package/built-in-skills/webapp-testing/SKILL.md +1 -0
- package/built-in-skills/wechat-pay/SKILL.md +59 -0
- package/dist/cli.cjs +71872 -19960
- package/dist/cli.js +71803 -19846
- package/dist/index.cjs +72010 -19966
- package/dist/index.d.cts +4258 -712
- package/dist/index.d.ts +4258 -712
- package/dist/index.js +72156 -20118
- package/package.json +37 -6
- package/plugins/memory/prismer/__init__.py +1211 -0
- package/plugins/memory/prismer/plugin.yaml +8 -0
- package/plugins/memory/prismer/tool-schemas.generated.json +249 -0
- package/plugins/tools/prismer-recall/__init__.py +282 -0
- package/plugins/tools/prismer-recall/plugin.yaml +15 -0
- 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", "")
|