@rubytech/create-maxy-code 0.1.115 → 0.1.117

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 (29) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/neo4j/schema.cypher +73 -0
  3. package/payload/platform/plugins/admin/PLUGIN.md +1 -0
  4. package/payload/platform/plugins/admin/hooks/signal-detector-stop.sh +309 -0
  5. package/payload/platform/plugins/memory/PLUGIN.md +7 -0
  6. package/payload/platform/plugins/memory/mcp/dist/index.js +32 -0
  7. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  8. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.d.ts +2 -0
  9. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.d.ts.map +1 -0
  10. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js +62 -0
  11. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js.map +1 -0
  12. package/payload/platform/plugins/memory/mcp/dist/tools/memory-signals-recent.d.ts +35 -0
  13. package/payload/platform/plugins/memory/mcp/dist/tools/memory-signals-recent.d.ts.map +1 -0
  14. package/payload/platform/plugins/memory/mcp/dist/tools/memory-signals-recent.js +73 -0
  15. package/payload/platform/plugins/memory/mcp/dist/tools/memory-signals-recent.js.map +1 -0
  16. package/payload/platform/plugins/memory/mcp/vitest.config.ts +2 -0
  17. package/payload/platform/scripts/seed-neo4j.sh +2 -1
  18. package/payload/platform/templates/agents/admin/IDENTITY.md +12 -0
  19. package/payload/platform/templates/specialists/agents/signal-detector.md +129 -0
  20. package/payload/server/public/assets/{admin-DOxHsV7g.js → admin-D6IfAzYY.js} +1 -1
  21. package/payload/server/public/assets/{data-NMvGx6jm.js → data-BGbQyufe.js} +1 -1
  22. package/payload/server/public/assets/{graph-BkwTCV2Y.js → graph-D-1lRTeL.js} +1 -1
  23. package/payload/server/public/assets/{graph-labels-h39S93Xo.js → graph-labels-BRXJHNYE.js} +1 -1
  24. package/payload/server/public/assets/{page-B0iMmY6t.js → page-B4oirCvn.js} +1 -1
  25. package/payload/server/public/assets/{page-zQ79YCEt.js → page-FmJ7PIYx.js} +2 -2
  26. package/payload/server/public/data.html +3 -3
  27. package/payload/server/public/graph.html +3 -3
  28. package/payload/server/public/index.html +4 -4
  29. package/payload/server/server.js +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.115",
3
+ "version": "0.1.117",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -1100,3 +1100,76 @@ FOR (h:CloudflareHostname) REQUIRE (h.accountId, h.hostnameValue) IS UNIQUE;
1100
1100
 
1101
1101
  CREATE INDEX cloudflare_hostname_account IF NOT EXISTS
1102
1102
  FOR (h:CloudflareHostname) ON (h.accountId);
