@rubytech/create-maxy-code 0.1.434 → 0.1.436

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 (83) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-11-task-1557-operator-on-behalf-preference-attribution.md +542 -0
  3. package/payload/platform/docs/superpowers/specs/2026-07-11-task-1557-operator-on-behalf-preference-attribution-design.md +90 -0
  4. package/payload/platform/lib/embed-client/dist/index.d.ts +2 -2
  5. package/payload/platform/lib/embed-client/dist/index.d.ts.map +1 -1
  6. package/payload/platform/lib/embed-client/dist/index.js +114 -37
  7. package/payload/platform/lib/embed-client/dist/index.js.map +1 -1
  8. package/payload/platform/lib/embed-client/src/index.ts +120 -37
  9. package/payload/platform/plugins/admin/PLUGIN.md +1 -0
  10. package/payload/platform/plugins/admin/hooks/__tests__/askuserquestion-channel-carrier-gate.test.sh +175 -0
  11. package/payload/platform/plugins/admin/hooks/askuserquestion-channel-carrier-gate.sh +137 -0
  12. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +2 -2
  13. package/payload/platform/plugins/docs/references/internals.md +1 -1
  14. package/payload/platform/plugins/memory/PLUGIN.md +2 -0
  15. package/payload/platform/plugins/memory/mcp/dist/index.js +42 -22
  16. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  17. package/payload/platform/plugins/memory/mcp/dist/lib/embed-progress-reporter.d.ts +22 -0
  18. package/payload/platform/plugins/memory/mcp/dist/lib/embed-progress-reporter.d.ts.map +1 -0
  19. package/payload/platform/plugins/memory/mcp/dist/lib/embed-progress-reporter.js +43 -0
  20. package/payload/platform/plugins/memory/mcp/dist/lib/embed-progress-reporter.js.map +1 -0
  21. package/payload/platform/plugins/memory/mcp/dist/lib/profile-identity.d.ts +34 -0
  22. package/payload/platform/plugins/memory/mcp/dist/lib/profile-identity.d.ts.map +1 -0
  23. package/payload/platform/plugins/memory/mcp/dist/lib/profile-identity.js +22 -0
  24. package/payload/platform/plugins/memory/mcp/dist/lib/profile-identity.js.map +1 -0
  25. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-admin-home-account.d.ts +13 -0
  26. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-admin-home-account.d.ts.map +1 -0
  27. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-admin-home-account.js +26 -0
  28. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-admin-home-account.js.map +1 -0
  29. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embed-progress-reporter.test.d.ts +2 -0
  30. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embed-progress-reporter.test.d.ts.map +1 -0
  31. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embed-progress-reporter.test.js +52 -0
  32. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embed-progress-reporter.test.js.map +1 -0
  33. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embed-progress.test.d.ts +2 -0
  34. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embed-progress.test.d.ts.map +1 -0
  35. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embed-progress.test.js +56 -0
  36. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embed-progress.test.js.map +1 -0
  37. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embed-throughput.test.js +186 -19
  38. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embed-throughput.test.js.map +1 -1
  39. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embed-timeout.test.js +11 -9
  40. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embed-timeout.test.js.map +1 -1
  41. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embeddings-cap.test.js +18 -12
  42. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/embeddings-cap.test.js.map +1 -1
  43. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-identity.test.d.ts +2 -0
  44. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-identity.test.d.ts.map +1 -0
  45. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-identity.test.js +72 -0
  46. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-identity.test.js.map +1 -0
  47. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-own-preference-selfheal.test.d.ts +2 -0
  48. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-own-preference-selfheal.test.d.ts.map +1 -0
  49. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-own-preference-selfheal.test.js +113 -0
  50. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-own-preference-selfheal.test.js.map +1 -0
  51. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-personfields-bootstrap.test.js +2 -2
  52. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-personfields-bootstrap.test.js.map +1 -1
  53. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-reconcile.test.js +6 -0
  54. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-reconcile.test.js.map +1 -1
  55. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/resolve-admin-home-account.test.d.ts +2 -0
  56. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/resolve-admin-home-account.test.d.ts.map +1 -0
  57. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/resolve-admin-home-account.test.js +38 -0
  58. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/resolve-admin-home-account.test.js.map +1 -0
  59. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts +9 -0
  60. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
  61. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +2 -2
  62. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
  63. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js +52 -7
  64. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js.map +1 -1
  65. package/payload/platform/plugins/memory/skills/conversational-memory/SKILL.md +8 -0
  66. package/payload/platform/scripts/lib/provision-account-dir.sh +2 -1
  67. package/payload/platform/services/claude-session-manager/dist/index.js +20 -0
  68. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  69. package/payload/platform/services/claude-session-manager/dist/interactive-wedge.d.ts +42 -0
  70. package/payload/platform/services/claude-session-manager/dist/interactive-wedge.d.ts.map +1 -0
  71. package/payload/platform/services/claude-session-manager/dist/interactive-wedge.js +149 -0
  72. package/payload/platform/services/claude-session-manager/dist/interactive-wedge.js.map +1 -0
  73. package/payload/server/{chunk-UUG6ZSO5.js → chunk-LMLVOZ6G.js} +35 -2
  74. package/payload/server/maxy-edge.js +1 -1
  75. package/payload/server/public/assets/{chat-C56bmC6C.js → chat-DUc7bt5W.js} +1 -1
  76. package/payload/server/public/assets/{operator-B4wN2c53.js → operator-B3Mi3TW0.js} +1 -1
  77. package/payload/server/public/assets/page-DbUaFfk6.js +32 -0
  78. package/payload/server/public/assets/{public-DmBKnBkr.js → public-CYhDdDJd.js} +1 -1
  79. package/payload/server/public/chat.html +2 -2
  80. package/payload/server/public/operator.html +2 -2
  81. package/payload/server/public/public.html +2 -2
  82. package/payload/server/server.js +8 -7
  83. package/payload/server/public/assets/page-BIfOKi9I.js +0 -32
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env bash
2
+ # Regression test for askuserquestion-channel-carrier-gate.sh.
3
+ #
4
+ # The hook blocks AskUserQuestion when the session has a native channel
5
+ # attached — signalled by a channel MCP config file for this session id in
6
+ # tmpdir. A channel carries no elicitation card, so the turn would wedge; the
7
+ # hook steers the agent to ask in prose via its channel reply tool instead.
8
+ #
9
+ # Covers (per .tasks/1552):
10
+ # 1. Channel attached (webchat config file present) + AskUserQuestion → BLOCK channel-attached
11
+ # 2. No channel file + AskUserQuestion → ALLOW no-channel
12
+ # 3. Non-AskUserQuestion tool → exit 0 silent (no [ask-channel] line)
13
+ # 4. PostToolUse event on AskUserQuestion → exit 0 silent
14
+ # 5. Missing session_id → ALLOW fail-open-no-session
15
+ # 6. No stdin envelope → ALLOW fail-open-no-envelope
16
+ # 7. Each of the 4 channel filename shapes present → BLOCK channel-attached
17
+ # 8. Session id needing sanitization, sanitized file present → BLOCK channel-attached
18
+ #
19
+ # All decisive cases assert exit code AND the decision-log signature on stderr.
20
+
21
+ set -u
22
+
23
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/askuserquestion-channel-carrier-gate.sh"
24
+ if [ ! -x "$HOOK" ]; then
25
+ echo "FAIL: $HOOK not executable" >&2
26
+ exit 1
27
+ fi
28
+
29
+ PASS=0
30
+ FAIL=0
31
+
32
+ # Run one case. Args:
33
+ # $1 = name
34
+ # $2 = stdin payload ("" for no-envelope: closed stdin)
35
+ # $3 = tmpdir the hook should scan (ASK_CHANNEL_TMPDIR override), "" for default
36
+ # $4 = expected exit code
37
+ # $5 = expected log signature regex (matched against stderr; "" = assert stderr is empty)
38
+ run_case() {
39
+ local name="$1" stdin="$2" scan_dir="$3" expected_exit="$4" expected_log_re="$5"
40
+ local stderr_file actual_exit
41
+ stderr_file="$(mktemp)"
42
+ if [ -z "$stdin" ]; then
43
+ # No envelope: close stdin so the hook's `[ -t 0 ]` / empty-read path fires.
44
+ ASK_CHANNEL_TMPDIR="$scan_dir" bash "$HOOK" >/dev/null 2>"$stderr_file" </dev/null
45
+ else
46
+ printf '%s' "$stdin" | ASK_CHANNEL_TMPDIR="$scan_dir" bash "$HOOK" >/dev/null 2>"$stderr_file"
47
+ fi
48
+ actual_exit=$?
49
+ local stderr_content
50
+ stderr_content=$(cat "$stderr_file")
51
+ rm -f "$stderr_file"
52
+
53
+ local exit_ok=0 log_ok=0
54
+ if [ "$actual_exit" -eq "$expected_exit" ]; then exit_ok=1; fi
55
+ if [ -z "$expected_log_re" ]; then
56
+ # Empty regex means: assert the hook emitted NO [ask-channel] line.
57
+ if ! printf '%s' "$stderr_content" | grep -q '\[ask-channel\]'; then log_ok=1; fi
58
+ else
59
+ if printf '%s' "$stderr_content" | grep -Eq "$expected_log_re"; then log_ok=1; fi
60
+ fi
61
+
62
+ if [ $exit_ok -eq 1 ] && [ $log_ok -eq 1 ]; then
63
+ echo "PASS: $name (exit=$actual_exit)"
64
+ PASS=$((PASS + 1))
65
+ else
66
+ echo "FAIL: $name" >&2
67
+ [ $exit_ok -eq 0 ] && echo " expected exit=$expected_exit, got=$actual_exit" >&2
68
+ [ $log_ok -eq 0 ] && echo " expected log regex: ${expected_log_re:-<none>}" >&2 && \
69
+ echo " actual stderr: $stderr_content" >&2
70
+ FAIL=$((FAIL + 1))
71
+ fi
72
+ }
73
+
74
+ SID="1a085718-1111-2222-3333-444455556666"
75
+ SID8="1a085718"
76
+
77
+ # Stdin payloads.
78
+ PRE_ASK="{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"AskUserQuestion\",\"session_id\":\"$SID\",\"tool_input\":{\"questions\":[]}}"
79
+ PRE_OTHER="{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\"session_id\":\"$SID\",\"tool_input\":{\"command\":\"ls\"}}"
80
+ POST_ASK="{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"AskUserQuestion\",\"session_id\":\"$SID\",\"tool_input\":{\"questions\":[]}}"
81
+ PRE_ASK_NO_SID='{"hook_event_name":"PreToolUse","tool_name":"AskUserQuestion","tool_input":{"questions":[]}}'
82
+
83
+ # ---------- Cases ----------
84
+
85
+ # 1. Channel attached (webchat config present) → BLOCK.
86
+ DIR_WEBCHAT=$(mktemp -d)
87
+ : > "$DIR_WEBCHAT/maxy-webchat-channel-$SID.json"
88
+ run_case "Webchat channel attached → BLOCK channel-attached" \
89
+ "$PRE_ASK" "$DIR_WEBCHAT" 2 \
90
+ "\[ask-channel\] decision=block sessionId=$SID8 channel=attached reason=channel-attached"
91
+ rm -rf "$DIR_WEBCHAT"
92
+
93
+ # 2. No channel file → ALLOW no-channel.
94
+ DIR_EMPTY=$(mktemp -d)
95
+ run_case "No channel file → ALLOW no-channel" \
96
+ "$PRE_ASK" "$DIR_EMPTY" 0 \
97
+ "\[ask-channel\] decision=allow sessionId=$SID8 channel=none reason=no-channel"
98
+ rm -rf "$DIR_EMPTY"
99
+
100
+ # 3. Non-AskUserQuestion tool → silent exit 0.
101
+ DIR_WITH_FILE=$(mktemp -d)
102
+ : > "$DIR_WITH_FILE/maxy-webchat-channel-$SID.json"
103
+ run_case "Non-AskUserQuestion tool → exit 0 silent" \
104
+ "$PRE_OTHER" "$DIR_WITH_FILE" 0 ""
105
+ rm -rf "$DIR_WITH_FILE"
106
+
107
+ # 4. PostToolUse event → silent exit 0 (even with channel file present).
108
+ DIR_POST=$(mktemp -d)
109
+ : > "$DIR_POST/maxy-webchat-channel-$SID.json"
110
+ run_case "PostToolUse on AskUserQuestion → exit 0 silent" \
111
+ "$POST_ASK" "$DIR_POST" 0 ""
112
+ rm -rf "$DIR_POST"
113
+
114
+ # 5. Missing session_id → ALLOW fail-open-no-session.
115
+ DIR_NOSID=$(mktemp -d)
116
+ run_case "Missing session_id → ALLOW fail-open-no-session" \
117
+ "$PRE_ASK_NO_SID" "$DIR_NOSID" 0 \
118
+ '\[ask-channel\] decision=allow sessionId=- channel=none reason=fail-open-no-session'
119
+ rm -rf "$DIR_NOSID"
120
+
121
+ # 6. No stdin envelope → ALLOW fail-open-no-envelope.
122
+ run_case "No stdin envelope → ALLOW fail-open-no-envelope" \
123
+ "" "" 0 \
124
+ '\[ask-channel\] decision=allow sessionId=- channel=none reason=fail-open-no-envelope'
125
+
126
+ # 7. Each of the 4 channel filename shapes present → BLOCK.
127
+ for kind in channel webchat-channel wa-channel telegram-channel; do
128
+ DIR_KIND=$(mktemp -d)
129
+ : > "$DIR_KIND/maxy-$kind-$SID.json"
130
+ run_case "Channel file shape maxy-$kind-<sid>.json → BLOCK channel-attached" \
131
+ "$PRE_ASK" "$DIR_KIND" 2 \
132
+ "\[ask-channel\] decision=block sessionId=$SID8 channel=attached reason=channel-attached"
133
+ rm -rf "$DIR_KIND"
134
+ done
135
+
136
+ # 8. Session id needing sanitization: raw id has chars outside [A-Za-z0-9_-];
137
+ # the writer sanitizes to '_', so the on-disk file uses the sanitized id.
138
+ RAW_SID="acc/one:sess" # '/' and ':' are sanitized to '_'
139
+ SAN_SID="acc_one_sess"
140
+ DIR_SAN=$(mktemp -d)
141
+ : > "$DIR_SAN/maxy-webchat-channel-$SAN_SID.json"
142
+ PRE_ASK_RAW="{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"AskUserQuestion\",\"session_id\":\"$RAW_SID\",\"tool_input\":{\"questions\":[]}}"
143
+ run_case "Sanitized session id resolves the file → BLOCK channel-attached" \
144
+ "$PRE_ASK_RAW" "$DIR_SAN" 2 \
145
+ '\[ask-channel\] decision=block sessionId=acc/one: channel=attached reason=channel-attached'
146
+ rm -rf "$DIR_SAN"
147
+
148
+ # 9. Real tmpdir resolution fallback: no ASK_CHANNEL_TMPDIR override, TMPDIR
149
+ # unset, TMP set → the hook must resolve TMP (mirroring Node's os.tmpdir())
150
+ # and find the config file there. Proves the production resolution order, not
151
+ # just the test override.
152
+ DIR_TMP=$(mktemp -d)
153
+ : > "$DIR_TMP/maxy-webchat-channel-$SID.json"
154
+ tmp_stderr="$(mktemp)"
155
+ printf '%s' "$PRE_ASK" | env -u TMPDIR -u ASK_CHANNEL_TMPDIR -u TEMP TMP="$DIR_TMP" \
156
+ bash "$HOOK" >/dev/null 2>"$tmp_stderr"
157
+ tmp_exit=$?
158
+ if [ "$tmp_exit" -eq 2 ] && grep -Eq "\[ask-channel\] decision=block sessionId=$SID8 channel=attached reason=channel-attached" "$tmp_stderr"; then
159
+ echo "PASS: TMP-fallback resolution finds the config file → BLOCK (exit=$tmp_exit)"
160
+ PASS=$((PASS + 1))
161
+ else
162
+ echo "FAIL: TMP-fallback resolution" >&2
163
+ echo " expected exit=2 got=$tmp_exit; stderr: $(cat "$tmp_stderr")" >&2
164
+ FAIL=$((FAIL + 1))
165
+ fi
166
+ rm -f "$tmp_stderr"
167
+ rm -rf "$DIR_TMP"
168
+
169
+ echo
170
+ echo "──────── askuserquestion-channel-carrier-gate test summary ────────"
171
+ echo "PASS: $PASS"
172
+ echo "FAIL: $FAIL"
173
+
174
+ [ "$FAIL" -gt 0 ] && exit 1
175
+ exit 0
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env bash
2
+ # AskUserQuestion native-channel carrier gate.
3
+ #
4
+ # Blocks `AskUserQuestion` when the session has a native channel attached. The
5
+ # Claude Code channel protocol carries exactly two interactive methods
6
+ # (permission_request / permission); there is NO carrier for the AskUserQuestion
7
+ # elicitation. On a native-channel (rc-spawn) webchat/WhatsApp/Telegram session
8
+ # the harness blocks the turn awaiting a selection that can never arrive: the
9
+ # reader renders only delivered replies, never an elicitation card, so the turn
10
+ # wedges — the operator sees the last delivered reply and a live-looking but
11
+ # dead composer (see .tasks/1552, live SiteDesk incident session 1a085718
12
+ # 2026-07-11). This hook removes that wedge deterministically by steering the
13
+ # agent to ask the same question as prose via its channel reply tool, which
14
+ # rides the ordinary delivered-reply path that works on every surface.
15
+ #
16
+ # This is a SEPARATE concern from askuserquestion-investigate-gate.sh (which
17
+ # blocks a fabricated menu raised before any investigation). Both are registered
18
+ # as commands on the same PreToolUse AskUserQuestion matcher; either exit 2
19
+ # blocks. This hook guards the "surface can't render this" condition; the other
20
+ # guards the "no evidence yet" condition.
21
+ #
22
+ # Signal: the rc-spawn writes the channel MCP registration as a config file in
23
+ # os.tmpdir() keyed by session id (channel-mcp.ts / webchat-channel-mcp.ts /
24
+ # wa-channel-mcp.ts / telegram-channel-mcp.ts). Its presence at tool-call time
25
+ # is the authoritative "channel attached to this session" marker: a Pi channel
26
+ # process sees the file (block); a claude.ai/code process on another host never
27
+ # sees the Pi tmpfile (fail-open, allow — that surface can answer the card).
28
+ #
29
+ # Contract:
30
+ # Input: PreToolUse stdin JSON envelope from Claude Code
31
+ # Output: exit 0 (allow) or exit 2 (block) per hook protocol
32
+ # Logs: one stderr line per decisive call, format
33
+ # [ask-channel] decision=<allow|block> sessionId=<id8>
34
+ # channel=<attached|none>
35
+ # reason=<channel-attached|no-channel|fail-open-no-envelope|fail-open-no-session>
36
+ #
37
+ # Fail-open philosophy: any missing/blank marker (no channel file, no session
38
+ # id, no envelope) nudges the agent through, never bricks the UI. Block is
39
+ # reserved for the deterministic case "AskUserQuestion fired while a channel
40
+ # config file for this session exists in tmpdir".
41
+
42
+ set -uo pipefail
43
+
44
+ # ----- Read stdin, identify event ------------------------------------------
45
+ if [ -t 0 ]; then
46
+ # No envelope — cannot enforce. Fail open per nudge-not-brick rule.
47
+ echo "[ask-channel] decision=allow sessionId=- channel=none reason=fail-open-no-envelope" >&2
48
+ exit 0
49
+ fi
50
+ INPUT=$(cat)
51
+
52
+ if [ -z "$INPUT" ]; then
53
+ echo "[ask-channel] decision=allow sessionId=- channel=none reason=fail-open-no-envelope" >&2
54
+ exit 0
55
+ fi
56
+
57
+ HOOK_EVENT=$(printf '%s' "$INPUT" | python3 -c '
58
+ import sys, json
59
+ try:
60
+ d = json.load(sys.stdin)
61
+ print(d.get("hook_event_name", "") or "")
62
+ except Exception:
63
+ print("")
64
+ ' 2>/dev/null)
65
+
66
+ TOOL_NAME=$(printf '%s' "$INPUT" | python3 -c '
67
+ import sys, json
68
+ try:
69
+ d = json.load(sys.stdin)
70
+ print(d.get("tool_name", "") or "")
71
+ except Exception:
72
+ print("")
73
+ ' 2>/dev/null)
74
+
75
+ # Only applies to PreToolUse on AskUserQuestion. Anything else: silent exit.
76
+ if [ "$HOOK_EVENT" != "PreToolUse" ] || [ "$TOOL_NAME" != "AskUserQuestion" ]; then
77
+ exit 0
78
+ fi
79
+
80
+ SESSION_ID=$(printf '%s' "$INPUT" | python3 -c '
81
+ import sys, json
82
+ try:
83
+ d = json.load(sys.stdin)
84
+ print(d.get("session_id", "") or "")
85
+ except Exception:
86
+ print("")
87
+ ' 2>/dev/null)
88
+ SESSION_ID8="${SESSION_ID:0:8}"
89
+ if [ -z "$SESSION_ID8" ]; then SESSION_ID8="-"; fi
90
+
91
+ if [ -z "$SESSION_ID" ]; then
92
+ echo "[ask-channel] decision=allow sessionId=- channel=none reason=fail-open-no-session" >&2
93
+ exit 0
94
+ fi
95
+
96
+ # ----- Resolve the tmpdir the writers used ---------------------------------
97
+ # The channel MCP config path is join(os.tmpdir(), ...). Node's os.tmpdir() on
98
+ # POSIX is `process.env.TMPDIR` with a trailing separator stripped, else '/tmp'.
99
+ # Reproduce that exactly so the lookup lands on the same directory the rc-spawn
100
+ # wrote to. `ASK_CHANNEL_TMPDIR` overrides it for tests only — never set in
101
+ # production; the SDK-spawned child inherits the manager's TMPDIR.
102
+ if [ -n "${ASK_CHANNEL_TMPDIR:-}" ]; then
103
+ TMP_DIR="$ASK_CHANNEL_TMPDIR"
104
+ else
105
+ # Mirror Node's os.tmpdir() (libuv uv_os_tmpdir): TMPDIR, then TMP, then TEMP,
106
+ # then /tmp; a trailing slash is stripped. The lookup must land on the exact
107
+ # directory the rc-spawn writer used, so this resolution order matches the
108
+ # writer's byte for byte. On the Pi (systemd user service) and macOS (launchd)
109
+ # only TMPDIR is ever set, but honouring the full order removes a latent
110
+ # false-allow if TMP/TEMP alone are present.
111
+ TMP_DIR="${TMPDIR:-${TMP:-${TEMP:-/tmp}}}"
112
+ TMP_DIR="${TMP_DIR%/}"
113
+ fi
114
+
115
+ # Sanitize the session id the same way the writers do:
116
+ # `sessionId.replace(/[^A-Za-z0-9_-]/g, '_')`.
117
+ SID_SAN=$(printf '%s' "$SESSION_ID" | sed 's/[^A-Za-z0-9_-]/_/g')
118
+
119
+ # ----- Channel attached? ---------------------------------------------------
120
+ # Any of the four channel MCP config filename shapes present in tmpdir for this
121
+ # session means a channel is bound (unified, webchat, whatsapp, or telegram).
122
+ CHANNEL_ATTACHED=0
123
+ for kind in channel webchat-channel wa-channel telegram-channel; do
124
+ if [ -e "$TMP_DIR/maxy-${kind}-${SID_SAN}.json" ]; then
125
+ CHANNEL_ATTACHED=1
126
+ break
127
+ fi
128
+ done
129
+
130
+ if [ "$CHANNEL_ATTACHED" -eq 1 ]; then
131
+ echo "[ask-channel] decision=block sessionId=${SESSION_ID8} channel=attached reason=channel-attached" >&2
132
+ echo "Blocked: this is a native-channel session and AskUserQuestion cannot be delivered over a channel (the channel protocol carries no elicitation card, so the turn would wedge). Ask the same question as prose in your channel reply tool and wait for the operator's next message." >&2
133
+ exit 2
134
+ fi
135
+
136
+ echo "[ask-channel] decision=allow sessionId=${SESSION_ID8} channel=none reason=no-channel" >&2
137
+ exit 0
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what Maxy ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:ceaeee9505453efdd80ee573d7574cb56a8301beca2152b8f70699d3688b2eba
4
+ content-hash: sha256:bd08bc341313562b1dd2139010b1120b23d7aab130e1d32c3e800678ad559f7c
5
5
  brand: maxy-code
6
6
  product-name: Maxy
7
7
  ---
@@ -3566,7 +3566,7 @@ If Ollama is unavailable at write time, the node is stored without an embedding
3566
3566
 
3567
3567
  The `memory-reindex` tool backfills missing embeddings by iterating nodes where `embedding IS NULL`. It uses the bounded representation (the `summary` for document/archive labels, never the raw body), embeds in batches with a per-node fallback on batch error, and **skips and continues** past a failing node — keeping skipped nodes out of the re-fetch via an exclude set — instead of aborting the whole run. `embed()`/`embedBatch()` cap oversized input (`EMBED_INPUT_MAX_CHARS`) before the Ollama POST, so a large body embeds truncated rather than throwing. Per-node `[reindex] op=embedded|skip-error` lines and a terminal `[reindex] op=done processed=N skipped=M`.
3568
3568
 
3569
- `embedBatch()` also paces large batches. A document's summary plus every section body in one `/api/embed` POST pins ollama's `llama-server` across all cores for the whole batch with no idle gaps; on a passively-cooled Pi the SoC climbs past its soft-temp limit and the board thermally hard-resets. `embedBatch` splits an input array longer than the effective sub-batch size into sub-batches, waiting `EMBED_SUBBATCH_COOLDOWN_MS` (default 1000) between them so the SoC gets cooling gaps; vectors are concatenated in input order. The effective sub-batch size is the smaller of two ceilings: the thermal count ceiling `EMBED_SUBBATCH_SIZE` (default 32) and a throughput ceiling `floor(EMBED_REQUEST_TIMEOUT_MS * 0.8 / EMBED_MS_PER_INPUT)`. The throughput ceiling exists because on a CPU-only embedder a fixed 32-input POST can exceed `EMBED_REQUEST_TIMEOUT_MS` and abort the atomic ingest write, so `EMBED_MS_PER_INPUT` (the budgeted worst-case wall-time for one capped input, default 6000 ms, env-tunable) bounds each POST to 80 % of the request timeout. Every `/api/embed` POST also carries `keep_alive` (`EMBED_KEEP_ALIVE`, default `-1` = keep the model resident forever) so an interactive graph query-embed never cold-loads and blows its 5 s timeout. All knobs are env-overridable for on-device tuning. The load-bearing thermal protection is the installer's `CPUQuota` cap on `ollama.service` (every embed caller is bounded there regardless of batch shape); this pacing is defence-in-depth. The UI's file-index path uses its own single-input embed client in `platform/ui/app/lib/neo4j-store.ts` (one embed per file, not a batch), so it is covered by the CPU cap, not by `embedBatch` pacing.
3569
+ `embedBatch()` also paces large batches. A document's summary plus every section body in one `/api/embed` POST pins ollama's `llama-server` across all cores for the whole batch with no idle gaps; on a passively-cooled Pi the SoC climbs past its soft-temp limit and the board thermally hard-resets. `embedBatch` splits a multi-input array into sub-batches, waiting `EMBED_SUBBATCH_COOLDOWN_MS` (default 1000) between them so the SoC gets cooling gaps; vectors are concatenated in input order. Each POST is sized from the device's *measured* throughput, not a fixed constant: the first POST of a multi-input batch is a single-input probe (the only size provably safe without a measurement), and every POST's observed per-character wall-time folds into a running maximum. Subsequent POSTs are packed with as many inputs as fit a character budget `floor(EMBED_REQUEST_TIMEOUT_MS * 0.8 / observedMsPerChar)`, capped by the `EMBED_SUBBATCH_SIZE` count ceiling. Sizing by characters rather than input count matters because an embed's wall-time scales with its characters — a POST sized by count from a short probe (a document summary) would over-provision when the later inputs are near-cap sections and abort. So a CPU-only device slower than the old budget gets character-bounded POSTs that complete inside `EMBED_REQUEST_TIMEOUT_MS`, instead of a fixed 16/32-input POST that exceeds it and aborts the atomic ingest write; the running maximum is monotonic, so a mid-batch slowdown shrinks the remainder and never re-expands into a timeout. `EMBED_SUBBATCH_SIZE` (default 4) is the thermal count ceiling and the hard cap on any POST — kept low because the character budget is derived from the probe's observed rate, so a probe taken while the machine is briefly quiet would otherwise let the next POST grow to the ceiling and then blow `EMBED_REQUEST_TIMEOUT_MS` if desktop contention spikes mid-batch; a ceiling of 4 bounds that worst case (~4 near-cap cold-CPU embeds) under the timeout on a contended shared desktop, while a quiet appliance is governed by the character budget instead. `EMBED_MS_PER_INPUT` (default 6000 ms, env-tunable) no longer sizes the POST; it is the reconciliation baseline — after each POST an `[embed] op=drift` line warns when the observed per-input time exceeds the budget by more than 1.5×, so a device slower than budget is surfaced by a standing check rather than by a later ingest failure. Every `/api/embed` POST also carries `keep_alive` (`EMBED_KEEP_ALIVE`, default `-1` = keep the model resident forever) so an interactive graph query-embed never cold-loads and blows its 5 s timeout. All knobs are env-overridable for on-device tuning. The load-bearing thermal protection is the installer's `CPUQuota` cap on `ollama.service` (every embed caller is bounded there regardless of batch shape); this pacing is defence-in-depth. The UI's file-index path uses its own single-input embed client in `platform/ui/app/lib/neo4j-store.ts` (one embed per file, not a batch), so it is covered by the CPU cap, not by `embedBatch` pacing.
3570
3570
 
3571
3571
  ---
3572
3572
 
@@ -95,7 +95,7 @@ If Ollama is unavailable at write time, the node is stored without an embedding
95
95
 
96
96
  The `memory-reindex` tool backfills missing embeddings by iterating nodes where `embedding IS NULL`. It uses the bounded representation (the `summary` for document/archive labels, never the raw body), embeds in batches with a per-node fallback on batch error, and **skips and continues** past a failing node — keeping skipped nodes out of the re-fetch via an exclude set — instead of aborting the whole run. `embed()`/`embedBatch()` cap oversized input (`EMBED_INPUT_MAX_CHARS`) before the Ollama POST, so a large body embeds truncated rather than throwing. Per-node `[reindex] op=embedded|skip-error` lines and a terminal `[reindex] op=done processed=N skipped=M`.
97
97
 
98
- `embedBatch()` also paces large batches. A document's summary plus every section body in one `/api/embed` POST pins ollama's `llama-server` across all cores for the whole batch with no idle gaps; on a passively-cooled Pi the SoC climbs past its soft-temp limit and the board thermally hard-resets. `embedBatch` splits an input array longer than the effective sub-batch size into sub-batches, waiting `EMBED_SUBBATCH_COOLDOWN_MS` (default 1000) between them so the SoC gets cooling gaps; vectors are concatenated in input order. The effective sub-batch size is the smaller of two ceilings: the thermal count ceiling `EMBED_SUBBATCH_SIZE` (default 32) and a throughput ceiling `floor(EMBED_REQUEST_TIMEOUT_MS * 0.8 / EMBED_MS_PER_INPUT)`. The throughput ceiling exists because on a CPU-only embedder a fixed 32-input POST can exceed `EMBED_REQUEST_TIMEOUT_MS` and abort the atomic ingest write, so `EMBED_MS_PER_INPUT` (the budgeted worst-case wall-time for one capped input, default 6000 ms, env-tunable) bounds each POST to 80 % of the request timeout. Every `/api/embed` POST also carries `keep_alive` (`EMBED_KEEP_ALIVE`, default `-1` = keep the model resident forever) so an interactive graph query-embed never cold-loads and blows its 5 s timeout. All knobs are env-overridable for on-device tuning. The load-bearing thermal protection is the installer's `CPUQuota` cap on `ollama.service` (every embed caller is bounded there regardless of batch shape); this pacing is defence-in-depth. The UI's file-index path uses its own single-input embed client in `platform/ui/app/lib/neo4j-store.ts` (one embed per file, not a batch), so it is covered by the CPU cap, not by `embedBatch` pacing.
98
+ `embedBatch()` also paces large batches. A document's summary plus every section body in one `/api/embed` POST pins ollama's `llama-server` across all cores for the whole batch with no idle gaps; on a passively-cooled Pi the SoC climbs past its soft-temp limit and the board thermally hard-resets. `embedBatch` splits a multi-input array into sub-batches, waiting `EMBED_SUBBATCH_COOLDOWN_MS` (default 1000) between them so the SoC gets cooling gaps; vectors are concatenated in input order. Each POST is sized from the device's *measured* throughput, not a fixed constant: the first POST of a multi-input batch is a single-input probe (the only size provably safe without a measurement), and every POST's observed per-character wall-time folds into a running maximum. Subsequent POSTs are packed with as many inputs as fit a character budget `floor(EMBED_REQUEST_TIMEOUT_MS * 0.8 / observedMsPerChar)`, capped by the `EMBED_SUBBATCH_SIZE` count ceiling. Sizing by characters rather than input count matters because an embed's wall-time scales with its characters — a POST sized by count from a short probe (a document summary) would over-provision when the later inputs are near-cap sections and abort. So a CPU-only device slower than the old budget gets character-bounded POSTs that complete inside `EMBED_REQUEST_TIMEOUT_MS`, instead of a fixed 16/32-input POST that exceeds it and aborts the atomic ingest write; the running maximum is monotonic, so a mid-batch slowdown shrinks the remainder and never re-expands into a timeout. `EMBED_SUBBATCH_SIZE` (default 4) is the thermal count ceiling and the hard cap on any POST — kept low because the character budget is derived from the probe's observed rate, so a probe taken while the machine is briefly quiet would otherwise let the next POST grow to the ceiling and then blow `EMBED_REQUEST_TIMEOUT_MS` if desktop contention spikes mid-batch; a ceiling of 4 bounds that worst case (~4 near-cap cold-CPU embeds) under the timeout on a contended shared desktop, while a quiet appliance is governed by the character budget instead. `EMBED_MS_PER_INPUT` (default 6000 ms, env-tunable) no longer sizes the POST; it is the reconciliation baseline — after each POST an `[embed] op=drift` line warns when the observed per-input time exceeds the budget by more than 1.5×, so a device slower than budget is surfaced by a standing check rather than by a later ingest failure. Every `/api/embed` POST also carries `keep_alive` (`EMBED_KEEP_ALIVE`, default `-1` = keep the model resident forever) so an interactive graph query-embed never cold-loads and blows its 5 s timeout. All knobs are env-overridable for on-device tuning. The load-bearing thermal protection is the installer's `CPUQuota` cap on `ollama.service` (every embed caller is bounded there regardless of batch shape); this pacing is defence-in-depth. The UI's file-index path uses its own single-input embed client in `platform/ui/app/lib/neo4j-store.ts` (one embed per file, not a batch), so it is covered by the CPU cap, not by `embedBatch` pacing.
99
99
 
100
100
  ---
101
101
 
@@ -222,6 +222,8 @@ Read-only window into the extractive graph writes produced under this account. R
222
222
 
223
223
  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.
224
224
 
225
+ When a house operator operates a client's sub-account, the session is scoped into that account (`ACCOUNT_ID` is the sub-account, `USER_ID` is the operator, no `HOUSE_ADMIN_SCOPE`). In that session `profile-read`/`profile-update`/`profile-delete` resolve to the account's **owner** `AdminUser`, not the operator: the operator has no `AdminUser` node in the sub-account subgraph, so `resolveProfileIdentity` sees the caller is not an admin of the account it is scoped to and attributes to the seeded owner — the same owner identity the cross-account `targetAccountId` path resolves (above), reached without `targetAccountId` because the re-scoped session cannot set it. An account with no seeded owner rejects the write loudly (`… has no owner AdminUser …`) rather than attributing it to the operator, and emits `[xacct] op=identity tool=profile-* mode=operator-on-behalf target=<sub8> caller=<op8> resolved-userId=<owner8>` on the owner-attributed path. The operator's own preferences live on their own (house) session.
226
+
225
227
  ## UserProfile + Person Field Management
226
228
 
227
229
  `profile-update` serves three purposes: managing Preference nodes (category/key/value), setting top-level UserProfile node properties via `profileFields`, and setting Person properties on the operator's personal-profile Person via `personFields`.
@@ -12,6 +12,7 @@ import { buildLiveSchemaSource, defaultSchemaCypherPath, } from "./lib/live-sche
12
12
  import { memoryReindex } from "./tools/memory-reindex.js";
13
13
  import { memoryIngestExtract } from "./tools/memory-ingest-extract.js";
14
14
  import { memoryIngest } from "./tools/memory-ingest.js";
15
+ import { makeEmbedProgressReporter } from "./lib/embed-progress-reporter.js";
15
16
  import { CONVERSATION_SOURCES } from "./lib/conversation-normalisers/types.js";
16
17
  import { kdClassify } from "./tools/kd-classify.js";
17
18
  import { memoryArchiveWrite } from "./tools/memory-archive-write.js";
@@ -42,6 +43,8 @@ import { memoryBrainCaptureRecent } from "./tools/memory-brain-capture-recent.js
42
43
  import { profileUpdate } from "./tools/profile-update.js";
43
44
  import { profileDelete } from "./tools/profile-delete.js";
44
45
  import { resolveOwnerUserId } from "./lib/resolve-owner-userid.js";
46
+ import { resolveAdminHomeAccount } from "./lib/resolve-admin-home-account.js";
47
+ import { classifyProfileIdentity } from "./lib/profile-identity.js";
45
48
  import { graphPruneDenylistAdd } from "./tools/graph-prune-denylist-add.js";
46
49
  import { graphPruneDenylistList } from "./tools/graph-prune-denylist-list.js";
47
50
  import { graphPruneDenylistRemove } from "./tools/graph-prune-denylist-remove.js";
@@ -113,31 +116,42 @@ const XACCT_DESC = "House-scoped admin only: operate on the named sub-account su
113
116
  // call (the resolver only returns a target for a house-authorised session).
114
117
  const BOOT_ACCOUNT_ID = accountId;
115
118
  async function resolveProfileIdentity(toolName, crossAccount, resolvedAccountId, callerUserId) {
116
- if (crossAccount) {
117
- const owner = await resolveOwnerUserId(resolvedAccountId);
118
- if (!owner) {
119
- return {
120
- reject: {
121
- content: [{
122
- type: "text",
123
- text: `${toolName} failed: target account ${resolvedAccountId.slice(0, 8)}… has no owner AdminUser to attribute the profile to — its graph root is unprovisioned (see Task 1359 seedAccountGraphRoot).`,
124
- }],
125
- isError: true,
126
- },
127
- };
128
- }
129
- process.stderr.write(`[xacct] op=identity tool=${toolName} target=${resolvedAccountId.slice(0, 8)} resolved-userId=${owner.slice(0, 8)}\n`);
130
- return { userId: owner };
131
- }
132
- if (!callerUserId) {
119
+ // Resolve the facts the pure classifier needs. On the own-account path the
120
+ // account's owner is resolved only when the caller is NOT operating their
121
+ // home account (a foreign house operator inside a client's sub-account), so
122
+ // the common owner-is-speaker path costs one AdminUser lookup, not two.
123
+ const callerHomeAccountId = !crossAccount && callerUserId ? await resolveAdminHomeAccount(callerUserId) : null;
124
+ const needOwner = crossAccount || (!!callerUserId && callerHomeAccountId !== resolvedAccountId);
125
+ const ownerUserId = needOwner ? await resolveOwnerUserId(resolvedAccountId) : null;
126
+ const decision = classifyProfileIdentity({
127
+ crossAccount,
128
+ resolvedAccountId,
129
+ callerUserId,
130
+ callerHomeAccountId,
131
+ ownerUserId,
132
+ });
133
+ if (decision.kind === "reject") {
134
+ const text = decision.reason === "no-owner"
135
+ ? `${toolName} failed: account ${resolvedAccountId.slice(0, 8)}… has no owner AdminUser to attribute the profile to — its graph root is unprovisioned (see Task 1359 seedAccountGraphRoot).`
136
+ : `${toolName} requires an authenticated admin session with userId`;
133
137
  return {
134
138
  reject: {
135
- content: [{ type: "text", text: `${toolName} requires an authenticated admin session with userId` }],
139
+ content: [{ type: "text", text }],
136
140
  isError: true,
137
141
  },
138
142
  };
139
143
  }
140
- return { userId: callerUserId };
144
+ if (decision.kind === "owner") {
145
+ if (crossAccount) {
146
+ process.stderr.write(`[xacct] op=identity tool=${toolName} target=${resolvedAccountId.slice(0, 8)} resolved-userId=${decision.userId.slice(0, 8)}\n`);
147
+ }
148
+ else {
149
+ process.stderr.write(`[xacct] op=identity tool=${toolName} mode=operator-on-behalf target=${resolvedAccountId.slice(0, 8)} caller=${(callerUserId ?? "").slice(0, 8)} resolved-userId=${decision.userId.slice(0, 8)}\n`);
150
+ }
151
+ return { userId: decision.userId, bootstrap: true };
152
+ }
153
+ // decision.kind === "caller" — own-account owner-is-speaker / second admin.
154
+ return { userId: decision.userId, bootstrap: false };
141
155
  }
142
156
  // Load the markdown schema sidecar once at startup. Every memory-write call
143
157
  // reads its required-property and synonym maps. If loading fails we throw —
@@ -1336,11 +1350,16 @@ if (!readOnly) {
1336
1350
  forceRewrite: z.boolean().optional().describe("When inlineRewrite is true, bypass the 7-day per-entity cooldown on compiledTruthUpdatedAt. All threshold-qualifying entities are returned as inlineRewriteCandidates regardless of when their truth was last rewritten."),
1337
1351
  inlineRewriteThreshold: z.number().int().min(1).optional().describe("Minimum MENTIONS/REFERENCES edge count from this source to an entity for it to qualify as an inline-rewrite candidate. Default: 2."),
1338
1352
  targetAccountId: z.string().optional().describe(XACCT_DESC),
1339
- }, async ({ attachmentId, conversationIdentity, source, archiveSha256, archiveSourceFile, participantElementIds, lastIngestedMessageHash, lastIngestedMessageAt, archiveTitle, cleanupPriorChunks, sessionIndex, documentSummary, anchorNodeId, anchorLabel, sections, documentEdges, orphanCandidates, scope, sourceUrl, sourceType, documentKeywords, userKeywords, sessionId: sessionIdOverride, inlineRewrite, forceRewrite, inlineRewriteThreshold, targetAccountId }) => {
1353
+ }, async ({ attachmentId, conversationIdentity, source, archiveSha256, archiveSourceFile, participantElementIds, lastIngestedMessageHash, lastIngestedMessageAt, archiveTitle, cleanupPriorChunks, sessionIndex, documentSummary, anchorNodeId, anchorLabel, sections, documentEdges, orphanCandidates, scope, sourceUrl, sourceType, documentKeywords, userKeywords, sessionId: sessionIdOverride, inlineRewrite, forceRewrite, inlineRewriteThreshold, targetAccountId }, extra) => {
1340
1354
  const scoped = resolveToolAccount("memory-ingest", targetAccountId);
1341
1355
  if ("reject" in scoped)
1342
1356
  return scoped.reject;
1343
1357
  const accountId = scoped.accountId;
1358
+ // Task 1555 — stream a progress notification per embed sub-batch POST so
1359
+ // a long embedding phase resets Claude Code's stdio idle timer and the
1360
+ // ingest commits instead of aborting mid-write. No-ops when the client
1361
+ // sent no progressToken.
1362
+ const reportProgress = makeEmbedProgressReporter(extra);
1344
1363
  try {
1345
1364
  const result = await memoryIngest({
1346
1365
  accountId,
@@ -1370,6 +1389,7 @@ if (!readOnly) {
1370
1389
  inlineRewrite,
1371
1390
  forceRewrite,
1372
1391
  inlineRewriteThreshold,
1392
+ reportProgress,
1373
1393
  });
1374
1394
  return {
1375
1395
  content: [{
@@ -2228,8 +2248,8 @@ if (!readOnly) {
2228
2248
  profileFields: profileFields,
2229
2249
  personFields,
2230
2250
  mergeSourceIds,
2231
- bootstrapProfile: crossAccount,
2232
- bootstrapPerson: crossAccount,
2251
+ bootstrapProfile: ident.bootstrap,
2252
+ bootstrapPerson: ident.bootstrap,
2233
2253
  });
2234
2254
  return {
2235
2255
  content: [{