@animalabs/connectome-host 0.3.1

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 (123) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/publish.yml +65 -0
  3. package/ARCHITECTURE.md +355 -0
  4. package/CHANGELOG.md +30 -0
  5. package/HEADLESS-FLEET-PLAN.md +330 -0
  6. package/README.md +189 -0
  7. package/UNIFIED-TREE-PLAN.md +242 -0
  8. package/bun.lock +288 -0
  9. package/docs/AGENT-MEMORY-GUIDE.md +214 -0
  10. package/docs/AGENT-ONBOARDING.md +541 -0
  11. package/docs/ATTENTION-AND-GATING.md +120 -0
  12. package/docs/DEPLOYMENTS.md +130 -0
  13. package/docs/DEV-ENVIRONMENT.md +215 -0
  14. package/docs/LIBRARY-PIPELINE.md +286 -0
  15. package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
  16. package/docs/claudeai-evacuation.md +228 -0
  17. package/docs/debug-context-api.md +208 -0
  18. package/docs/webui-deployment.md +219 -0
  19. package/package.json +33 -0
  20. package/recipes/SETUP.md +308 -0
  21. package/recipes/TRIUMVIRATE-SETUP.md +467 -0
  22. package/recipes/claude-export-revive.json +19 -0
  23. package/recipes/clerk.json +157 -0
  24. package/recipes/knowledge-miner.json +174 -0
  25. package/recipes/knowledge-reviewer.json +76 -0
  26. package/recipes/mcpl-editor-test.json +38 -0
  27. package/recipes/prompts/transplant-addendum.md +17 -0
  28. package/recipes/triumvirate.json +63 -0
  29. package/recipes/webui-fleet-test.json +27 -0
  30. package/recipes/webui-test.json +16 -0
  31. package/recipes/zulip-miner.json +87 -0
  32. package/scripts/connectome-doctor +373 -0
  33. package/scripts/evacuator.ts +956 -0
  34. package/scripts/import-claudeai-export.ts +747 -0
  35. package/scripts/lib/line-reader.ts +53 -0
  36. package/scripts/test-historical-thinking.ts +148 -0
  37. package/scripts/warmup-session.ts +336 -0
  38. package/src/agent-name.ts +58 -0
  39. package/src/commands.ts +1074 -0
  40. package/src/headless.ts +528 -0
  41. package/src/index.ts +867 -0
  42. package/src/logging-adapter.ts +208 -0
  43. package/src/mcpl-config.ts +199 -0
  44. package/src/modules/activity-module.ts +270 -0
  45. package/src/modules/channel-mode-module.ts +260 -0
  46. package/src/modules/fleet-module.ts +1705 -0
  47. package/src/modules/fleet-types.ts +225 -0
  48. package/src/modules/lessons-module.ts +465 -0
  49. package/src/modules/mcpl-admin-module.ts +345 -0
  50. package/src/modules/retrieval-module.ts +327 -0
  51. package/src/modules/settings-module.ts +143 -0
  52. package/src/modules/subagent-module.ts +2074 -0
  53. package/src/modules/subscription-gc-module.ts +322 -0
  54. package/src/modules/time-module.ts +85 -0
  55. package/src/modules/tui-module.ts +76 -0
  56. package/src/modules/web-ui-curve-page.ts +249 -0
  57. package/src/modules/web-ui-module.ts +2595 -0
  58. package/src/recipe.ts +1003 -0
  59. package/src/session-manager.ts +278 -0
  60. package/src/state/agent-tree-reducer.ts +431 -0
  61. package/src/state/fleet-tree-aggregator.ts +195 -0
  62. package/src/strategies/frontdesk-strategy.ts +364 -0
  63. package/src/synesthete.ts +68 -0
  64. package/src/tui.ts +2200 -0
  65. package/src/types/bun-ffi.d.ts +6 -0
  66. package/src/web/protocol.ts +648 -0
  67. package/test/agent-name.test.ts +62 -0
  68. package/test/agent-tree-fleet-launch.test.ts +64 -0
  69. package/test/agent-tree-reducer-parity.test.ts +194 -0
  70. package/test/agent-tree-reducer.test.ts +443 -0
  71. package/test/agent-tree-rollup.test.ts +109 -0
  72. package/test/autobio-progress-snapshot.test.ts +40 -0
  73. package/test/claudeai-export-importer.test.ts +386 -0
  74. package/test/evacuator.test.ts +332 -0
  75. package/test/fleet-commands.test.ts +244 -0
  76. package/test/fleet-no-subfleets.test.ts +147 -0
  77. package/test/fleet-orchestration.test.ts +353 -0
  78. package/test/fleet-recipe.test.ts +124 -0
  79. package/test/fleet-route.test.ts +44 -0
  80. package/test/fleet-smoke.test.ts +479 -0
  81. package/test/fleet-subscribe-union-e2e.test.ts +123 -0
  82. package/test/fleet-subscribe-union.test.ts +85 -0
  83. package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
  84. package/test/fleet-tree-aggregator.test.ts +215 -0
  85. package/test/frontdesk-strategy.test.ts +326 -0
  86. package/test/headless-describe.test.ts +182 -0
  87. package/test/headless-smoke.test.ts +190 -0
  88. package/test/logging-adapter.test.ts +87 -0
  89. package/test/mcpl-admin-module.test.ts +213 -0
  90. package/test/mcpl-agent-overlay.test.ts +126 -0
  91. package/test/mock-headless-child.ts +133 -0
  92. package/test/recipe-cache-ttl.test.ts +37 -0
  93. package/test/recipe-env.test.ts +157 -0
  94. package/test/recipe-path-resolution.test.ts +170 -0
  95. package/test/subagent-async-timeout.test.ts +381 -0
  96. package/test/subagent-fork-materialise.test.ts +241 -0
  97. package/test/subagent-peek-scoping.test.ts +180 -0
  98. package/test/subagent-peek-zombie.test.ts +148 -0
  99. package/test/subagent-reaper-in-flight.test.ts +229 -0
  100. package/test/subscription-gc-module.test.ts +136 -0
  101. package/test/warmup-handoff.test.ts +150 -0
  102. package/test/web-ui-bind.test.ts +51 -0
  103. package/test/web-ui-module.test.ts +246 -0
  104. package/test/web-ui-protocol.test.ts +0 -0
  105. package/tsconfig.json +14 -0
  106. package/web/bun.lock +357 -0
  107. package/web/index.html +12 -0
  108. package/web/package.json +24 -0
  109. package/web/src/App.tsx +1931 -0
  110. package/web/src/Context.tsx +158 -0
  111. package/web/src/ContextDocument.tsx +150 -0
  112. package/web/src/Files.tsx +271 -0
  113. package/web/src/Lessons.tsx +164 -0
  114. package/web/src/Mcpl.tsx +310 -0
  115. package/web/src/Stream.tsx +119 -0
  116. package/web/src/TreeSidebar.tsx +283 -0
  117. package/web/src/Usage.tsx +182 -0
  118. package/web/src/main.tsx +7 -0
  119. package/web/src/styles.css +26 -0
  120. package/web/src/tree.ts +268 -0
  121. package/web/src/wire.ts +120 -0
  122. package/web/tsconfig.json +21 -0
  123. package/web/vite.config.ts +32 -0
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "Zulip Knowledge Miner",
3
+ "description": "Extract structured knowledge from Zulip workspaces using forking agents",
4
+ "version": "1.0.0",
5
+ "agent": {
6
+ "name": "researcher",
7
+ "systemPrompt": "You are a knowledge extraction researcher. Your job is to analyze conversations from Zulip — a team communication platform — and extract structured knowledge.\n\n## How You Work\n\nYou have access to Zulip streams (channels) and topics (threads). You can:\n1. **Browse**: List streams, topics, and read message history\n2. **Analyze**: Read conversations and identify key information — decisions, patterns, people, processes\n3. **Extract**: Create persistent lessons capturing the knowledge you find\n4. **Delegate**: Fork subagents for parallel analysis of different streams/topics\n\n## Tools\n\n### Zulip Access\nUse the Zulip MCP tools to read data. Start by listing streams to see what's available, then drill into topics and messages.\n\n### Subagents\nYou can fork subagents to analyze multiple topics in parallel:\n- `subagent--fork` and `subagent--spawn` are **async by default** — they return immediately and results arrive as messages\n- Call multiple forks/spawns in one turn to run them concurrently; you can continue working while they run\n- When a subagent completes, its results appear as a \"[Subagent 'X' returned]\" message and you'll be prompted to process them\n- Pass `sync: true` to block until completion (useful when you need the result immediately)\n- Use `subagent--spawn` for tasks that need a completely blank slate\n- Use `subagent--hud enabled:true` to see a fleet status summary before each inference\n\n### Wake Subscriptions\nUse `wake--subscribe` to selectively trigger inference on incoming MCPL events:\n- With no subscriptions, all events trigger inference (default behavior)\n- With subscriptions, only matching events wake you up\n- Filter by text match or regex, scope to specific event types\n- Use `once` type for one-shot subscriptions that auto-remove after matching\n\n### Lessons\nUse `lessons--create` to persist extracted knowledge. Each lesson should be:\n- **Specific**: One clear piece of knowledge per lesson\n- **Tagged**: Use tags for categorization (people, process, decision, technical, etc.)\n- **Evidenced**: Include source references (stream:topic:messageId) when possible\n\n### Workspace (Files & Products)\nUse the `workspace--` tools to read local files and write reports, summaries, and other products:\n- `workspace--read` to read a file (prefix path with mount name, e.g., `input/data.csv` or `products/report.md`)\n- `workspace--write` to create or overwrite a file (e.g., `products/reports/team-overview.md`)\n- `workspace--edit` to make targeted edits to an existing file\n- `workspace--ls` to list directory contents (e.g., `input/` or `products/`)\n- `workspace--glob` to find files by pattern\n- `workspace--grep` to search file contents\n- `workspace--materialize` to write workspace files to disk\n\nMounts available:\n- `input/` — read-only access to input files\n- `products/` — read-write workspace for reports and outputs (materializes to `./output`)\n\nWrite products when you have substantial findings worth preserving as a document — analysis reports, team profiles, process maps, decision logs, etc.\n\n## Parallelization Strategy\n\nWork in iterative scout-then-dive waves:\n\n### Wave 1: Scout\nBrowse the available streams and topics. Identify which ones are relevant to the user's request. Do this yourself — it's fast and gives you the lay of the land.\n\n### Wave 2: Dive\nFork subagents into the most promising leads. Each fork gets a specific, bounded task:\n- \"Read stream X, topics Y and Z. Extract key decisions and people involved.\"\n- \"Analyze the last 200 messages in stream X. Identify recurring patterns.\"\n\nFork 2-3 subagents at a time. They run in the background — you can continue scouting or processing while they work.\n\n### Wave 3: Integrate & Pursue\nWhen forks return (as messages), synthesize their findings. Identify new leads that emerged — cross-references to other streams, people to track, decisions that need more context. Then fork again into these new leads.\n\n### Rules\n- **You are the coordinator.** You read fork results, synthesize, decide what to investigate next, create lessons, and write products. Forks are your eyes, not your brain.\n- **Forks can sub-fork when useful.** A fork that discovers multiple independent leads can call `subagent--fork` itself to parallelize them, rather than returning and waiting for you to re-dispatch. Use sub-forking when a fork finds several items to investigate in parallel. Prefer returning findings to the parent when the fork's task is bounded and doesn't branch further.\n- **Keep forks focused.** Each fork should have a clear, bounded task. \"Analyze everything\" is too broad. \"Read the last 100 messages in #router-dev and summarize the architecture\" is good.\n- **Forks are cheap, context is not.** Prefer multiple small forks over one giant fork. A fork that reads 50K tokens of chat history and returns a 500-word summary is ideal.\n\nBe thorough but concise. Focus on knowledge that would be useful for someone trying to understand the team, its processes, and its decisions.\n\n## Writing Quality: Confidence Markers\n\nWhen writing documents (reports, summaries, architecture docs) to the workspace, tag every non-trivial claim with a confidence marker:\n\n| Marker | Meaning |\n|--------|---------|\n| `[SRC: source]` | Directly quoted or closely paraphrased from a specific source. Cite inline: `[SRC: Zulip: stream > topic]` |\n| `[INF]` | Inferred — synthesized from multiple sources, or logically derived. You connected the dots. |\n| `[GEN]` | General domain knowledge — not found in any source; added because this is how such systems typically work. |\n| `❓` | Knowledge gap — not found anywhere; requires a human expert to fill. |\n\n### Rules for markers\n- **Do not fill gaps with plausible domain logic.** If something is not in the sources, use `❓` and note what needs SME input. Do not invent.\n- **When in doubt, mark it.** An unmarked claim that turns out to be wrong is worse than a `[GEN]` tag that gets verified.\n- **Cite inline, not in footnotes.** A brief tag on the same line is enough.\n- **All written documents are Draft.** A human must review and approve before it becomes Active.\n- **Lessons don't need markers.** Lessons have their own confidence scores and evidence arrays. Markers are for prose documents only.",
8
+ "strategy": {
9
+ "type": "autobiographical",
10
+ "headWindowTokens": 4000,
11
+ "recentWindowTokens": 30000,
12
+ "maxMessageTokens": 10000
13
+ }
14
+ },
15
+ "mcpServers": {
16
+ "zulip": {
17
+ "command": "node",
18
+ "args": [
19
+ "../zulip-mcp/build/index.js"
20
+ ],
21
+ "env": {
22
+ "ENABLE_ZULIP": "true",
23
+ "ENABLE_DISCORD": "false"
24
+ },
25
+ "channelSubscription": "manual",
26
+ "source": {
27
+ "url": "https://github.com/antra-tess/zulip_mcp.git",
28
+ "install": "npm",
29
+ "inContainer": {
30
+ "path": "/zulip-mcp"
31
+ }
32
+ }
33
+ }
34
+ },
35
+ "modules": {
36
+ "subagents": true,
37
+ "lessons": true,
38
+ "retrieval": true,
39
+ "wake": {
40
+ "policies": [
41
+ {
42
+ "name": "user-input",
43
+ "match": {
44
+ "scope": [
45
+ "external-message"
46
+ ]
47
+ },
48
+ "behavior": "always"
49
+ },
50
+ {
51
+ "name": "subagent-completions",
52
+ "match": {
53
+ "scope": [
54
+ "inference-request"
55
+ ]
56
+ },
57
+ "behavior": "always"
58
+ }
59
+ ],
60
+ "default": "skip"
61
+ },
62
+ "workspace": {
63
+ "configMount": true,
64
+ "mounts": [
65
+ {
66
+ "name": "input",
67
+ "path": "./input",
68
+ "mode": "read-only"
69
+ },
70
+ {
71
+ "name": "products",
72
+ "path": "./output",
73
+ "mode": "read-write"
74
+ }
75
+ ]
76
+ }
77
+ },
78
+ "sessionNaming": {
79
+ "examples": [
80
+ "Zulip Thread Archaeology",
81
+ "Lobby Protocol Dissection",
82
+ "Context Window Budgeting",
83
+ "Agent Memory Architecture",
84
+ "Discord Bridge Debugging"
85
+ ]
86
+ }
87
+ }
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env python3
2
+ """connectome-doctor — one-shot "why isn't the agent responding?" diagnosis.
3
+
4
+ Usage: connectome-doctor [INSTALL_DIR] [UNIT]
5
+ connectome-doctor ~/mythos-cm mythos-agent.service
6
+
7
+ Prints a pipeline ladder: the last-seen timestamp at every stage an inbound
8
+ event passes through. The first stage whose timestamp lags its upstream is
9
+ where events are dying.
10
+
11
+ discord event -> mcpl forward -> chronicle ingest (gate verdict)
12
+ -> inference (llm-call) -> routeSpeech delivery
13
+
14
+ Also checks: service status/restarts, disk, journal staleness (and where the
15
+ REAL logs are), recent inference failures, and fires the SIGUSR2 live gate
16
+ dump if the framework supports it.
17
+ """
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import subprocess
23
+ import sys
24
+ import time
25
+ from datetime import datetime, timezone
26
+
27
+ C_RED = "\033[31m"
28
+ C_YEL = "\033[33m"
29
+ C_GRN = "\033[32m"
30
+ C_DIM = "\033[2m"
31
+ C_OFF = "\033[0m"
32
+ if not sys.stdout.isatty():
33
+ C_RED = C_YEL = C_GRN = C_DIM = C_OFF = ""
34
+
35
+ NOW = time.time()
36
+ WARNINGS = []
37
+
38
+
39
+ def sh(cmd, timeout=15):
40
+ try:
41
+ r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
42
+ return r.stdout.strip()
43
+ except Exception:
44
+ return ""
45
+
46
+
47
+ def age(ts):
48
+ """Humanize seconds-since-epoch -> '3m ago' / 'never'."""
49
+ if ts is None:
50
+ return "never"
51
+ d = NOW - ts
52
+ if d < 0:
53
+ return "future?"
54
+ if d < 90:
55
+ return f"{d:.0f}s ago"
56
+ if d < 5400:
57
+ return f"{d/60:.0f}m ago"
58
+ if d < 172800:
59
+ return f"{d/3600:.1f}h ago"
60
+ return f"{d/86400:.1f}d ago"
61
+
62
+
63
+ def hhmm(ts):
64
+ if ts is None:
65
+ return "--:--:--"
66
+ return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%m-%d %H:%M:%S")
67
+
68
+
69
+ def parse_iso(s):
70
+ try:
71
+ return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
72
+ except Exception:
73
+ return None
74
+
75
+
76
+ def warn(msg):
77
+ WARNINGS.append(msg)
78
+
79
+
80
+ def tail_bytes(path, n):
81
+ try:
82
+ with open(path, "rb") as f:
83
+ f.seek(0, 2)
84
+ size = f.tell()
85
+ f.seek(max(0, size - n))
86
+ return f.read()
87
+ except Exception:
88
+ return b""
89
+
90
+
91
+ def last_matching_line(path, pattern, chunk=2_000_000):
92
+ """Last line matching regex `pattern`, scanning only the file tail."""
93
+ data = tail_bytes(path, chunk).decode("utf-8", "replace")
94
+ matches = [ln for ln in data.splitlines() if re.search(pattern, ln)]
95
+ return matches[-1] if matches else None
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # 0. Arguments / discovery
100
+ # ---------------------------------------------------------------------------
101
+ install_dir = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/mythos-cm")
102
+ unit = sys.argv[2] if len(sys.argv) > 2 else None
103
+ if unit is None:
104
+ # Guess: <name>-agent.service from install dir like ~/mythos-cm
105
+ base = os.path.basename(install_dir.rstrip("/")).replace("-cm", "")
106
+ unit = f"{base}-agent.service"
107
+
108
+ data_dir = os.path.join(install_dir, "data")
109
+ logs_dir = os.path.join(install_dir, "logs")
110
+
111
+ print(f"connectome-doctor — {install_dir} ({unit})")
112
+ print("=" * 72)
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # 1. Service
116
+ # ---------------------------------------------------------------------------
117
+ props = {}
118
+ for line in sh(
119
+ f"systemctl --user show {unit} -p ActiveState -p SubState -p NRestarts "
120
+ f"-p ExecMainStartTimestamp -p MainPID -p StandardOutput -p StandardError"
121
+ ).splitlines():
122
+ if "=" in line:
123
+ k, v = line.split("=", 1)
124
+ props[k] = v
125
+
126
+ active = props.get("ActiveState", "?")
127
+ pid = props.get("MainPID", "0")
128
+ start_ts = None
129
+ if props.get("ExecMainStartTimestamp"):
130
+ t = sh(f"date -d '{props['ExecMainStartTimestamp']}' +%s")
131
+ start_ts = float(t) if t else None
132
+
133
+ col = C_GRN if active == "running" or active == "active" else C_RED
134
+ print(f"service : {col}{active}/{props.get('SubState','?')}{C_OFF} pid={pid} "
135
+ f"started {age(start_ts)} restarts={props.get('NRestarts','?')}")
136
+ if active not in ("active", "running"):
137
+ warn(f"service is {active} — start it: systemctl --user start {unit}")
138
+ if start_ts and NOW - start_ts < 300:
139
+ warn(f"service started only {age(start_ts)} — recent restart/crash?")
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # 2. Disk
143
+ # ---------------------------------------------------------------------------
144
+ df = sh("df -h / | tail -1").split()
145
+ if len(df) >= 5:
146
+ pct = int(df[4].rstrip("%"))
147
+ col = C_GRN if pct < 80 else (C_YEL if pct < 88 else C_RED)
148
+ print(f"disk / : {col}{df[4]} used{C_OFF} ({df[3]} free)")
149
+ if pct >= 88:
150
+ warn(f"disk at {pct}% — agents corrupt stores when writes fail (see 2026-07-02)")
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # 3. Journal staleness + real log location
154
+ # ---------------------------------------------------------------------------
155
+ jlast = sh(f"journalctl --user -u {unit} -q --no-pager -n 1 -o short-unix 2>/dev/null | tail -1")
156
+ jts = None
157
+ m = re.match(r"^(\d+)\.\d+", jlast)
158
+ if m:
159
+ jts = float(m.group(1))
160
+ log_redirect = None
161
+ frag = sh(f"systemctl --user show {unit} -p FragmentPath --value")
162
+ if frag and os.path.exists(frag):
163
+ m = re.search(r"Standard(?:Output|Error)=(?:append|file):(\S+)", open(frag).read())
164
+ if m:
165
+ log_redirect = m.group(1)
166
+
167
+ agent_log = log_redirect or os.path.join(logs_dir, "agent.log")
168
+ print(f"journal : last entry {age(jts)}" + (f" {C_DIM}(stdout/err -> {agent_log}){C_OFF}" if log_redirect else ""))
169
+ if jts is None or (start_ts and jts < start_ts):
170
+ warn(f"journalctl is STALE for this unit (unit logs to a file) — real logs: {agent_log}")
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # 4. Pipeline ladder
174
+ # ---------------------------------------------------------------------------
175
+ stages = [] # (name, ts, detail)
176
+
177
+ # 4a. discord-mcpl: last gateway event seen / forwarded / dropped
178
+ dbg = os.path.join(data_dir, "discord-mcpl-debug.log")
179
+ enter_ts = fwd_ts = None
180
+ enter_detail = fwd_detail = ""
181
+ if os.path.exists(dbg):
182
+ ln = last_matching_line(dbg, r"handleDiscordMessage:enter")
183
+ if ln:
184
+ enter_ts = parse_iso(ln.split(" ", 1)[0])
185
+ mm = re.search(r'"channelName":"?([^",]*)"?.*?"contentPreview":"(.{0,40})', ln)
186
+ if mm:
187
+ enter_detail = f"#{mm.group(1)}: {mm.group(2)}…"
188
+ ln = last_matching_line(dbg, r"handleDiscordMessage:(sent|forwarding)")
189
+ if ln:
190
+ fwd_ts = parse_iso(ln.split(" ", 1)[0])
191
+ fwd_detail = "push/event" if "push/event" in ln else "channels/incoming"
192
+ ln = last_matching_line(dbg, r"handleDiscordMessage:drop")
193
+ if ln:
194
+ dts = parse_iso(ln.split(" ", 1)[0])
195
+ mm = re.search(r'"reason":"([^"]*)"', ln)
196
+ if dts and NOW - dts < 3600 and mm:
197
+ stages_note = f"last drop {age(dts)}: {mm.group(1)}"
198
+ else:
199
+ stages_note = None
200
+ else:
201
+ stages_note = None
202
+ stages.append(("discord event seen", enter_ts, enter_detail))
203
+ stages.append(("mcpl forward to host", fwd_ts, fwd_detail))
204
+
205
+ # 4b. chronicle ingest: last "messages" append in records.log (byte-encoded!)
206
+ rec_ts = None
207
+ rec_detail = ""
208
+ records = None
209
+ sessions_dir = os.path.join(data_dir, "sessions")
210
+ if os.path.isdir(sessions_dir):
211
+ for s in os.listdir(sessions_dir):
212
+ cand = os.path.join(sessions_dir, s, "records.log")
213
+ if os.path.exists(cand):
214
+ records = cand
215
+ if records:
216
+ data = tail_bytes(records, 8_000_000).decode("utf-8", "replace")
217
+ last_msg = None
218
+ for mm in re.finditer(r'\{"record_id":\d+,"global_sequence":\d+,"state_id":"messages".*?"timestamp":(\d+)\}', data):
219
+ last_msg = mm
220
+ if last_msg:
221
+ rec_ts = int(last_msg.group(1)) / 1_000_000 # store stamps in microseconds
222
+ # decode the byte-array payload to recover metadata (triggered flag etc.)
223
+ arr = re.search(r"\[([0-9,]{40,})\]", last_msg.group(0))
224
+ if arr:
225
+ try:
226
+ payload = bytes(int(x) for x in arr.group(1).split(",")).decode("utf-8", "replace")
227
+ trig = '"triggered":true' in payload
228
+ pm = re.search(r'"participant":"([^"]*)"', payload)
229
+ cm = re.search(r'"channelName":"([^"]*)"', payload)
230
+ rec_detail = (f"{'triggered=true' if trig else 'triggered=false (gate skip)'}"
231
+ + (f" #{cm.group(1)}" if cm else "")
232
+ + (f" by {pm.group(1)}" if pm else ""))
233
+ if trig:
234
+ rec_detail = rec_detail # noted below vs inference lag
235
+ globals()["_last_ingest_triggered"] = trig
236
+ except Exception:
237
+ pass
238
+ stages.append(("chronicle ingest (+gate)", rec_ts, rec_detail))
239
+
240
+ # 4c. inference: newest llm-calls file, last record stop_reason
241
+ llm_ts = None
242
+ llm_detail = ""
243
+ llm_files = sorted(
244
+ (f for f in os.listdir(data_dir) if f.startswith("llm-calls.")),
245
+ key=lambda f: os.path.getmtime(os.path.join(data_dir, f)),
246
+ )
247
+ if llm_files:
248
+ newest = os.path.join(data_dir, llm_files[-1])
249
+ llm_ts = os.path.getmtime(newest)
250
+ # last complete JSON line from tail (lines can be multi-MB)
251
+ raw = tail_bytes(newest, 30_000_000).decode("utf-8", "replace")
252
+ for ln in reversed(raw.splitlines()):
253
+ ln = ln.strip()
254
+ if not ln.startswith("{"):
255
+ continue
256
+ try:
257
+ r = json.loads(ln)
258
+ except Exception:
259
+ continue
260
+ resp = r.get("rawResponse") or {}
261
+ resp = resp.get("response") or resp
262
+ sr = resp.get("stop_reason")
263
+ err = resp.get("error") or r.get("error")
264
+ llm_detail = f"stop={sr}" + (f" {C_RED}err={str(err)[:80]}{C_OFF}" if err else "")
265
+ if sr == "refusal":
266
+ warn("last model response was a REFUSAL — check context contamination "
267
+ "(see project_mythos: contextBudgetTokens fix)")
268
+ break
269
+ stages.append(("inference (llm call)", llm_ts, llm_detail))
270
+
271
+ # 4d. delivery: last routeSpeech in agent log
272
+ del_ts = None
273
+ del_detail = ""
274
+ if os.path.exists(agent_log):
275
+ ln = last_matching_line(agent_log, r"\[routeSpeech\]")
276
+ if ln:
277
+ del_ts = os.path.getmtime(agent_log) # agent.log lines lack timestamps
278
+ if "no locus" in ln:
279
+ del_detail = f"{C_RED}NO LOCUS — speech stayed in chronicle{C_OFF}"
280
+ warn("last routeSpeech had no publish locus (push-event wake quirk) — "
281
+ "reply was generated but NOT delivered to Discord")
282
+ else:
283
+ mm = re.search(r"routed (\d+) chars -> (\S+)", ln)
284
+ del_detail = f"routed {mm.group(1)}ch -> {mm.group(2)}" if mm else ln[-60:]
285
+ else:
286
+ del_detail = "none this boot (log rotates at restart)"
287
+ stages.append(("routeSpeech delivery", del_ts, del_detail))
288
+
289
+ print()
290
+ print("pipeline ladder (last seen per stage):")
291
+ prev_ts = None
292
+ for name, ts, detail in stages:
293
+ mark = f"{C_GRN}o{C_OFF}" if ts else f"{C_RED}x{C_OFF}"
294
+ lag = ""
295
+ if prev_ts and ts and prev_ts - ts > 300:
296
+ lag = f" {C_YEL}<-- lags upstream by {age(ts).replace(' ago','')} vs {age(prev_ts).replace(' ago','')}{C_OFF}"
297
+ mark = f"{C_YEL}!{C_OFF}"
298
+ print(f" {mark} {name:<26} {hhmm(ts)} ({age(ts):>9}) {C_DIM}{detail}{C_OFF}{lag}")
299
+ if ts:
300
+ prev_ts = ts
301
+
302
+ # triggered ingest but no inference since -> the tonight signature
303
+ if globals().get("_last_ingest_triggered") and rec_ts and (llm_ts is None or llm_ts < rec_ts - 120):
304
+ warn("a gate-TRIGGERED event was ingested but NO inference followed — "
305
+ "events are dying between the gate and the model (check inference "
306
+ "policy drops / budget failures / [diagnostics] dump below)")
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # 5. Recent failures (agent.log + failures.log)
310
+ # ---------------------------------------------------------------------------
311
+ print()
312
+ fail_lines = []
313
+ for path in (agent_log, os.path.join(logs_dir, "failures.log")):
314
+ if os.path.exists(path):
315
+ data = tail_bytes(path, 400_000).decode("utf-8", "replace")
316
+ fail_lines += [ln for ln in data.splitlines()
317
+ if re.search(r"\[inference-(failed|hard-down|dropped)\]|\[push-event-rejected\]|Error", ln)][-5:]
318
+ if fail_lines:
319
+ print("recent failures:")
320
+ for ln in fail_lines[-5:]:
321
+ print(f" {C_RED}{ln[:150]}{C_OFF}")
322
+ if any("inference-failed" in ln for ln in fail_lines):
323
+ warn("inference failures present — if the reason repeats identically, "
324
+ "the agent is hard-down until config changes (e.g. budget)")
325
+ else:
326
+ print(f"recent failures: {C_GRN}none found{C_OFF} in {agent_log}")
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # 6. Live gate dump (SIGUSR2)
330
+ # ---------------------------------------------------------------------------
331
+ if pid not in ("0", "", None) and active in ("active", "running"):
332
+ before = os.path.getsize(agent_log) if os.path.exists(agent_log) else 0
333
+ sh(f"kill -USR2 {pid} 2>/dev/null")
334
+ time.sleep(0.7)
335
+ dump = None
336
+ if os.path.exists(agent_log):
337
+ with open(agent_log, "rb") as f:
338
+ f.seek(before)
339
+ for ln in f.read().decode("utf-8", "replace").splitlines():
340
+ if "[diagnostics]" in ln:
341
+ dump = ln
342
+ print()
343
+ if dump:
344
+ print(f"live gate dump (SIGUSR2): {dump.split('[diagnostics] ',1)[-1]}")
345
+ try:
346
+ d = json.loads(dump.split("[diagnostics] ", 1)[-1])
347
+ g = d.get("gate") or {}
348
+ if g.get("inferring") and g.get("bufferedEvents", 0) > 0:
349
+ warn("WAKE-WEDGE signature: agent stuck in `inferring` with events buffering")
350
+ if g.get("sleepUntil", 0) > NOW * 1000:
351
+ warn(f"agent is ASLEEP until {hhmm(g['sleepUntil']/1000)}")
352
+ if d.get("activeStreams"):
353
+ # a turn is running right now — the "no inference followed"
354
+ # heuristic is premature; the model is just still thinking.
355
+ WARNINGS[:] = [w for w in WARNINGS if "NO inference followed" not in w]
356
+ print(f" {C_GRN}turn in-flight for: {', '.join(d['activeStreams'])}{C_OFF}")
357
+ except Exception:
358
+ pass
359
+ else:
360
+ print(f"live gate dump: {C_DIM}no [diagnostics] response to SIGUSR2 "
361
+ f"(framework predates the diagnostics patch?){C_OFF}")
362
+
363
+ # ---------------------------------------------------------------------------
364
+ # 7. Verdict
365
+ # ---------------------------------------------------------------------------
366
+ print()
367
+ if WARNINGS:
368
+ print(f"{C_YEL}=== findings ==={C_OFF}")
369
+ for w in WARNINGS:
370
+ print(f" {C_YEL}*{C_OFF} {w}")
371
+ sys.exit(1)
372
+ else:
373
+ print(f"{C_GRN}=== no obvious problems — pipeline looks alive ==={C_OFF}")