1103
+
1104
+ // ----------------------------------------------------------
1105
+ // Estate-agent ontology (Task 358) — Listing-centric model
1106
+ // documented at platform/plugins/memory/references/schema-estate-agent.md.
1107
+ //
1108
+ // These declarations exist primarily so the memory-write validator's
1109
+ // parseLabelsFromSchemaCypher() registers each label on a fresh deploy
1110
+ // where no node of the label has yet landed — without them, the first
1111
+ // :Listing write from the listing-curator agent is rejected as "unknown
1112
+ // label" against an empty db.labels() set.
1113
+ //
1114
+ // Idempotency contracts mirror schema-estate-agent.md:
1115
+ // :Listing / :Viewing / :Offer — (accountId, sourceSystem, sourceId)
1116
+ // :Property — (accountId, slug)
1117
+ // :ImageObject — (accountId, listingSlug, url) for the
1118
+ // estate-agent variant, (accountId, name,
1119
+ // contentUrl) for the brand-asset variant.
1120
+ // Because the two writers use different
1121
+ // natural keys, only an accountId index is
1122
+ // declared at the schema level — uniqueness
1123
+ // is enforced at write time by the curator
1124
+ // / brand-asset writer's MERGE clause.
1125
+ // ----------------------------------------------------------
1126
+
1127
+ CREATE CONSTRAINT listing_account_source_unique IF NOT EXISTS
1128
+ FOR (l:Listing) REQUIRE (l.accountId, l.sourceSystem, l.sourceId) IS UNIQUE;
1129
+
1130
+ CREATE INDEX listing_account IF NOT EXISTS
1131
+ FOR (l:Listing) ON (l.accountId);
1132
+
1133
+ CREATE INDEX listing_status IF NOT EXISTS
1134
+ FOR (l:Listing) ON (l.status);
1135
+
1136
+ CREATE INDEX listing_type IF NOT EXISTS
1137
+ FOR (l:Listing) ON (l.listingType);
1138
+
1139
+ CREATE VECTOR INDEX listing_embedding IF NOT EXISTS
1140
+ FOR (l:Listing) ON (l.embedding)
1141
+ OPTIONS {
1142
+ indexConfig: {
1143
+ `vector.dimensions`: 768,
1144
+ `vector.similarity_function`: 'cosine'
1145
+ }
1146
+ };
1147
+
1148
+ CREATE CONSTRAINT property_account_slug_unique IF NOT EXISTS
1149
+ FOR (p:Property) REQUIRE (p.accountId, p.slug) IS UNIQUE;
1150
+
1151
+ CREATE INDEX property_account IF NOT EXISTS
1152
+ FOR (p:Property) ON (p.accountId);
1153
+
1154
+ CREATE CONSTRAINT viewing_account_source_unique IF NOT EXISTS
1155
+ FOR (v:Viewing) REQUIRE (v.accountId, v.sourceSystem, v.sourceId) IS UNIQUE;
1156
+
1157
+ CREATE INDEX viewing_account IF NOT EXISTS
1158
+ FOR (v:Viewing) ON (v.accountId);
1159
+
1160
+ CREATE CONSTRAINT offer_account_source_unique IF NOT EXISTS
1161
+ FOR (o:Offer) REQUIRE (o.accountId, o.sourceSystem, o.sourceId) IS UNIQUE;
1162
+
1163
+ CREATE INDEX offer_account IF NOT EXISTS
1164
+ FOR (o:Offer) ON (o.accountId);
1165
+
1166
+ CREATE INDEX image_object_account IF NOT EXISTS
1167
+ FOR (i:ImageObject) ON (i.accountId);
1168
+
1169
+ // :PostalAddress (schema:PostalAddress) — shared address nodes documented in
1170
+ // schema-base.md. Address rows are deliberately accountId-free (one address
1171
+ // can be shared across customers / orders / listings); writers MERGE on the
1172
+ // formatted address triple. Declared here so a fresh deploy registers the
1173
+ // label before the first :PostalAddress write lands.
1174
+ CREATE INDEX postal_address_postcode IF NOT EXISTS
1175
+ FOR (a:PostalAddress) ON (a.postalCode);
@@ -136,6 +136,7 @@ Tools are available via the `admin` MCP server.
136
136
  - `hooks/webfetch-preflight.mjs` — short-circuits WebFetch on JS-SPA shells with a structured `WEBFETCH_CANNOT_READ_JS_SPA` error so the agent surfaces a loud failure to the owner instead of paying the 60s extraction timeout. Fail-open on any internal error.
137
137
  - `hooks/askuserquestion-investigate-gate.sh` — PreToolUse matcher=`AskUserQuestion`. Blocks the question (exit 2) when no read-only investigation tool has fired since the latest real user turn in the session JSONL. The structural fix for the failure class where the agent fabricates a menu before evidence-gathering (session `c085ec2c-46fb-4b73-8865-68cf85866ea8` 2026-05-22 — "change remote access password" → invented options "Admin PIN / Cloudflare tunnel / WiFi password" with zero prior tool_use; post-correction the agent immediately fired `remote-auth-status` → `ToolSearch` → `remote-auth-set-password`, proving it knew the moves). **Allowlist** (exact, with trailing `__<tool>` suffix-match for namespaced `mcp__plugin_<plugin>_<server>__<tool>` aliases): `ToolSearch`, `Grep`, `Glob`, `Read`, `LS`, `NotebookRead`, `Bash`, `WebFetch`, `WebSearch`, plus the read-only admin / memory MCP tools (`*-status`, `*-list`, `*-read`, `skill-find`, `memory-find-candidates`, `profile-read`, `conversation-list`, `memory-list-attachments`, `memory-read-attachment`). **Block message:** `Blocked: AskUserQuestion requires at least one investigation tool (ToolSearch, Grep, Read, *-list, *-status, *-read, skill-find, ...) earlier in this turn. Search the operator's literal phrase first.` **Log line** (stderr, one per call): `[ask-gate] decision=<allow|block> sessionId=<id8> seen=<csv|-> reason=<allowlist-hit|no-investigation|fail-open-no-transcript|fail-open-parse-error>`. **Fail-open** on missing transcript or parse error — nudges, never bricks the UI.
