@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.
- package/package.json +1 -1
- package/payload/platform/neo4j/schema.cypher +73 -0
- package/payload/platform/plugins/admin/PLUGIN.md +1 -0
- package/payload/platform/plugins/admin/hooks/signal-detector-stop.sh +309 -0
- package/payload/platform/plugins/memory/PLUGIN.md +7 -0
- package/payload/platform/plugins/memory/mcp/dist/index.js +32 -0
- package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js +62 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-signals-recent.d.ts +35 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-signals-recent.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-signals-recent.js +73 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-signals-recent.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/vitest.config.ts +2 -0
- package/payload/platform/scripts/seed-neo4j.sh +2 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +12 -0
- package/payload/platform/templates/specialists/agents/signal-detector.md +129 -0
- package/payload/server/public/assets/{admin-DOxHsV7g.js → admin-D6IfAzYY.js} +1 -1
- package/payload/server/public/assets/{data-NMvGx6jm.js → data-BGbQyufe.js} +1 -1
- package/payload/server/public/assets/{graph-BkwTCV2Y.js → graph-D-1lRTeL.js} +1 -1
- package/payload/server/public/assets/{graph-labels-h39S93Xo.js → graph-labels-BRXJHNYE.js} +1 -1
- package/payload/server/public/assets/{page-B0iMmY6t.js → page-B4oirCvn.js} +1 -1
- package/payload/server/public/assets/{page-zQ79YCEt.js → page-FmJ7PIYx.js} +2 -2
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +3 -3
- package/payload/server/public/index.html +4 -4
- package/payload/server/server.js +13 -0
package/package.json
CHANGED
|
@@ -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
|
//
|