@rubytech/create-maxy 1.0.799 → 1.0.801
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/dist/index.js +7 -1
- package/package.json +1 -1
- package/payload/platform/plugins/admin/hooks/__tests__/archive-ingest-surface-gate.test.sh +191 -0
- package/payload/platform/plugins/admin/hooks/archive-ingest-surface-gate.sh +207 -0
- package/payload/platform/plugins/cloudflare/references/manual-setup.md +12 -0
- package/payload/platform/plugins/cloudflare/scripts/_cdp-authorize-matcher.mjs +74 -0
- package/payload/platform/plugins/cloudflare/scripts/_cdp-authorize.mjs +60 -50
- package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +118 -22
- package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +4 -0
- package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
- package/payload/platform/plugins/whatsapp-import/PLUGIN.md +2 -2
- package/payload/platform/plugins/whatsapp-import/bin/ingest.mjs +732 -0
- package/payload/platform/plugins/whatsapp-import/bin/whatsapp-ingest.sh +102 -0
- package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/SKILL.md +49 -97
- package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/references/export-parse.md +1 -1
- package/payload/platform/scripts/seed-neo4j.sh +24 -15
- package/payload/platform/templates/specialists/agents/database-operator.md +13 -3
- package/payload/server/public/assets/{admin-C0lKk6WM.js → admin-Sa301b8q.js} +6 -6
- package/payload/server/public/index.html +1 -1
- package/payload/platform/plugins/admin/hooks/__tests__/archive-ingest-gate.test.sh +0 -166
- package/payload/platform/plugins/admin/hooks/archive-ingest-gate.sh +0 -147
- package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/references/conversation-and-messages.md +0 -99
- package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/references/insight-extraction.md +0 -121
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>Maxy</title>
|
|
7
7
|
<link rel="icon" href="/favicon.ico">
|
|
8
|
-
<script type="module" crossorigin src="/assets/admin-
|
|
8
|
+
<script type="module" crossorigin src="/assets/admin-Sa301b8q.js"></script>
|
|
9
9
|
<link rel="modulepreload" crossorigin href="/assets/chunk-DD-I1_y5.js">
|
|
10
10
|
<link rel="modulepreload" crossorigin href="/assets/jsx-runtime-DJER3a7U.js">
|
|
11
11
|
<link rel="modulepreload" crossorigin href="/assets/preload-helper-qlgyTAkD.js">
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Regression test for archive-ingest-gate.sh (Task 846).
|
|
3
|
-
#
|
|
4
|
-
# Six cases cover the contract:
|
|
5
|
-
# 1. Edit on /platform/plugins/<x>/lib/* is BLOCKED (exit 2).
|
|
6
|
-
# 2. Edit on a benign path is ALLOWED (exit 0).
|
|
7
|
-
# 3. Bash with `npx vitest` is BLOCKED.
|
|
8
|
-
# 4. PostToolUse on whatsapp-export-parse with isError:true sets the flag.
|
|
9
|
-
# 5. Subsequent PreToolUse on ANY tool is BLOCKED (post-parse-error gate).
|
|
10
|
-
# 6. UserPromptSubmit clears the flag, restoring normal allow behavior.
|
|
11
|
-
#
|
|
12
|
-
# Tests use ARCHIVE_INGEST_GATE_STATE_DIR to point at a tmp dir so they run
|
|
13
|
-
# without a real account layout.
|
|
14
|
-
|
|
15
|
-
set -u
|
|
16
|
-
|
|
17
|
-
HOOK="$(cd "$(dirname "$0")/.." && pwd)/archive-ingest-gate.sh"
|
|
18
|
-
if [[ ! -x "$HOOK" ]]; then
|
|
19
|
-
echo "FAIL: $HOOK not executable" >&2
|
|
20
|
-
exit 1
|
|
21
|
-
fi
|
|
22
|
-
|
|
23
|
-
# Per-run isolated state dir
|
|
24
|
-
STATE_DIR=$(mktemp -d)
|
|
25
|
-
export ARCHIVE_INGEST_GATE_STATE_DIR="$STATE_DIR"
|
|
26
|
-
FLAG_FILE="$STATE_DIR/archive-ingest-parse-error.flag"
|
|
27
|
-
|
|
28
|
-
cleanup() { rm -rf "$STATE_DIR"; }
|
|
29
|
-
trap cleanup EXIT
|
|
30
|
-
|
|
31
|
-
PASS=0
|
|
32
|
-
FAIL=0
|
|
33
|
-
|
|
34
|
-
run_case() {
|
|
35
|
-
local name="$1" stdin="$2" expected_exit="$3"
|
|
36
|
-
local actual_exit
|
|
37
|
-
printf '%s' "$stdin" | bash "$HOOK" >/dev/null 2>/dev/null
|
|
38
|
-
actual_exit=$?
|
|
39
|
-
if [[ "$actual_exit" -eq "$expected_exit" ]]; then
|
|
40
|
-
echo "PASS: $name (exit=$actual_exit)"
|
|
41
|
-
PASS=$((PASS + 1))
|
|
42
|
-
else
|
|
43
|
-
echo "FAIL: $name (expected exit=$expected_exit, got=$actual_exit)" >&2
|
|
44
|
-
FAIL=$((FAIL + 1))
|
|
45
|
-
fi
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
# Case 1 — Edit on plugin lib path: BLOCKED
|
|
49
|
-
run_case "Edit on platform/plugins/whatsapp-import/lib/src/parse-export.ts → BLOCKED" \
|
|
50
|
-
'{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"/Users/x/repo/platform/plugins/whatsapp-import/lib/src/parse-export.ts","old_string":"a","new_string":"b"}}' \
|
|
51
|
-
2
|
|
52
|
-
|
|
53
|
-
# Case 2 — Edit on a benign path: ALLOWED
|
|
54
|
-
run_case "Edit on README.md → ALLOWED" \
|
|
55
|
-
'{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"/Users/x/repo/README.md","old_string":"a","new_string":"b"}}' \
|
|
56
|
-
0
|
|
57
|
-
|
|
58
|
-
# Case 3 — Bash with `npx vitest`: BLOCKED
|
|
59
|
-
run_case "Bash 'npx vitest run parse-export.test.ts' → BLOCKED" \
|
|
60
|
-
'{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"npx vitest run parse-export.test.ts"}}' \
|
|
61
|
-
2
|
|
62
|
-
|
|
63
|
-
# Case 3b — Bash with benign command: ALLOWED
|
|
64
|
-
run_case "Bash 'ls -la' → ALLOWED" \
|
|
65
|
-
'{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls -la"}}' \
|
|
66
|
-
0
|
|
67
|
-
|
|
68
|
-
# Case 3c — Bash with `bun test`: BLOCKED
|
|
69
|
-
run_case "Bash 'bun test' → BLOCKED" \
|
|
70
|
-
'{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"bun test"}}' \
|
|
71
|
-
2
|
|
72
|
-
|
|
73
|
-
# Case 3d — Bash with `npm test`: BLOCKED
|
|
74
|
-
run_case "Bash 'npm test' → BLOCKED" \
|
|
75
|
-
'{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"npm test"}}' \
|
|
76
|
-
2
|
|
77
|
-
|
|
78
|
-
# Make sure flag is absent before parse-error simulation
|
|
79
|
-
rm -f "$FLAG_FILE"
|
|
80
|
-
|
|
81
|
-
# Case 4 — PostToolUse on whatsapp-export-parse with isError:true sets flag
|
|
82
|
-
run_case "PostToolUse parse-error sets flag (exit 0, flag side-effect)" \
|
|
83
|
-
'{"hook_event_name":"PostToolUse","tool_name":"mcp__memory__whatsapp-export-parse","tool_input":{"filePath":"_chat.txt"},"tool_response":{"isError":true,"content":[{"type":"text","text":"parse-error file=_chat.txt line=1 reason=not-a-_chat.txt"}]}}' \
|
|
84
|
-
0
|
|
85
|
-
|
|
86
|
-
if [[ -f "$FLAG_FILE" ]]; then
|
|
87
|
-
echo "PASS: parse-error flag created at $FLAG_FILE"
|
|
88
|
-
PASS=$((PASS + 1))
|
|
89
|
-
else
|
|
90
|
-
echo "FAIL: parse-error flag NOT created at $FLAG_FILE" >&2
|
|
91
|
-
FAIL=$((FAIL + 1))
|
|
92
|
-
fi
|
|
93
|
-
|
|
94
|
-
# Case 5 — Subsequent PreToolUse on ANY tool BLOCKED while flag is fresh
|
|
95
|
-
run_case "PreToolUse Read after parse-error → BLOCKED" \
|
|
96
|
-
'{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}' \
|
|
97
|
-
2
|
|
98
|
-
|
|
99
|
-
run_case "PreToolUse Bash after parse-error → BLOCKED" \
|
|
100
|
-
'{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo hi"}}' \
|
|
101
|
-
2
|
|
102
|
-
|
|
103
|
-
# Case 6 — UserPromptSubmit clears flag
|
|
104
|
-
run_case "UserPromptSubmit clears flag (exit 0)" \
|
|
105
|
-
'{"hook_event_name":"UserPromptSubmit","prompt":"retry"}' \
|
|
106
|
-
0
|
|
107
|
-
|
|
108
|
-
if [[ ! -f "$FLAG_FILE" ]]; then
|
|
109
|
-
echo "PASS: UserPromptSubmit cleared flag"
|
|
110
|
-
PASS=$((PASS + 1))
|
|
111
|
-
else
|
|
112
|
-
echo "FAIL: UserPromptSubmit did NOT clear flag" >&2
|
|
113
|
-
FAIL=$((FAIL + 1))
|
|
114
|
-
fi
|
|
115
|
-
|
|
116
|
-
# Case 7 — After clearance, normal allow resumes
|
|
117
|
-
run_case "PreToolUse Read after clearance → ALLOWED" \
|
|
118
|
-
'{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}' \
|
|
119
|
-
0
|
|
120
|
-
|
|
121
|
-
# Case 8 — PostToolUse with isError:false does NOT set flag
|
|
122
|
-
rm -f "$FLAG_FILE"
|
|
123
|
-
run_case "PostToolUse parse-success (isError:false) does NOT set flag" \
|
|
124
|
-
'{"hook_event_name":"PostToolUse","tool_name":"mcp__memory__whatsapp-export-parse","tool_input":{"filePath":"_chat.txt"},"tool_response":{"isError":false,"content":[{"type":"text","text":"{\"parsedLines\":[]}"}]}}' \
|
|
125
|
-
0
|
|
126
|
-
|
|
127
|
-
if [[ ! -f "$FLAG_FILE" ]]; then
|
|
128
|
-
echo "PASS: parse-success leaves flag absent"
|
|
129
|
-
PASS=$((PASS + 1))
|
|
130
|
-
else
|
|
131
|
-
echo "FAIL: parse-success incorrectly created flag" >&2
|
|
132
|
-
FAIL=$((FAIL + 1))
|
|
133
|
-
fi
|
|
134
|
-
|
|
135
|
-
# Case 9 — Stale flag (>600s) auto-clears + allows
|
|
136
|
-
PAST=$(( $(date -u +%s) - 700 ))
|
|
137
|
-
echo "$PAST" > "$FLAG_FILE"
|
|
138
|
-
run_case "Stale flag auto-clears, PreToolUse Read → ALLOWED" \
|
|
139
|
-
'{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}' \
|
|
140
|
-
0
|
|
141
|
-
|
|
142
|
-
# Case 10 — No stdin (terminal) fails closed
|
|
143
|
-
echo "Probing fail-closed behaviour (no stdin)..."
|
|
144
|
-
bash "$HOOK" </dev/null >/dev/null 2>/dev/null
|
|
145
|
-
ACTUAL=$?
|
|
146
|
-
# /dev/null IS a stdin — the `[ -t 0 ]` check tests for terminal, not file.
|
|
147
|
-
# A file/pipe stdin reads as empty, which produces empty hook_event_name and
|
|
148
|
-
# falls through to default `exit 0` (allow). The terminal-only fail-closed
|
|
149
|
-
# branch can't be tested non-interactively; verify the script reads `[ -t 0 ]`.
|
|
150
|
-
if grep -q '\[ -t 0 \]' "$HOOK"; then
|
|
151
|
-
echo "PASS: fail-closed terminal check is present"
|
|
152
|
-
PASS=$((PASS + 1))
|
|
153
|
-
else
|
|
154
|
-
echo "FAIL: fail-closed terminal check missing" >&2
|
|
155
|
-
FAIL=$((FAIL + 1))
|
|
156
|
-
fi
|
|
157
|
-
|
|
158
|
-
echo
|
|
159
|
-
echo "──────── archive-ingest-gate test summary ────────"
|
|
160
|
-
echo "PASS: $PASS"
|
|
161
|
-
echo "FAIL: $FAIL"
|
|
162
|
-
|
|
163
|
-
if [[ "$FAIL" -gt 0 ]]; then
|
|
164
|
-
exit 1
|
|
165
|
-
fi
|
|
166
|
-
exit 0
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Archive-ingest gate (Task 846).
|
|
3
|
-
#
|
|
4
|
-
# Three enforcements, one script — phase decided by `hook_event_name` on stdin:
|
|
5
|
-
#
|
|
6
|
-
# 1. PreToolUse Edit/Write/NotebookEdit: deny writes under
|
|
7
|
-
# `*platform/plugins/*/lib/*` (parser/CSV-shape source for any *-import or
|
|
8
|
-
# *-export plugin). The database-operator subagent has Read+Bash but not
|
|
9
|
-
# Edit/Write per its `tools:` frontmatter — yet it can still mutate files
|
|
10
|
-
# via Bash heredoc. The path block applies to every tool that takes a
|
|
11
|
-
# `file_path` and has been observed as the improvisation surface.
|
|
12
|
-
#
|
|
13
|
-
# 2. PreToolUse Bash: deny commands invoking JavaScript test runners
|
|
14
|
-
# (vitest|bun test|npm test|npx jest|node .*vitest). The reproducer
|
|
15
|
-
# incident (conv 47c6a590) saw the operator run all four variants in
|
|
16
|
-
# sequence after parse-export returned isError.
|
|
17
|
-
#
|
|
18
|
-
# 3. Parse-error gate: PostToolUse on any `mcp__*__*-export-parse` /
|
|
19
|
-
# `mcp__*__*-import-parse` tool whose `tool_response.isError == true`
|
|
20
|
-
# writes a flag file. Subsequent PreToolUse on ANY tool blocks until
|
|
21
|
-
# UserPromptSubmit clears the flag (semantics: "subagent's next action
|
|
22
|
-
# must be a user-facing message; further tool calls blocked"). A 600s
|
|
23
|
-
# TTL is the cross-session safety net.
|
|
24
|
-
#
|
|
25
|
-
# Exit codes follow Claude Code hook protocol: 0 = allow, 2 = block (stderr
|
|
26
|
-
# message shown to the agent). Fail-closed on stdin read failure to match
|
|
27
|
-
# pre-tool-use.sh.
|
|
28
|
-
|
|
29
|
-
set -uo pipefail
|
|
30
|
-
|
|
31
|
-
# Read stdin — fail closed if unavailable
|
|
32
|
-
if [ -t 0 ]; then
|
|
33
|
-
echo "Blocked: archive-ingest-gate received no stdin (cannot inspect tool call). Failing closed." >&2
|
|
34
|
-
exit 2
|
|
35
|
-
fi
|
|
36
|
-
INPUT=$(cat)
|
|
37
|
-
|
|
38
|
-
# ----- Resolve account dir for state file ----------------------------------
|
|
39
|
-
# Mirrors pre-tool-use.sh lines 100-107: walk from this hook's location to
|
|
40
|
-
# platform/, then `../data/accounts/<single-account>/`. Phase 0 has exactly
|
|
41
|
-
# one account directory.
|
|
42
|
-
HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
|
43
|
-
PLATFORM_ROOT_RESOLVED="${HOOK_DIR}/../../.."
|
|
44
|
-
ACCOUNTS_DIR="${PLATFORM_ROOT_RESOLVED}/../data/accounts"
|
|
45
|
-
ACCOUNT_DIR=""
|
|
46
|
-
if [ -d "$ACCOUNTS_DIR" ]; then
|
|
47
|
-
ACCOUNT_DIR=$(find "$ACCOUNTS_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -1)
|
|
48
|
-
fi
|
|
49
|
-
# Test harness override — tests pass `ARCHIVE_INGEST_GATE_STATE_DIR` to point
|
|
50
|
-
# at a tmp dir without needing a real account layout.
|
|
51
|
-
STATE_DIR="${ARCHIVE_INGEST_GATE_STATE_DIR:-${ACCOUNT_DIR}/state}"
|
|
52
|
-
FLAG_FILE="${STATE_DIR}/archive-ingest-parse-error.flag"
|
|
53
|
-
TTL_SECONDS=600
|
|
54
|
-
|
|
55
|
-
# ----- Parse fields from stdin ---------------------------------------------
|
|
56
|
-
# Hook protocol: every event includes `hook_event_name`. PreToolUse +
|
|
57
|
-
# PostToolUse include `tool_name`. PostToolUse adds `tool_response`.
|
|
58
|
-
# UserPromptSubmit has neither. Use grep/sed against the JSON envelope —
|
|
59
|
-
# no jq dependency to match the rest of the hook fleet.
|
|
60
|
-
HOOK_EVENT=$(printf '%s' "$INPUT" | grep -o '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
|
61
|
-
TOOL_NAME=$(printf '%s' "$INPUT" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
|
62
|
-
|
|
63
|
-
# ============================================================================
|
|
64
|
-
# UserPromptSubmit — clear the parse-error flag.
|
|
65
|
-
# ============================================================================
|
|
66
|
-
if [ "$HOOK_EVENT" = "UserPromptSubmit" ]; then
|
|
67
|
-
if [ -f "$FLAG_FILE" ]; then
|
|
68
|
-
rm -f "$FLAG_FILE" 2>/dev/null
|
|
69
|
-
echo "[archive-ingest-gate] cleared reason=user-prompt-submit" >&2
|
|
70
|
-
fi
|
|
71
|
-
exit 0
|
|
72
|
-
fi
|
|
73
|
-
|
|
74
|
-
# ============================================================================
|
|
75
|
-
# PostToolUse — record parse-error from any *-export-parse / *-import-parse.
|
|
76
|
-
# ============================================================================
|
|
77
|
-
if [ "$HOOK_EVENT" = "PostToolUse" ]; then
|
|
78
|
-
case "$TOOL_NAME" in
|
|
79
|
-
mcp__*__*-export-parse|mcp__*__*-import-parse)
|
|
80
|
-
# Inspect tool_response for isError true. Body is a JSON object; match
|
|
81
|
-
# `"isError":true` with optional whitespace.
|
|
82
|
-
if printf '%s' "$INPUT" | grep -Eq '"isError"[[:space:]]*:[[:space:]]*true'; then
|
|
83
|
-
mkdir -p "$STATE_DIR" 2>/dev/null
|
|
84
|
-
date -u +%s > "$FLAG_FILE" 2>/dev/null
|
|
85
|
-
echo "[archive-ingest-gate] surfaced reason=parse-error tool=${TOOL_NAME}" >&2
|
|
86
|
-
fi
|
|
87
|
-
;;
|
|
88
|
-
esac
|
|
89
|
-
# PostToolUse must always allow the tool result through.
|
|
90
|
-
exit 0
|
|
91
|
-
fi
|
|
92
|
-
|
|
93
|
-
# ============================================================================
|
|
94
|
-
# PreToolUse — three independent blocks.
|
|
95
|
-
# ============================================================================
|
|
96
|
-
if [ "$HOOK_EVENT" != "PreToolUse" ]; then
|
|
97
|
-
# Unknown event, or hook fired in a context without an event name. Allow.
|
|
98
|
-
exit 0
|
|
99
|
-
fi
|
|
100
|
-
|
|
101
|
-
# --- Block 1: post-parse-error gate (applies to ALL tools) -----------------
|
|
102
|
-
if [ -f "$FLAG_FILE" ]; then
|
|
103
|
-
FLAG_TS=$(cat "$FLAG_FILE" 2>/dev/null | head -1)
|
|
104
|
-
NOW=$(date -u +%s)
|
|
105
|
-
if [ -n "$FLAG_TS" ] && [ "$FLAG_TS" -gt 0 ] 2>/dev/null; then
|
|
106
|
-
AGE=$(( NOW - FLAG_TS ))
|
|
107
|
-
if [ "$AGE" -lt "$TTL_SECONDS" ]; then
|
|
108
|
-
echo "[archive-ingest-gate] block tool=${TOOL_NAME} reason=post-parse-error age_s=${AGE}" >&2
|
|
109
|
-
echo "Blocked: an archive-parser MCP tool returned isError=true earlier in this turn. The subagent's next action must be a user-facing message naming the parse-error and yielding back to the operator. Further tool calls are blocked until the operator submits a new prompt." >&2
|
|
110
|
-
exit 2
|
|
111
|
-
fi
|
|
112
|
-
# Stale flag — clean up so we don't keep blocking on a corpse.
|
|
113
|
-
rm -f "$FLAG_FILE" 2>/dev/null
|
|
114
|
-
else
|
|
115
|
-
# Unparseable flag — remove rather than block on garbage.
|
|
116
|
-
rm -f "$FLAG_FILE" 2>/dev/null
|
|
117
|
-
fi
|
|
118
|
-
fi
|
|
119
|
-
|
|
120
|
-
# --- Block 2: plugin-source path block (Edit/Write/NotebookEdit) -----------
|
|
121
|
-
case "$TOOL_NAME" in
|
|
122
|
-
Edit|Write|NotebookEdit)
|
|
123
|
-
FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
|
124
|
-
case "$FILE_PATH" in
|
|
125
|
-
*/platform/plugins/*/lib/*|platform/plugins/*/lib/*)
|
|
126
|
-
echo "[archive-ingest-gate] block tool=${TOOL_NAME} reason=plugin-source-edit path=${FILE_PATH}" >&2
|
|
127
|
-
echo "Blocked: ${TOOL_NAME} on ${FILE_PATH} is a platform plugin lib/ path. The database-operator subagent does not own plugin source; if a parser is broken, surface the parse-error to the operator and let them dispatch a code-edit task instead." >&2
|
|
128
|
-
exit 2
|
|
129
|
-
;;
|
|
130
|
-
esac
|
|
131
|
-
;;
|
|
132
|
-
esac
|
|
133
|
-
|
|
134
|
-
# --- Block 3: shell test-runner block (Bash) -------------------------------
|
|
135
|
-
if [ "$TOOL_NAME" = "Bash" ]; then
|
|
136
|
-
COMMAND=$(printf '%s' "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
|
137
|
-
# Match any of: `vitest`, `bun test`, `npm test`, `npx jest`, `node ... vitest`.
|
|
138
|
-
# Word-boundary checks via grep -E with explicit token boundaries — POSIX
|
|
139
|
-
# `[[:space:]]` covers leading-token edge cases, end-of-string covers tail.
|
|
140
|
-
if printf '%s' "$COMMAND" | grep -Eq '(^|[[:space:]/])vitest($|[[:space:]])|(^|[[:space:]])bun[[:space:]]+test($|[[:space:]])|(^|[[:space:]])npm[[:space:]]+test($|[[:space:]])|(^|[[:space:]])npx[[:space:]]+jest($|[[:space:]])|node[[:space:]].*vitest'; then
|
|
141
|
-
echo "[archive-ingest-gate] block tool=Bash reason=test-runner command=${COMMAND}" >&2
|
|
142
|
-
echo "Blocked: Bash command invokes a JavaScript test runner (vitest/bun test/npm test/npx jest). The database-operator subagent does not run plugin tests; surface the parse-error to the operator." >&2
|
|
143
|
-
exit 2
|
|
144
|
-
fi
|
|
145
|
-
fi
|
|
146
|
-
|
|
147
|
-
exit 0
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
# Reference: Conversation + Messages row schema
|
|
2
|
-
|
|
3
|
-
This reference defines the `archiveType='whatsapp-export'` row + conversation block + participantNodeIds shapes that the [SKILL.md](../SKILL.md) passes to `mcp__memory__memory-archive-write`. The Cypher body is fixed server-side in [`platform/plugins/memory/mcp/src/tools/memory-archive-write.ts`](../../../../memory/mcp/src/tools/memory-archive-write.ts); the agent's responsibility ends at producing valid input.
|
|
4
|
-
|
|
5
|
-
## Tool input shape
|
|
6
|
-
|
|
7
|
-
```json
|
|
8
|
-
{
|
|
9
|
-
"archiveType": "whatsapp-export",
|
|
10
|
-
"ownerNodeId": "<elementId of :AdminUser or :Person — operator who exported the chat>",
|
|
11
|
-
"sessionId": "<UUID — generated once per skill run>",
|
|
12
|
-
"conversation": {
|
|
13
|
-
"conversationId": "whatsapp-export:<sha256(file)>:<accountId>",
|
|
14
|
-
"archiveSourceFile": "<sha256(file)>",
|
|
15
|
-
"firstMessageAt": "2026-03-14T10:15:23Z",
|
|
16
|
-
"lastMessageAt": "2026-04-21T18:42:11Z",
|
|
17
|
-
"participantCount": 2,
|
|
18
|
-
"messageCount": 174
|
|
19
|
-
},
|
|
20
|
-
"participantNodeIds": ["<elemId-Joel>", "<elemId-Sarah>"],
|
|
21
|
-
"rows": [
|
|
22
|
-
{
|
|
23
|
-
"messageId": "whatsapp-export:<conversationId>:<lineHash>",
|
|
24
|
-
"conversationId": "<same as conversation.conversationId>",
|
|
25
|
-
"senderNodeId": "<elemId — operator-confirmed>",
|
|
26
|
-
"senderName": "Joel",
|
|
27
|
-
"dateSent": "2026-03-14T10:15:23Z",
|
|
28
|
-
"body": "Quick question about the deck —\ndo you have the v3 PDF anywhere?",
|
|
29
|
-
"sequenceIndex": 0
|
|
30
|
-
},
|
|
31
|
-
...
|
|
32
|
-
]
|
|
33
|
-
}
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
## What the server does (informational, not the agent's responsibility)
|
|
37
|
-
|
|
38
|
-
Each batch (max 500 rows) runs in one `executeWrite` transaction:
|
|
39
|
-
|
|
40
|
-
1. **MERGE Conversation** — `(:Conversation:WhatsAppConversation {conversationId})`. ON CREATE stamps full provenance + scalar metadata (firstMessageAt, lastMessageAt, participantCount, messageCount, archiveSourceFile). ON MATCH refreshes the moving scalars (`lastMessageAt`, `messageCount`, `participantCount`) plus `lastImportedAt` / `lastImportedBySession` for re-import audit; createdBy* stamps stay frozen.
|
|
41
|
-
2. **MATCH sender by elementId** — `WHERE elementId(sender) = row.senderNodeId`. The verifyParticipants preflight already confirmed every senderNodeId resolves; this MATCH never fails at runtime.
|
|
42
|
-
3. **MERGE Message** — `(:Message:WhatsAppMessage {messageId})`. ON CREATE stamps provenance, body, dateSent, senderName (raw, audit-only), sequenceIndex.
|
|
43
|
-
4. **MERGE PART_OF** — `(m)-[:PART_OF]->(c)`.
|
|
44
|
-
5. **MERGE SENT** — `(sender)-[:SENT]->(m)`. Per-row, idempotent.
|
|
45
|
-
6. **MERGE PARTICIPANT_IN** — `(sender)-[:PARTICIPANT_IN]->(c)`. Per-row, but rolls up across rows because MERGE on the same edge between the same pair is idempotent — a 200-message conversation with 2 participants ends with 2 PARTICIPANT_IN edges, not 200.
|
|
46
|
-
|
|
47
|
-
After all batches, the **finalize** hook runs one additional `executeWrite` transaction:
|
|
48
|
-
|
|
49
|
-
7. **MERGE NEXT chain** — `MATCH (m:Message)-[:PART_OF]->(c {conversationId})` ordered by `dateSent ASC, sequenceIndex ASC`, then UNWIND consecutive pairs, MERGE `(prev)-[:NEXT]->(next)`. Idempotent on MERGE — re-imports add only the new tail edges. Returns `nextEdges` count for the per-call log line.
|
|
50
|
-
|
|
51
|
-
## Natural keys + provenance
|
|
52
|
-
|
|
53
|
-
| Entity | Key | Provenance fields stamped |
|
|
54
|
-
|---|---|---|
|
|
55
|
-
| `:Conversation:WhatsAppConversation` | `conversationId` | `accountId, source='whatsapp', createdByAgent='whatsapp-import', createdBySource='whatsapp-import', createdBySession, createdAt=datetime(), scope='admin', agentType='admin', archiveSourceFile`. ON re-import, also: `lastImportedAt, lastImportedBySession`. |
|
|
56
|
-
| `:Message:WhatsAppMessage` | `messageId` | Same provenance set + `conversationId, dateSent, body, senderName, sequenceIndex, scope='admin'`. |
|
|
57
|
-
| `[:SENT]` | (sender, message) pair | `source='whatsapp', createdAt=datetime()`. |
|
|
58
|
-
| `[:PARTICIPANT_IN]` | (sender, conversation) pair | `source='whatsapp', createdAt=datetime()`. |
|
|
59
|
-
| `[:PART_OF]` | (message, conversation) pair | (no edge properties needed; the edge type is the signal). |
|
|
60
|
-
| `[:NEXT]` | (prev message, next message) pair | `source='whatsapp', createdAt=datetime()`. |
|
|
61
|
-
|
|
62
|
-
## Edges this reference does NOT mint
|
|
63
|
-
|
|
64
|
-
- `(:AdminUser)-[:OWNS]->(:Conversation)` — explicitly NOT created. The exporter is metadata-on-Conversation provenance, not a graph relationship. A `:Conversation` is owned by its `accountId`, the same way other account-scoped nodes are. Adding an OWNS edge would duplicate property-based scope with edge-based scope (anti-pattern; see `feedback_account_scope_is_a_property.md`).
|
|
65
|
-
- `(:Conversation)-[:OCCURRED_AT]->(:Date)` — date is a scalar property on the Conversation, not a separate node. Querying by date uses the property, not an edge traversal.
|
|
66
|
-
- `(:Message)-[:HAS_BODY]->(:Text)` — the body is a property on the Message; no separate Text node.
|
|
67
|
-
|
|
68
|
-
If a future query pattern reveals one of these is genuinely useful, file a separate task — never add them ad-hoc here.
|
|
69
|
-
|
|
70
|
-
## Divergence from live-channel `persistMessage`
|
|
71
|
-
|
|
72
|
-
The live `whatsapp` plugin's `persistMessage` in [`platform/ui/app/lib/neo4j-store.ts`](../../../../../ui/app/lib/neo4j-store.ts) holds a per-conversation lock when chaining NEXT — concurrent inbound messages to the same conversation would otherwise race to insert NEXT pointers. Bulk import does **not** need that lock because:
|
|
73
|
-
|
|
74
|
-
1. The whole import runs in one `database-operator` turn, no concurrency.
|
|
75
|
-
2. The finalize step runs ONE Cypher statement that scopes to a single `conversationId` and rebuilds the chain in `dateSent ASC` order. MERGE is idempotent; concurrent re-runs (which can't happen in one turn) would converge regardless.
|
|
76
|
-
|
|
77
|
-
If a future change runs imports in parallel across conversations, that's still safe — each conversationId's NEXT chain is rebuilt independently. If two parallel imports targeted the same conversationId (operator scripting error), MERGE-on-NEXT idempotency saves the graph; the latest write wins on edge properties.
|
|
78
|
-
|
|
79
|
-
## Counter shape returned to the agent
|
|
80
|
-
|
|
81
|
-
```json
|
|
82
|
-
{
|
|
83
|
-
"archiveType": "whatsapp-export",
|
|
84
|
-
"processedRows": 174,
|
|
85
|
-
"counters": {
|
|
86
|
-
"createdMessages": 174,
|
|
87
|
-
"relationshipsCreated": 524,
|
|
88
|
-
"labelsAdded": 348,
|
|
89
|
-
"nextEdgesProcessed": 173,
|
|
90
|
-
"nextEdgesCreated": 173
|
|
91
|
-
},
|
|
92
|
-
"errors": [],
|
|
93
|
-
"nextChainStatus": "ok"
|
|
94
|
-
}
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
The skill displays the counts to the operator in chat and emits the `[whatsapp-import] file=...` log line using `processedRows`, `nextEdgesCreated`, and the elapsed time. The exact counter keys evolve (per-handler), so the skill should iterate the `counters` map rather than destructure specific names.
|
|
98
|
-
|
|
99
|
-
When a batch errors, `errors[]` contains `{rowIndex, reason}`; `nextChainStatus` becomes `"partial"` (finalize still runs over whatever messages did land); the skill's chat output names the failed offset and the operator decides whether to re-import.
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
# Reference: Insight extraction (pass 2)
|
|
2
|
-
|
|
3
|
-
After the deterministic ingest completes, the [SKILL.md](../SKILL.md) runs a second pass that emits **first-class graph entities** capturing what the conversation is *about*. Insights are not summaries; they are typed nodes and edges the operator's queries can traverse.
|
|
4
|
-
|
|
5
|
-
This pass runs INLINE in the database-operator specialist's own LLM turn — Sonnet — calling existing memory tools. There is no separate insight-extraction MCP tool. Reuse-over-invent: each observation maps onto an existing graph label or edge type wherever possible. `:Insight` is the last resort.
|
|
6
|
-
|
|
7
|
-
## Reuse-over-invent — the six observation kinds
|
|
8
|
-
|
|
9
|
-
| Kind | Maps to | Edge from | Edge to | Edge type | Notes |
|
|
10
|
-
|---|---|---|---|---|---|
|
|
11
|
-
| Third-party mention | `:Person` (existing) | `:Message` | `:Person` | `:MENTIONS` | Hallucination gate: see below. |
|
|
12
|
-
| Recurring topic (≥3 mentions) | `:DefinedTerm` (existing) with `category:'topic'` | `:Conversation` | `:DefinedTerm` | `:DISCUSSES` (with `frequency` property) | Surface as a topic only if the term recurs ≥3 distinct messages. |
|
|
13
|
-
| Expressed preference | `:Preference` (existing, see [schema.cypher:588](../../../../../neo4j/schema.cypher#L588-L595)) | `:Person` or `:AdminUser` | `:Preference` | `:HAS_PREFERENCE` + `(:Preference)-[:OBSERVED_IN]->(:Conversation)` | Preference text is a single observation, not a paragraph. |
|
|
14
|
-
| Commitment / action item | `:Task` (existing) | `:Person` or `:AdminUser` | `:Task` | `:OWNS` | Set `dueDate` only when explicitly named ("by Friday", "next week"). Skip for vague "soon". |
|
|
15
|
-
| Inter-person relationship | (matched `:Person` nodes) | `:Person` | `:Person` | `:RELATED_TO` (with `kind`, `evidenceMessageIds[]`) | Operator-confirmation gate before write — see below. |
|
|
16
|
-
| Genuinely novel finding | `:Insight` (new label, last resort only) | `:Insight` | `:Message` | `:DERIVED_FROM` | Only when reuse-over-invent fails for every existing label. Self-rated `confidence` 0–1. |
|
|
17
|
-
|
|
18
|
-
## Anti-hallucination gates — server-enforced (Task 805)
|
|
19
|
-
|
|
20
|
-
The biggest risk in this pass is Sonnet writing edges to wrong-Person nodes. **Three gates protect the graph and they live in code, not prose.** `mcp__memory__whatsapp-export-insight-write` enforces all of them server-side; the agent cannot bypass them by skipping this section. This file describes how the gates work and what the agent must supply to pass them; the [tool source](../../../../memory/mcp/src/tools/whatsapp-export-insight-write.ts) is the canonical contract.
|
|
21
|
-
|
|
22
|
-
`:MENTIONS` and `:RELATED_TO` writes ROUTE THROUGH this tool. Other observation kinds (`:Preference`, `:Task`, `:DefinedTerm`, `:Insight`) keep using `memory-write` because their adjacency is not subject to wrong-Person ambiguity.
|
|
23
|
-
|
|
24
|
-
### Gate 1: candidate-overlap (re-run `memory-search` before every `:MENTIONS` write)
|
|
25
|
-
|
|
26
|
-
For every `:MENTIONS` edge candidate the agent runs `memory-search(query=<mentioned-name>, labels=['Person','AdminUser'])` first, then calls `whatsapp-export-insight-write` with the resulting candidate `nodeId`s in `candidateElementIds`. **The server re-runs the same search and asserts at least one of those IDs appears in the live result.** Mismatch → `gate-rejected reason=candidate-mismatch`.
|
|
27
|
-
|
|
28
|
-
- **0 hits** — the mentioned name doesn't exist in the graph. Don't write — the server would reject with `candidate-mismatch` because there is nothing to overlap. Do not auto-mint a `:Person` from a chat-mention alone.
|
|
29
|
-
- **1+ hits** — supply the `nodeId`(s) the agent expects to be the referent. Server confirms by re-running the search.
|
|
30
|
-
|
|
31
|
-
### Gate 2: first-name-only rejection (independent of hit count)
|
|
32
|
-
|
|
33
|
-
Single-token names ("Sarah") without an explicit disambiguator are rejected at the tool boundary regardless of memory-search hit count — that one match might be the wrong Sarah. The rule lives in code: if `name` lacks whitespace AND lacks digits AND `disambiguatorOk` is not `true`, the tool returns `gate-rejected reason=first-name-only` and writes nothing.
|
|
34
|
-
|
|
35
|
-
- The mention has a **disambiguator** (full name "Sarah Chen", phone, email, role context "Sarah at Acme") → set `disambiguatorOk: true` in the tool call, gate passes if Gate 1 also passes.
|
|
36
|
-
- The mention is a **first-name only** without disambiguator → omit `disambiguatorOk` (or set `false`); the tool refuses the write. The agent surfaces this to the operator as ambiguous and asks for confirmation before retrying with `disambiguatorOk: true`.
|
|
37
|
-
|
|
38
|
-
Surface format when the operator must disambiguate:
|
|
39
|
-
|
|
40
|
-
```
|
|
41
|
-
[whatsapp-import] mention-ambiguous name="Sarah" reason=first-name-only candidates=1 awaiting-operator-resolution
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
Followed by a chat prompt: `"Sarah" mentioned in message <messageId>. Found 1 :Person candidate: Sarah Chen (sarah@acme.com). Confirm? Yes / No / Pick another.`
|
|
45
|
-
|
|
46
|
-
### Gate 3: `:RELATED_TO` requires `operatorConfirmed: true`
|
|
47
|
-
|
|
48
|
-
When the second pass infers a relationship between two `:Person` nodes who both already exist in the graph (e.g., chat says "Joel and Sarah are working on Q3 together" → `(joel)-[:RELATED_TO {kind:'collaborator'}]->(sarah)`), the agent surfaces the inferred edge with both endpoints' names + supporting message excerpts. On operator yes, the agent calls `whatsapp-export-insight-write(kind='RELATED_TO', operatorConfirmed: true, evidenceMessageIds: [...])`. **Without `operatorConfirmed=true` the tool returns `gate-rejected reason=relationship-needs-confirm` and writes nothing.**
|
|
49
|
-
|
|
50
|
-
The default for this gate is conservative — when in doubt, surface. False-positive RELATED_TO edges are graph noise; false-negative skips can be re-run.
|
|
51
|
-
|
|
52
|
-
### Endpoint label + accountId checks (free with the tool)
|
|
53
|
-
|
|
54
|
-
`whatsapp-export-insight-write` also rejects writes whose endpoints are missing, cross-account, or wrong-labelled (a MENTIONS source must be a :Message; the target must be :Person/:AdminUser; RELATED_TO requires both endpoints to be :Person/:AdminUser). These are tool-level invariants — the agent does not need to re-check them in skill code.
|
|
55
|
-
|
|
56
|
-
## Chunking strategy
|
|
57
|
-
|
|
58
|
-
For conversations with 100+ messages, chunk the input to the inline LLM turn at ~50 messages per chunk. The classifier processes each chunk independently; the skill aggregates observations across chunks before writing. Aggregation deduplicates (the same `:MENTIONS` edge would otherwise be proposed once per chunk that referenced the same person).
|
|
59
|
-
|
|
60
|
-
Per-chunk processing emits one log line for grep-ability:
|
|
61
|
-
|
|
62
|
-
```
|
|
63
|
-
[whatsapp-import] insight-pass-chunk index=<i> messages=<n> mentions-proposed=<n> preferences-proposed=<n> tasks-proposed=<n>
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
After all chunks complete and observations are written, the per-pass summary log line fires:
|
|
67
|
-
|
|
68
|
-
```
|
|
69
|
-
[whatsapp-import] insight-pass model=sonnet chunks=<n> mentions=<n> preferences=<n> tasks=<n> observed-relationships=<n> novel-insights=<n> ms=<elapsed>
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
The numbers in the summary line are **edges actually written**, not edges proposed. Proposed-but-skipped (zero hits, ambiguous-skipped, operator-rejected) don't count.
|
|
73
|
-
|
|
74
|
-
## When to mint `:Insight`
|
|
75
|
-
|
|
76
|
-
Only when **every** of the six observation kinds above fails to fit. Examples that DO fit existing labels:
|
|
77
|
-
|
|
78
|
-
- "I prefer dark roast" → `:Preference`
|
|
79
|
-
- "Send the deck Friday" → `:Task` with `dueDate`
|
|
80
|
-
- "Sarah keeps mentioning Q3 reporting" → `:DefinedTerm{category:'topic', name:'Q3 reporting'}` + `:DISCUSSES`
|
|
81
|
-
- "Joel works with Sarah" → `:RELATED_TO`
|
|
82
|
-
|
|
83
|
-
Examples that genuinely warrant `:Insight`:
|
|
84
|
-
|
|
85
|
-
- "The team's morale dropped after the office move" — observation about a state change with no entity it cleanly attaches to.
|
|
86
|
-
- "There's a recurring tension between sales and ops on lead-handoff timing" — meta-observation about an organisational dynamic.
|
|
87
|
-
- "Joel's writing style shifts to formal English when discussing legal matters" — a behavioural pattern that doesn't fit `:Preference`.
|
|
88
|
-
|
|
89
|
-
Each `:Insight` carries `summary` (one sentence, ≤200 chars), `kind` (free-text classifier label), `confidence` (Sonnet self-rated 0–1), plus the standard provenance stamps. `:DERIVED_FROM` edges link to the SPECIFIC messages that supported the insight (not the entire conversation).
|
|
90
|
-
|
|
91
|
-
```cypher
|
|
92
|
-
MERGE (i:Insight {insightId: $insightId})
|
|
93
|
-
ON CREATE SET
|
|
94
|
-
i.summary = $summary,
|
|
95
|
-
i.kind = $kind,
|
|
96
|
-
i.confidence = $confidence,
|
|
97
|
-
i.accountId = $accountId,
|
|
98
|
-
i.source = 'whatsapp',
|
|
99
|
-
i.createdByAgent = 'whatsapp-import',
|
|
100
|
-
i.createdBySession = $sessionId,
|
|
101
|
-
i.createdAt = datetime(),
|
|
102
|
-
i.scope = 'admin'
|
|
103
|
-
WITH i, $messageIds AS mids
|
|
104
|
-
UNWIND mids AS mid
|
|
105
|
-
MATCH (m:Message {messageId: mid})
|
|
106
|
-
MERGE (i)-[d:DERIVED_FROM]->(m)
|
|
107
|
-
ON CREATE SET d.createdAt = datetime()
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
This Cypher runs through `mcp__memory__memory-write` (single-node + relationships payload), not through `memory-archive-write`. The agent does not author this Cypher directly — the schema-aware writer translates the structured payload.
|
|
111
|
-
|
|
112
|
-
## What pass 2 does NOT do
|
|
113
|
-
|
|
114
|
-
- **Does not summarise the conversation.** A `:KnowledgeDocument` summary is the wrong shape per `feedback_archives_are_not_documents.md`.
|
|
115
|
-
- **Does not score sentiment** per message. Sentiment is noise at the per-message level. Conversation-level emotional tone may show up as a `:Insight` if genuinely interesting.
|
|
116
|
-
- **Does not aggregate across conversations.** Each export ingests in isolation. Cross-conversation patterns are a query-time concern using the `createdBySession` provenance index.
|
|
117
|
-
- **Does not invent identifiers.** A `:MENTIONS` candidate without a `memory-search` hit is dropped, never auto-minted as a placeholder Person.
|
|
118
|
-
|
|
119
|
-
## Operator-side discipline
|
|
120
|
-
|
|
121
|
-
The operator may interrupt at any prompt during pass 2 and ask "skip the insight pass, just commit the messages." The skill obeys — no retroactive insight scan, no "let me extract a few quick ones first." Pass 1's Conversation+Messages is independent and complete on its own. The insight pass is opt-out at any prompt.
|