138
138
  - `hooks/session-end-retrospective.sh` — **Stop hook.** Fires on every assistant end_turn but exits 0 with `trigger-skipped reason=<role-not-admin|is-specialist|empty-stdin|missing-transcript|no-intent-match>` on every path EXCEPT when the operator's latest real-user message carries an end-intent token (`/end`, `/archive`, `end session`, `archive this session`, case-insensitive, must match the whole message or a standalone line — not embedded in prose). On that path the hook blocks (exit 2 + structured retrospective instruction on stderr per Stop-hook contract) and emits `[session-retrospective] gate-blocked sessionId=<id> reason=sentinel-absent` until the admin agent calls the `session-retrospective-mark-complete` MCP sentinel tool. Completion is recognised by greping the operator's own JSONL (`transcript_path` on the Stop envelope) for a `tool_use` block whose `name` equals that exact string — never by parsing prose ([[feedback_no_stdout_parsing_for_control_flow]], [[feedback_doctrine_paragraph_is_not_a_gate]]). The sentinel-grep doubles as the re-entry guard: every Stop after the sentinel call sees it and exits 0 with `gate-released sessionId=<id>`. Recursion guard: any session whose `MAXY_SPECIALIST` env is set (specialist subagent dispatched via Task tool) skips the gate. The Sidebar archive button POSTs `/:sessionId/archive` directly to the manager which kills the PTY — no Stop fires after that, so clicking archive is the operator's current escape hatch (opt-out remains deferred per the task spec). All emits go through `POST /api/admin/log-ingest`; the only stderr writer is the gate-blocked path (the instruction block the agent reads). The retrospective itself runs as one or more additional turns inside the operator's existing admin session — Task-tool delegation to `database-operator` is the existing IDENTITY.md "Recording" route, not a parallel admin session ([[feedback_deterministic_means_remove_llm]]).
139
+ - `hooks/signal-detector-stop.sh` — **Stop hook.** Task 303 — gbrain port. Fires on every admin-agent end_turn alongside `session-end-retrospective.sh` (both registered on the Stop matcher). Inspects the operator's JSONL transcript and decides whether to dispatch the `signal-detector` specialist for background brain-compounding. Throttle gates: ≥3 new real-user turns since the most recent in-transcript Task call with `subagent_type=signal-detector` (overridable via `MAXY_SIGNAL_DETECTOR_MIN_NEW_TURNS`) AND ≥30 000 ms since the latest real-user message's `timestamp` (overridable via `MAXY_SIGNAL_DETECTOR_MIN_IDLE_MS`). Below either threshold → exit 0 with `trigger-skipped reason=below-{turn,idle}-threshold`. Recursion guard mirrors `session-end-retrospective.sh`: any session with `MAXY_SPECIALIST` set on the PTY env is skipped, plus the in-transcript Task-call grep means the very next Stop after the agent's dispatch turn sees the call and exits 0 (`reason=already-dispatched`). Gate-blocked path emits exit 2 + stderr directive `[system: signal-detector-stop-hook]` per Stop-hook contract; the admin IDENTITY.md "Stop-hook system directives" section binds the agent's response shape (perform one `Task` dispatch, emit a single-space ack, stop). First-fire bootstrap: idempotently copies the template from `$MAXY_PLATFORM_ROOT/templates/specialists/agents/signal-detector.md` into `$ACCOUNT_DIR/specialists/agents/` and appends the AGENTS.md routing line so the specialist is available platform-core without onboarding-flow surgery. All emits go through `POST /api/admin/log-ingest` under tag `signal-detector-hook`. Operator-channel doctrine: by design, only the admin agent's stop fires this hook (public/visitor channels never do — high-value signal flows through the operator, not the visitor).
139
140
  - `hooks/turn-completed-graph-write.sh` — **Dormant since Task 214** — the Stop-hook registration is no longer written to account settings.json; admin delegates graph writes to `database-operator` via the Task tool. The script, envelope walker, loopback `/api/admin/claude-sessions` 127.0.0.1 bypass, `[turn-recorder]` emitters, recorder-auto-archive subscriber, and `initialMessage` envelope spec are preserved as reusable infrastructure for any future autonomous post-turn flow. Historical contract (still describes the dormant path): Stop hook fired once per completed admin-agent turn. Gates on `MAXY_SESSION_ROLE=admin` + `MAXY_SPECIALIST!=database-operator` so it never recurses into the recorder PTY or fires on public sessions. **Task 147** — the recorder is spawned via the same route Sidebar uses. ONE POST to `POST /api/admin/claude-sessions`, body carries `{specialist:'database-operator', model:'haiku', initialMessage:<json-envelope>, adminSessionId:<op>}` — no synthetic `senderId: 'turn-recorder'` marker. The Hono wrapper bypasses cookie auth on this exact method+path when the request originates from `127.0.0.1` (same trust boundary the claude-session-manager itself relies on), looks up the operator's senderId from the manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn body. The `/recorder-spawn` sibling route is gone. The hook reads its UI port from `MAXY_UI_INTERNAL_PORT` (stamped on the manager systemd unit) — no fallback; absence emits `[turn-recorder] spawn-failed reason=missing-env env=MAXY_UI_INTERNAL_PORT` to stderr instead of silently 19199-ing. **Task 177** — `initialMessage` is a JSON-stringified envelope; the schema lives in [`platform/plugins/docs/references/admin-session.md`](../docs/references/admin-session.md) under "`initialMessage` JSON envelope (Task 177)". Top-level keys exactly: `turns`, `conversationId`, `accountId`, `occurredAt`. `turns` is the full conversation transcript, oldest first, newest last, with each entry `{ role: "user"|"assistant", text, ts, toolCalls? }`. No windowing, no truncation. Multi-record assistant messages collapse on `message.id`. `tool_use` and matching `tool_result` blocks attach as a single `toolCalls` entry on the owning assistant turn; the user record carrying only the `tool_result` does not create a separate user turn. No leading instruction prose. `toolCalls[].input` and `toolCalls[].output` are native JSON values, never re-stringified. (Replaces Task 175's `(operatorMessage, assistantReply)` pair, which asserted a temporal Q→A relationship the walker never enforced.) One observability line `[turn-recorder] envelope sessionId=<op> turnsCount=<n> userTurns=<n> assistantTurns=<n> toolCallTurns=<n>` precedes `spawn-request`. The envelope rides on the `/spawn` body as a trailing positional argv to `claude`, so the database-operator session's JSONL first `role=user` line is the JSON object verbatim. No separate `POST /:id/input` call, no bracketed-paste, no keystroke injection. The recorder-auto-archive subscriber stops the recorder PTY as soon as its JSONL contains an assistant message with `stop_reason === "end_turn"`. **Task 129** — every emit goes through `POST /api/admin/log-ingest` so the lines land in `server.log` keyed by the operator session id. The chain is `trigger` → `spawn-request` → `spawn-success` → `input-delivered` → `tool-call` × N → `tool-result` × N → `write-complete` → `auto-archive`; each gated-off path emits one `trigger-skipped reason=<role-not-admin|is-recorder|empty-stdin|missing-transcript|conversation-empty>` line. Failure modes — `spawn-failed`, `tool-surface-missing`, `input-failed`, `write-empty`, `auto-archive reason=stale-recorder` — each emit one named line; absence is itself a defect.
140
141
 
141
142
  ## Session identifiers (Task 135)
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env bash
2
+ # Stop hook — gbrain signal-detector dispatch. Task 303.
3
+ #
4
+ # Fires on every admin-agent assistant end_turn. Inspects the operator's
5
+ # JSONL transcript and decides whether to invoke the `signal-detector`
6
+ # specialist to extract entity mentions and original-thinking candidates
7
+ # from the conversation slice since the last dispatch.
8
+ #
9
+ # Trigger logic (must clear both gates to fire):
10
+ # - >= MIN_NEW_TURNS new real-user messages since the most recent
11
+ # in-transcript Task dispatch with subagent_type='signal-detector'
12
+ # (default 3 — overridable via MAXY_SIGNAL_DETECTOR_MIN_NEW_TURNS).
13
+ # - >= MIN_IDLE_MS milliseconds since the latest real-user message's
14
+ # `timestamp` (default 30_000 — overridable via
15
+ # MAXY_SIGNAL_DETECTOR_MIN_IDLE_MS). Captures "operator paused, not
16
+ # mid-thought".
17
+ #
18
+ # Recursion guard. The dispatch resolves through a Task tool call carrying
19
+ # `subagent_type=signal-detector`; once such a call appears in the transcript
20
+ # after the latest real-user message, the gate has already fired for this
21
+ # turn and must NOT re-fire. Same shape as
22
+ # session-end-retrospective.sh's sentinel grep
23
+ # ([[feedback_no_stdout_parsing_for_control_flow]] /
24
+ # [[feedback_doctrine_paragraph_is_not_a_gate]]) — control flow gates on a
25
+ # structural tool_use, never on prose.
26
+ #
27
+ # Specialist auto-install on first fire. The template ships at
28
+ # `$MAXY_TEMPLATES_DIR/specialists/agents/signal-detector.md` (or
29
+ # `$ACCOUNT_DIR/../templates/...` via PLATFORM_ROOT). On the first fire
30
+ # that decides to dispatch, the hook idempotently copies the template into
31
+ # `$ACCOUNT_DIR/specialists/agents/` and appends the AGENTS.md routing line
32
+ # if absent — so signal-detector is platform-core (no operator install
33
+ # step) without surgery on the onboarding flow.
34
+ #
35
+ # Gating (emits via `/api/admin/log-ingest`; stderr stays silent on every
36
+ # skip path; the gate-blocked path is the only stderr emitter, by Stop-hook
37
+ # contract — exit 2 + stderr is how the agent receives the dispatch
38
+ # directive):
39
+ # - MAXY_SESSION_ROLE must equal "admin" → reason=role-not-admin
40
+ # - MAXY_SPECIALIST must be empty → reason=is-specialist
41
+ # - Stop-hook stdin must be non-empty → reason=empty-stdin
42
+ # - transcript_path must exist on disk → reason=missing-transcript
43
+ # - new real-user turns since last fire < MIN_NEW → reason=below-turn-threshold
44
+ # - idle_ms < MIN_IDLE_MS → reason=below-idle-threshold
45
+ # - latest real-user message older than the latest → reason=already-dispatched
46
+ # signal-detector dispatch
47
+ #
48
+ # Blocks:
49
+ # - gate-blocked sessionId=<id> reason=dispatching newTurns=<n> idleMs=<n>
50
+ #
51
+ # Input: Claude Code's Stop hook stdin shape
52
+ # { "session_id": "<intrinsic>", "transcript_path": "<jsonl path>", ... }
53
+
54
+ set -uo pipefail
55
+
56
+ MIN_NEW_TURNS="${MAXY_SIGNAL_DETECTOR_MIN_NEW_TURNS:-3}"
57
+ MIN_IDLE_MS="${MAXY_SIGNAL_DETECTOR_MIN_IDLE_MS:-30000}"
58
+
59
+ UI_PORT="${MAXY_UI_INTERNAL_PORT:-}"
60
+ if [ -z "$UI_PORT" ]; then
61
+ echo "[signal-detector-hook] trigger-skipped sessionId=unknown reason=missing-env env=MAXY_UI_INTERNAL_PORT" >&2
62
+ exit 0
63
+ fi
64
+ UI_BASE="http://127.0.0.1:${UI_PORT}"
65
+ LOG_INGEST_URL="${UI_BASE}/api/admin/log-ingest"
66
+
67
+ emit_log() {
68
+ local line="$1"
69
+ curl -sS -o /dev/null -X POST \
70
+ -H 'Content-Type: application/json' \
71
+ --max-time 2 \
72
+ --data "$(python3 -c '
73
+ import sys, json
74
+ print(json.dumps({"tag": "signal-detector-hook", "level": "info", "line": sys.argv[1]}))
75
+ ' "$line")" \
76
+ "$LOG_INGEST_URL" 2>/dev/null || true
77
+ }
78
+
79
+ INPUT=""
80
+ if [ -t 0 ]; then
81
+ INPUT=""
82
+ else
83
+ INPUT=$(cat)
84
+ fi
85
+
86
+ PARSED=$(printf '%s' "$INPUT" | python3 -c '
87
+ import sys, json
88
+ try:
89
+ d = json.load(sys.stdin)
90
+ sid = d.get("session_id", "") or ""
91
+ tpath = d.get("transcript_path", "") or ""
92
+ print(f"{sid}\t{tpath}")
93
+ except Exception:
94
+ print("\t")
95
+ ' 2>/dev/null)
96
+ SESSION_ID="${PARSED%% *}"
97
+ TRANSCRIPT_PATH="${PARSED#* }"
98
+ SESSION_ID_REPORTED="${SESSION_ID:-unknown}"
99
+
100
+ if [ "${MAXY_SESSION_ROLE:-}" != "admin" ]; then
101
+ emit_log "trigger-skipped sessionId=${SESSION_ID_REPORTED} reason=role-not-admin"
102
+ exit 0
103
+ fi
104
+ # Recursion guard. A specialist subagent (database-operator, signal-detector
105
+ # itself, etc.) stamps `MAXY_SPECIALIST` on its PTY env. Skip — the hook
106
+ # only fires on the operator's own admin session, never on a subagent's
107
+ # own stop.
108
+ if [ -n "${MAXY_SPECIALIST:-}" ]; then
109
+ emit_log "trigger-skipped sessionId=${SESSION_ID_REPORTED} reason=is-specialist specialist=${MAXY_SPECIALIST}"
110
+ exit 0
111
+ fi
112
+ if [ -z "$INPUT" ]; then
113
+ emit_log "trigger-skipped sessionId=${SESSION_ID_REPORTED} reason=empty-stdin"
114
+ exit 0
115
+ fi
116
+ if [ -z "$SESSION_ID" ] || [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then
117
+ emit_log "trigger-skipped sessionId=${SESSION_ID_REPORTED} reason=missing-transcript"
118
+ exit 0
119
+ fi
120
+
121
+ # Single Python pass over the transcript. Returns three tab-separated
122
+ # fields:
123
+ # 1. count of real-user messages whose timestamp is more recent than the
124
+ # latest in-transcript Task dispatch with subagent_type=signal-detector
125
+ # (or, when no such dispatch exists yet, the total real-user count)
126
+ # 2. idle_ms — milliseconds between the latest real-user message's
127
+ # timestamp and now (hook fire time)
128
+ # 3. excerpt — the latest real-user message's text, truncated to 2000
129
+ # chars and base64-encoded so newlines and quotes survive the shell
130
+ # round-trip
131
+ INSPECTION=$(TRANSCRIPT_PATH="$TRANSCRIPT_PATH" python3 - <<'PY'
132
+ import os, json, base64
133
+ from datetime import datetime, timezone
134
+
135
+ def is_real_user(rec):
136
+ if not isinstance(rec, dict) or rec.get("type") != "user":
137
+ return False
138
+ msg = rec.get("message")
139
+ if not isinstance(msg, dict) or msg.get("role") != "user":
140
+ return False
141
+ content = msg.get("content")
142
+ if isinstance(content, str):
143
+ return True
144
+ if isinstance(content, list):
145
+ for b in content:
146
+ if isinstance(b, dict) and b.get("type") != "tool_result":
147
+ return True
148
+ return False
149
+ return True
150
+
151
+ def extract_user_text(rec):
152
+ msg = rec.get("message", {}) or {}
153
+ content = msg.get("content")
154
+ if isinstance(content, str):
155
+ return content
156
+ if isinstance(content, list):
157
+ out = []
158
+ for b in content:
159
+ if isinstance(b, dict) and b.get("type") == "text":
160
+ t = b.get("text")
161
+ if isinstance(t, str):
162
+ out.append(t)
163
+ return "".join(out)
164
+ return ""
165
+
166
+ def latest_signal_detector_dispatch_index(records):
167
+ """Return the index of the most recent assistant tool_use of `Task`
168
+ whose `subagent_type` is `signal-detector`, or -1 if none."""
169
+ for i in range(len(records) - 1, -1, -1):
170
+ rec = records[i]
171
+ if not isinstance(rec, dict) or rec.get("type") != "assistant":
172
+ continue
173
+ msg = rec.get("message", {}) or {}
174
+ if msg.get("role") != "assistant":
175
+ continue
176
+ content = msg.get("content")
177
+ if not isinstance(content, list):
178
+ continue
179
+ for b in content:
180
+ if not isinstance(b, dict) or b.get("type") != "tool_use":
181
+ continue
182
+ if b.get("name") != "Task":
183
+ continue
184
+ inp = b.get("input", {})
185
+ if isinstance(inp, dict) and inp.get("subagent_type") == "signal-detector":
186
+ return i
187
+ return -1
188
+
189
+ path = os.environ["TRANSCRIPT_PATH"]
190
+ records = []
191
+ try:
192
+ with open(path, "r", encoding="utf-8") as f:
193
+ for raw in f:
194
+ raw = raw.strip()
195
+ if not raw:
196
+ continue
197
+ try:
198
+ records.append(json.loads(raw))
199
+ except Exception:
200
+ continue
201
+ except Exception:
202
+ # Fail-open — print empty fields, the bash side will treat as missing.
203
+ print("0\t0\t")
204
+ raise SystemExit(0)
205
+
206
+ last_dispatch_idx = latest_signal_detector_dispatch_index(records)
207
+
208
+ # Count real-user messages after last_dispatch_idx (exclusive); when
209
+ # last_dispatch_idx is -1, count from the start.
210
+ new_user_count = 0
211
+ latest_user_ts = None
212
+ latest_user_text = ""
213
+ start = last_dispatch_idx + 1
214
+ for i in range(start, len(records)):
215
+ rec = records[i]
216
+ if is_real_user(rec):
217
+ new_user_count += 1
218
+ ts = rec.get("timestamp") or ""
219
+ if isinstance(ts, str) and ts:
220
+ latest_user_ts = ts
221
+ latest_user_text = extract_user_text(rec).strip()
222
+
223
+ if latest_user_ts is None:
224
+ print(f"{new_user_count}\t0\t")
225
+ raise SystemExit(0)
226
+
227
+ try:
228
+ # JSONL timestamps from Claude Code are ISO 8601 with Z suffix.
229
+ parsed = datetime.fromisoformat(latest_user_ts.replace("Z", "+00:00"))
230
+ now = datetime.now(timezone.utc)
231
+ idle_ms = max(0, int((now - parsed).total_seconds() * 1000))
232
+ except Exception:
233
+ idle_ms = 0
234
+
235
+ excerpt = latest_user_text[:2000]
236
+ b64 = base64.b64encode(excerpt.encode("utf-8")).decode("ascii")
237
+ print(f"{new_user_count}\t{idle_ms}\t{b64}")
238
+ PY
239
+ )
240
+ NEW_TURNS="${INSPECTION%% *}"
241
+ REMAINING="${INSPECTION#* }"
242
+ IDLE_MS="${REMAINING%% *}"
243
+ EXCERPT_B64="${REMAINING#* }"
244
+
245
+ # Throttle gates.
246
+ if [ "$NEW_TURNS" -eq 0 ] 2>/dev/null; then
247
+ emit_log "trigger-skipped sessionId=${SESSION_ID} reason=already-dispatched newTurns=0"
248
+ exit 0
249
+ fi
250
+ if [ "$NEW_TURNS" -lt "$MIN_NEW_TURNS" ] 2>/dev/null; then
251
+ emit_log "trigger-skipped sessionId=${SESSION_ID} reason=below-turn-threshold newTurns=${NEW_TURNS} threshold=${MIN_NEW_TURNS}"
252
+ exit 0
253
+ fi
254
+ if [ "$IDLE_MS" -lt "$MIN_IDLE_MS" ] 2>/dev/null; then
255
+ emit_log "trigger-skipped sessionId=${SESSION_ID} reason=below-idle-threshold idleMs=${IDLE_MS} threshold=${MIN_IDLE_MS}"
256
+ exit 0
257
+ fi
258
+
259
+ # Specialist auto-install. The template lives under
260
+ # `$PLATFORM_ROOT/templates/specialists/agents/signal-detector.md`. The
261
+ # installer / seeder copies templates to per-account dirs at onboarding;
262
+ # this fallback heals an account that pre-dates the template's existence
263
+ # without forcing the operator to re-run onboarding.
264
+ PLATFORM_ROOT_FALLBACK="${MAXY_PLATFORM_ROOT:-}"
265
+ ACCOUNT_DIR_LOCAL="${ACCOUNT_DIR:-}"
266
+ SPECIALIST_TARGET="${ACCOUNT_DIR_LOCAL}/specialists/agents/signal-detector.md"
267
+ if [ -n "$ACCOUNT_DIR_LOCAL" ] && [ ! -f "$SPECIALIST_TARGET" ] && [ -n "$PLATFORM_ROOT_FALLBACK" ]; then
268
+ TEMPLATE_SRC="${PLATFORM_ROOT_FALLBACK}/templates/specialists/agents/signal-detector.md"
269
+ if [ -f "$TEMPLATE_SRC" ]; then
270
+ mkdir -p "$(dirname "$SPECIALIST_TARGET")" 2>/dev/null
271
+ cp "$TEMPLATE_SRC" "$SPECIALIST_TARGET" 2>/dev/null
272
+ AGENTS_MD="${ACCOUNT_DIR_LOCAL}/agents/admin/AGENTS.md"
273
+ if [ -f "$AGENTS_MD" ]; then
274
+ if ! grep -q "specialists:signal-detector" "$AGENTS_MD" 2>/dev/null; then
275
+ printf '%s\n' "- **specialists:signal-detector**: Background brain-compounder dispatched only by the admin-agent Stop hook (\`platform/plugins/admin/hooks/signal-detector-stop.sh\`). Never invoke manually." >> "$AGENTS_MD"
276
+ fi
277
+ fi
278
+ emit_log "bootstrap-installed sessionId=${SESSION_ID} target=${SPECIALIST_TARGET}"
279
+ fi
280
+ fi
281
+
282
+ EXCERPT_PREVIEW=$(printf '%s' "$EXCERPT_B64" | base64 --decode 2>/dev/null | head -c 200 | tr '\n' ' ')
283
+ emit_log "gate-blocked sessionId=${SESSION_ID} reason=dispatching newTurns=${NEW_TURNS} idleMs=${IDLE_MS} preview=$(printf '%s' "$EXCERPT_PREVIEW" | sed 's/[\"]//g' | head -c 80)"
284
+
285
+ # Emit the dispatch directive on stderr per Stop-hook contract. The admin
286
+ # IDENTITY.md "Stop-hook system directives" section names this token prefix
287
+ # and the binding response shape — agent must perform the single Agent
288
+ # dispatch and emit an empty acknowledgement on return, nothing else.
289
+ EXCERPT_DECODED=$(printf '%s' "$EXCERPT_B64" | base64 --decode 2>/dev/null)
290
+
291
+ cat >&2 <<DIRECTIVE
292
+ [system: signal-detector-stop-hook]
293
+ Dispatch the signal-detector specialist NOW using the Task tool. Do not respond to the operator; this is a background brain-compounding task and the operator does not see this turn. After the specialist returns its one-line summary, emit a single space character and stop.
294
+
295
+ Agent dispatch parameters:
296
+ subagent_type: signal-detector
297
+ prompt: |
298
+ accountId: ${ACCOUNT_ID:-<unset>}
299
+ sessionId: ${SESSION_ID}
300
+ newUserTurnsSinceLastRun: ${NEW_TURNS}
301
+ idleMsBeforeFire: ${IDLE_MS}
302
+
303
+ The operator's latest turn (truncated to 2000 chars):
304
+ ${EXCERPT_DECODED}
305
+
306
+ Resolve the operator's most recent :Message in this conversation via memory-search (filter by conversationId from your MCP server env), then write :MENTIONS edges and :Idea nodes per your specialist body. Return one line in the documented output shape.
307
+ DIRECTIVE
308
+
309
+ exit 2
@@ -77,6 +77,9 @@ tools:
77
77
  - name: profile-delete
78
78
  publicAllowlist: false
79
79
  adminAllowlist: true
80
+ - name: memory-signals-recent
81
+ publicAllowlist: false
82
+ adminAllowlist: true
80
83
  - name: graph-prune-denylist-add
81
84
  publicAllowlist: false
82
85
  adminAllowlist: true
@@ -162,6 +165,10 @@ Graph hygiene is **agent-directed, case by case** — no autonomous rule engine,
162
165
 
163
166
  **Why soft-delete.** On 2026-04-20 an autonomous orphan-delete rule wiped 19 nodes in one `graph-prune-run` call; properties were unrecoverable (Neo4j Community has no PITR, no APOC). The cron, the `graph-prune-run` tool, and the rule engine have been removed. An earlier fix added the `:Trashed` label primitive so every soft-delete caller (`memory-delete`, `contact-delete`, the admin `/graph` page's trash button) runs the same shared `trashNode` helper — relationships preserved, unique keys snapshotted into `_trashedKeys`, audit lines `[trash:marked] by=<surface>` for cross-cut provenance.
164
167
 
168
+ ## Signal-detector diagnostic — `memory-signals-recent`
169
+
170
+ Read-only window into the writes the `signal-detector` specialist has produced under this account. Returns recent `:MENTIONS` edges (`:Message-[:MENTIONS]->Entity`) and `:Idea` nodes interleaved by `observedAt`. Default window is 24 hours; pass `windowHours` (max 720) and `limit` (max 200) to scan a longer arc. Use this when the operator asks "what has the brain captured today?", "show me recent mentions of X", or "what ideas did I leave on the table this week?". The signal-detector specialist itself is dispatched only by the admin-agent Stop hook at `platform/plugins/admin/hooks/signal-detector-stop.sh` — never by operator intent and never via `<specialist-domains>` routing. Task 303.
171
+
165
172
  ## Conversational Memory
166
173
 
167
174
  The owner's profile and preferences accumulate organically from conversation — never from questionnaires. Run `skill-load skillName=conversational-memory` for full guidance on when to observe preferences, how to handle remember/forget requests, and how to answer "what do you know about me?" transparently with confidence scores and evidence trail.
@@ -31,6 +31,7 @@ import { memoryReadAttachment } from "./tools/memory-read-attachment.js";
31
31
  import { memoryEditAttachment } from "./tools/memory-edit-attachment.js";
32
32
  import { memoryRenameAttachment } from "./tools/memory-rename-attachment.js";
33
33
  import { profileRead } from "./tools/profile-read.js";
34
+ import { memorySignalsRecent } from "./tools/memory-signals-recent.js";
34
35
  import { profileUpdate } from "./tools/profile-update.js";
35
36
  import { profileDelete } from "./tools/profile-delete.js";
36
37
  import { graphPruneDenylistAdd } from "./tools/graph-prune-denylist-add.js";
@@ -1678,6 +1679,37 @@ if (!readOnly) {
1678
1679
  };
1679
1680
  }
1680
1681
  });
1682
+ // memory-signals-recent: read-only diagnostic for the signal-detector
1683
+ // specialist's writes. Returns recent :MENTIONS edges
1684
+ // (:Message-[:MENTIONS]->Entity) and :Idea nodes filtered by
1685
+ // r.createdByAgent='signal-detector'. The admin agent uses this to
1686
+ // answer "what has the brain captured today?" without touching cypher.
1687
+ // The signal-detector specialist itself is dispatched only by the
1688
+ // admin-agent Stop hook — never by operator intent. Task 303.
1689
+ server.tool("memory-signals-recent", "Read the most recent signal-detector writes for this account: entity mentions extracted from operator messages, and original-thinking :Idea nodes. " +
1690
+ "Default window is the last 24 hours. " +
1691
+ "Use this when the operator asks 'what has the brain captured today?', 'show me recent mentions of X', or 'what ideas did I leave on the table this week?'.", {
1692
+ windowHours: z.number().int().optional().describe("Hours back from now. Default 24. Capped at 720 (30 days)."),
1693
+ limit: z.number().int().optional().describe("Max rows returned. Default 50, max 200."),
1694
+ }, async ({ windowHours, limit }) => {
1695
+ try {
1696
+ if (!accountId)
1697
+ return refuseNoAccount("memory-signals-recent");
1698
+ const result = await memorySignalsRecent({ accountId, windowHours, limit });
1699
+ return {
1700
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1701
+ };
1702
+ }
1703
+ catch (err) {
1704
+ return {
1705
+ content: [{
1706
+ type: "text",
1707
+ text: `memory-signals-recent failed: ${err instanceof Error ? err.message : String(err)}`,
1708
+ }],
1709
+ isError: true,
1710
+ };
1711
+ }
1712
+ });
1681
1713
  // -------------------------------------------------------------------------
1682
1714
  // Deny-list maintenance + conversation-memory expunge.
1683
1715
  //