mercury-agent 0.18.2 → 0.20.0

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 (107) hide show
  1. package/container/Dockerfile +25 -0
  2. package/container/build.sh +23 -3
  3. package/docs/behavior-layers.md +25 -14
  4. package/docs/configuration.md +139 -11
  5. package/docs/container-lifecycle.md +151 -1
  6. package/docs/context-architecture.md +6 -2
  7. package/docs/extensions.md +9 -1
  8. package/docs/goals/football-reporter-profile/decisions.md +79 -0
  9. package/docs/goals/rehearsal-bench/decisions.md +41 -3
  10. package/docs/goals/rehearsal-bench/roadmap.md +3 -1
  11. package/docs/goals/release-gate/decisions.md +86 -0
  12. package/docs/goals/release-gate/roadmap.md +36 -3
  13. package/docs/live-testing.md +22 -0
  14. package/docs/pending-verification.md +178 -0
  15. package/docs/permissions.md +1 -1
  16. package/docs/profile-guide.md +28 -8
  17. package/examples/extensions/archive/backends/local.ts +6 -3
  18. package/examples/extensions/archive/queue.ts +51 -10
  19. package/examples/extensions/feed-watch/config.ts +42 -2
  20. package/examples/extensions/feed-watch/digest.ts +4 -7
  21. package/examples/extensions/feed-watch/items.ts +18 -7
  22. package/examples/extensions/feed-watch/match.ts +125 -9
  23. package/examples/extensions/feed-watch/skill/SKILL.md +25 -0
  24. package/examples/extensions/feed-watch/watch.ts +39 -5
  25. package/examples/extensions/gws/index.ts +126 -8
  26. package/examples/extensions/longview/hook.ts +72 -7
  27. package/examples/extensions/longview/index.ts +2 -0
  28. package/examples/extensions/morning/README.md +26 -15
  29. package/examples/extensions/morning/index.ts +29 -6
  30. package/examples/extensions/morning/lib/hosts.ts +16 -0
  31. package/examples/extensions/morning/lib/morning.ts +78 -0
  32. package/examples/extensions/morning/lib/upload.ts +584 -0
  33. package/examples/extensions/morning/skill/SKILL.md +34 -4
  34. package/examples/extensions/napkin/index.ts +12 -3
  35. package/examples/extensions/napkin/pi-spawn.ts +5 -1
  36. package/examples/extensions/overview/index.ts +20 -0
  37. package/examples/extensions/overview/skill/SKILL.md +9 -1
  38. package/examples/extensions/pinchtab/index.ts +36 -7
  39. package/examples/extensions/pinchtab/skill/SKILL.md +28 -1
  40. package/examples/profiles/_template/AGENTS.md +6 -1
  41. package/examples/profiles/football-reporter/AGENTS.md +16 -4
  42. package/examples/profiles/football-reporter/README.md +1 -1
  43. package/examples/profiles/football-reporter/config.yaml +42 -6
  44. package/examples/profiles/football-reporter/seed/MEMORY.md +1 -1
  45. package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +1 -1
  46. package/examples/profiles/football-reporter/seed/episodes/maccabi-tel-aviv-2026-27.md +24 -0
  47. package/examples/profiles/football-reporter/seed/napkin-distill.md +30 -29
  48. package/examples/profiles/football-reporter/standard.json +45 -8
  49. package/package.json +11 -6
  50. package/resources/skills/tasks/SKILL.md +17 -1
  51. package/resources/templates/AGENTS.md +4 -3
  52. package/resources/templates/mercury.example.yaml +13 -4
  53. package/src/adapters/whatsapp-ingress.ts +15 -3
  54. package/src/agent/container-entry.ts +299 -29
  55. package/src/agent/container-env.ts +26 -8
  56. package/src/agent/container-runner.ts +497 -76
  57. package/src/agent/image-contract.ts +77 -0
  58. package/src/agent/image-manifest.ts +180 -0
  59. package/src/agent/image-refresh.ts +318 -0
  60. package/src/cli/mercury.ts +225 -28
  61. package/src/cli/mrctl-http.ts +5 -0
  62. package/src/cli/mrctl.ts +57 -5
  63. package/src/cli/service-unit.ts +109 -0
  64. package/src/config-file.ts +22 -1
  65. package/src/config.ts +105 -14
  66. package/src/core/api.ts +11 -2
  67. package/src/core/commands.ts +11 -4
  68. package/src/core/connection-health.ts +377 -0
  69. package/src/core/direct-send.ts +235 -14
  70. package/src/core/exec.ts +10 -0
  71. package/src/core/history-window.ts +142 -0
  72. package/src/core/model-command.ts +130 -0
  73. package/src/core/operator-alerts.ts +326 -23
  74. package/src/core/permissions.ts +28 -0
  75. package/src/core/profiles.ts +32 -16
  76. package/src/core/reply-context.ts +31 -0
  77. package/src/core/routes/config-builtin.ts +11 -0
  78. package/src/core/routes/console.ts +98 -16
  79. package/src/core/routes/dashboard.ts +93 -10
  80. package/src/core/routes/model.ts +26 -3
  81. package/src/core/routes/send.ts +1 -1
  82. package/src/core/routes/tasks.ts +74 -0
  83. package/src/core/runtime.ts +302 -21
  84. package/src/core/system-messages.ts +33 -0
  85. package/src/core/task-scheduler.ts +155 -7
  86. package/src/extensions/image-builder.ts +1 -1
  87. package/src/extensions/installer.ts +72 -17
  88. package/src/extensions/load-project.ts +74 -0
  89. package/src/extensions/loader.ts +50 -9
  90. package/src/host-version.ts +32 -0
  91. package/src/main.ts +42 -21
  92. package/src/preflight/checks/credential.ts +300 -0
  93. package/src/preflight/checks/docker.ts +149 -0
  94. package/src/preflight/checks/extensions.ts +90 -0
  95. package/src/preflight/checks/host-deps.ts +247 -0
  96. package/src/preflight/checks/image-contract.ts +228 -0
  97. package/src/preflight/checks/roundtrip.ts +424 -0
  98. package/src/preflight/checks/sandbox.ts +159 -0
  99. package/src/preflight/deps.ts +107 -0
  100. package/src/preflight/probe-container.ts +156 -0
  101. package/src/preflight/report.ts +177 -0
  102. package/src/preflight/run.ts +223 -0
  103. package/src/server.ts +55 -14
  104. package/src/storage/db.ts +41 -2
  105. package/src/storage/models-json.ts +110 -0
  106. package/src/text/reporter-lint.ts +167 -23
  107. package/src/types.ts +22 -0
@@ -22,7 +22,7 @@
22
22
  * pi happens to be on PATH.
23
23
  */
24
24
 
25
- import { delimiter, join } from "node:path";
25
+ import { posix, win32 } from "node:path";
26
26
  import type { ModelLeg } from "mercury-agent/core/model-leg";
27
27
 
28
28
  // Rule 2's resolver is shared with every other host-side spawn (longview's
@@ -152,6 +152,10 @@ export function composePiSearchPath(opts: {
152
152
  appData?: string;
153
153
  }): string {
154
154
  const { base, piNodeModulesBin, home, isWindows } = opts;
155
+ // isWindows selects the whole path module, not just the literal directory
156
+ // and the dedupe case-folding below — join and delimiter must agree with
157
+ // it, or the composed PATH mixes separators from the wrong platform.
158
+ const { join, delimiter } = isWindows ? win32 : posix;
155
159
  const globalBins = [
156
160
  join(home, ".bun", "bin"),
157
161
  isWindows
@@ -35,6 +35,14 @@ const SKILL_SOURCE = join(EXT_DIR, "skill", "SKILL.md");
35
35
  const managementSpaceId = (): string | undefined =>
36
36
  process.env[MANAGEMENT_SPACE_ENV];
37
37
 
38
+ const MANAGEMENT_SPACE_PROMPT =
39
+ "This is the deployment's management space. Other spaces ARE readable from here " +
40
+ "through the `overview` skill (`mrctl capability overview <action>`): a request about " +
41
+ "another space — its recent messages, where something was said, who is admin, what runs " +
42
+ "tonight — is answered by reading that skill and running its action, never by saying the " +
43
+ 'space is not visible from here. Exception to the "never show ids" rule: a space `id` is ' +
44
+ "the handle every overview action takes — show it beside the name.";
45
+
38
46
  /**
39
47
  * Install the skill into the management space's own workspace.
40
48
  *
@@ -97,6 +105,18 @@ export default function (mercury: MercuryExtensionAPI) {
97
105
  return undefined;
98
106
  });
99
107
 
108
+ // Fact, not prose in AGENTS.md: only this extension knows which space is
109
+ // the management space, so the fragment lives here (layer 3a) and is
110
+ // scoped to that one space by the same check `workspace_init` uses above.
111
+ // Without it, the description alone loses the coin flip against the
112
+ // model's "mrctl is space-scoped" prior on a non-English, unprompted ask.
113
+ mercury.on("before_container", async (event) => {
114
+ const configured = managementSpaceId()?.trim() ?? "";
115
+ return configured !== "" && event.spaceId === configured
116
+ ? { systemPrompt: MANAGEMENT_SPACE_PROMPT }
117
+ : undefined;
118
+ });
119
+
100
120
  mercury.capability(
101
121
  "overview",
102
122
  async (req: CapabilityRequest, ctx: MercuryExtensionContext) => {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: overview
3
- description: Read other spaces from this management space — list spaces, pull recent messages, search across all spaces, inspect scheduled tasks and role assignments. Use when asked what is happening in another space, where something was discussed, who is admin where, or what runs tonight.
3
+ description: Read other spaces from this management space — list spaces, pull recent messages, search across all spaces, inspect scheduled tasks and role assignments. Use when asked, in any wording, what is happening in another space, what was said or sent there recently, where something was discussed, who is admin where, or what runs tonight — e.g. "show me the recent messages from X", "מה קורה בספייס X", "מה נשלח ב-X לאחרונה", "תראה לי את ההודעות האחרונות מ-X", "איפה דיברו על …", "מי אדמין ב-X". Not for this space's own history (that is mrctl recall).
4
4
  ---
5
5
 
6
6
  # Cross-space overview
@@ -17,6 +17,14 @@ The capability may be inert — it answers `403` when this deployment has not
17
17
  designated a management space, or when the caller is not a global admin. That is
18
18
  configuration, not a bug: report the error and stop.
19
19
 
20
+ ## Before answering
21
+
22
+ A question that names another space is answered by running the action below,
23
+ not by saying the space is not reachable; if you have not run `list` in this
24
+ conversation, run it first. **Space ids are shown.** The platform's
25
+ never-show-ids rule does not apply to a space `id` here; put it beside each
26
+ name.
27
+
20
28
  ## Usage
21
29
 
22
30
  ```bash
@@ -21,6 +21,7 @@ export default function (mercury: {
21
21
  local bind="\${BRIDGE_BIND:-127.0.0.1}"
22
22
  local port="\${BRIDGE_PORT:-9867}"
23
23
  local log="\${PINCHTAB_LOG:-/tmp/pinchtab.log}"
24
+ local pidfile="\${PINCHTAB_PIDFILE:-/tmp/pinchtab.pid}"
24
25
  local max_wait="\${1:-120}"
25
26
  mkdir -p "$(dirname "$log")" 2>/dev/null || true
26
27
  # Belt and braces alongside the container-level "--ulimit core=0": Chromium
@@ -52,7 +53,27 @@ export default function (mercury: {
52
53
  if command -v pinchtab >/dev/null 2>&1 && _pinchtab_port_open; then
53
54
  return 0
54
55
  fi
55
- pkill -f '[p]inchtab' 2>/dev/null || true
56
+ # Kill only the daemon THIS function started (and its Chromium children).
57
+ # This used to be a pkill sweep over the whole command line of every process,
58
+ # matching a bare "pinchtab" substring: the caller's own shell, a grep, an
59
+ # editor -- anything whose argv merely mentions the tool -- was killed with a
60
+ # bare "Terminated" and no explanation. The bracket trick in that old pattern
61
+ # only excluded pkill's own argv, never the caller's. Record the pid instead.
62
+ # grep -qa on /proc/PID/cmdline (NUL-separated) avoids writing a NUL escape
63
+ # inside this TS template literal, where it would collapse before reaching
64
+ # disk -- the same collapse that produced the multiPartTld wildcard bug.
65
+ _pinchtab_kill_stale() {
66
+ local old
67
+ old="$(cat "$pidfile" 2>/dev/null)" || return 0
68
+ [ -n "$old" ] || return 0
69
+ # PIDs are reused: only kill if it is still a pinchtab.
70
+ if grep -qa pinchtab "/proc/$old/cmdline" 2>/dev/null; then
71
+ pkill -TERM -P "$old" 2>/dev/null || true
72
+ kill -TERM "$old" 2>/dev/null || true
73
+ fi
74
+ rm -f "$pidfile"
75
+ }
76
+ _pinchtab_kill_stale
56
77
  # pinchtab >=0.13 ships IDPI enforcing with a loopback-only website
57
78
  # whitelist, so every public URL is refused with 403 idpi_domain_blocked --
58
79
  # including the Brave search this skill tells the agent to run. Widen the
@@ -89,6 +110,7 @@ export default function (mercury: {
89
110
  fi
90
111
  nohup pinchtab server >>"$log" 2>&1 &
91
112
  local pid=$!
113
+ echo "$pid" >"$pidfile"
92
114
  sleep 2
93
115
  if ! kill -0 "$pid" 2>/dev/null; then
94
116
  echo "pinchtab exited immediately (pid $pid). Log:" >&2
@@ -119,6 +141,17 @@ export default function (mercury: {
119
141
  let sessionPromptFragment = "";
120
142
 
121
143
  if (process.env.MERCURY_BROWSER_SESSIONS) {
144
+ // The `multiPartTld` regex below writes both of its dots as `\\.`, and
145
+ // that doubling is load-bearing: this is a TS template literal, so one
146
+ // backslash collapses on evaluation and a single `\.` would reach disk as
147
+ // a bare dot — a wildcard where a literal dot is meant. That was the bug
148
+ // in `pinchtab-multipart-tld-regex-dots-are-wildcards`; Biome's
149
+ // noUselessEscapeInString does not flag a doubled backslash, so the
150
+ // autofix that erased the original escapes cannot recur. This note lives
151
+ // out here because everything inside the backticks is shipped to the
152
+ // container, where "TS template literal" means nothing.
153
+ // tests/pinchtab-prompt.test.ts pins the EMITTED form, not this source.
154
+ //
122
155
  // Node.js injection script — pure ES5-style, no backticks or ${} so no TS escaping needed.
123
156
  // Reads MERCURY_BROWSER_SESSIONS from env, looks up the domain, injects cookies +
124
157
  // localStorage via the pinchtab HTTP bridge, then reloads. Exits 0 on success (session
@@ -131,12 +164,8 @@ var sessions;
131
164
  try { sessions = JSON.parse(Buffer.from(raw, "base64").toString()); } catch (e) { process.exit(1); }
132
165
  var hostname = (new URL(url)).hostname;
133
166
  var parts = hostname.split(".");
134
- // The two dots below are WILDCARDS, not literal dots, and always have been: a
135
- // backslash before a dot collapses to a bare dot when this TS template literal
136
- // is evaluated, long before the script reaches disk. Spelled out in words here
137
- // because writing the escape itself would be stripped the same way. Left as-is:
138
- // restoring the intent would change emitted behaviour, so it is filed, not fixed.
139
- var multiPartTld = /.(co|com|org|net|gov|ac|edu|or|ne|gr|gen|plc|ltd|me).[a-z]{2}$/i;
167
+ // eTLD+1: the dots are literal, so only a genuine multi-part TLD takes slice(-3).
168
+ var multiPartTld = /\\.(co|com|org|net|gov|ac|edu|or|ne|gr|gen|plc|ltd|me)\\.[a-z]{2}$/i;
140
169
  var domain = parts.length <= 2 ? hostname : (multiPartTld.test(hostname) ? parts.slice(-3).join(".") : parts.slice(-2).join("."));
141
170
  var b64 = sessions[domain];
142
171
  if (!b64) process.exit(1);
@@ -38,6 +38,7 @@ pinchtab_ensure() {
38
38
  local bind="${BRIDGE_BIND:-127.0.0.1}"
39
39
  local port="${BRIDGE_PORT:-9867}"
40
40
  local log="${PINCHTAB_LOG:-/tmp/pinchtab.log}"
41
+ local pidfile="${PINCHTAB_PIDFILE:-/tmp/pinchtab.pid}"
41
42
  local max_wait="${1:-120}"
42
43
  mkdir -p "$(dirname "$log")" 2>/dev/null || true
43
44
  # Chromium helper processes segfault under the container sandbox; each dump is
@@ -66,7 +67,24 @@ pinchtab_ensure() {
66
67
  if command -v pinchtab >/dev/null 2>&1 && _pinchtab_port_open; then
67
68
  return 0
68
69
  fi
69
- pkill -f '[p]inchtab' 2>/dev/null || true
70
+ # Kill only the daemon THIS function started (and its Chromium children).
71
+ # This used to be a pkill sweep over the whole command line of every process,
72
+ # matching a bare "pinchtab" substring: the caller's own shell, a grep, an
73
+ # editor -- anything whose argv merely mentions the tool -- was killed with a
74
+ # bare "Terminated" and no explanation. The bracket trick in that old pattern
75
+ # only excluded pkill's own argv, never the caller's. Record the pid instead.
76
+ _pinchtab_kill_stale() {
77
+ local old
78
+ old="$(cat "$pidfile" 2>/dev/null)" || return 0
79
+ [ -n "$old" ] || return 0
80
+ # PIDs are reused: only kill if it is still a pinchtab.
81
+ if grep -qa pinchtab "/proc/$old/cmdline" 2>/dev/null; then
82
+ pkill -TERM -P "$old" 2>/dev/null || true
83
+ kill -TERM "$old" 2>/dev/null || true
84
+ fi
85
+ rm -f "$pidfile"
86
+ }
87
+ _pinchtab_kill_stale
70
88
  # pinchtab >=0.13 ships IDPI enforcing with a loopback-only website whitelist,
71
89
  # so every public URL is refused with 403 idpi_domain_blocked -- including the
72
90
  # Brave search this skill tells the agent to run. Widen the allowlist but keep
@@ -98,6 +116,7 @@ pinchtab_ensure() {
98
116
  fi
99
117
  nohup pinchtab server >>"$log" 2>&1 &
100
118
  local pid=$!
119
+ echo "$pid" >"$pidfile"
101
120
  sleep 2
102
121
  if ! kill -0 "$pid" 2>/dev/null; then
103
122
  echo "pinchtab exited immediately (pid $pid). Log:" >&2
@@ -134,6 +153,14 @@ pinchtab text
134
153
 
135
154
  If `pinchtab_ensure` fails, show the user the tail of `/tmp/pinchtab.log`; do not only increase `sleep` and retry blindly.
136
155
 
156
+ **Inside the container, start the daemon only through `pinchtab_ensure`.** It
157
+ records the daemon's pid in `$PINCHTAB_PIDFILE` (default `/tmp/pinchtab.pid`)
158
+ and that record is the only thing a later call uses to clear a stale daemon. A
159
+ daemon started as a bare `pinchtab server &` — the quick-start above and the
160
+ "Advanced" options below both show that form, which is correct outside Mercury —
161
+ writes no pidfile, so it is never cleaned up and will hold the port against the
162
+ next `pinchtab_ensure`.
163
+
137
164
  ### Recommended Secure Setup
138
165
 
139
166
  ```bash
@@ -70,7 +70,12 @@ the ones that apply, delete the rest:>
70
70
  "users here are developers; show the command you ran when asked">.
71
71
  - Standing instructions from members: <what you do with "from now on…" from
72
72
  a non-admin — relay to the admin in one line / decline once / treat as
73
- banter — instead of the default "set a preference / only the admin can">.
73
+ banter>. Know what you are overriding: a personality, tone, greeting or
74
+ character request already gets a one-line "the owner sets it" decline from
75
+ the platform text, which every caller receives. Any *other* standing request
76
+ (a language, a length, a format) has **no** platform default for a member —
77
+ the procedure that would turn it into a preference is gated on `prefs.set`,
78
+ so if you do not say what happens here, nothing does.
74
79
  - Concision / clarification: <"a factual question gets a full answer, not a
75
80
  teaser" | "act on a reasonable reading; do not ask before acting">.
76
81
 
@@ -6,11 +6,17 @@
6
6
  ## תפקיד
7
7
 
8
8
  כתב הכדורגל של הקבוצה. הסיקור: ישראלים בחו"ל, הפועל תל אביב, בית"ר ירושלים,
9
- מכבי חיפה, מנצ'סטר יונייטד, ברצלונה, ריאל מדריד, וקבוצות ישראליות באירופה.
9
+ מכבי חיפה, מכבי תל אביב, מנצ'סטר יונייטד, ברצלונה, ריאל מדריד, וקבוצות
10
+ ישראליות באירופה.
10
11
  לשלושת המועדונים הגדולים, יונייטד וברצלונה וריאל, משקל שווה בדיוק: אף אחד לא
11
12
  מקבל יותר מקום מהאחרים. שאלה עובדתית מקבלת תשובה מלאה, לא טיזר. שטות מקבלת
12
13
  שטות.
13
14
 
15
+ כדורגל נשים וכדורגל נוער הם מחוץ לסיקור, בכל שפה ובכל תחרות — גם כשמדובר
16
+ באחד הנושאים שכן מסוקרים, וגם כשהפריט הגיע אליך כי סינון הכותרות פספס
17
+ אותו. פריט כזה לא נכנס לסריקה ולא לכתבה. אם חבר שואל עליו במפורש, עונים
18
+ כרגיל אבל בלי לפתוח לזה פנקס; פשוט לא מדווחים עליו ביוזמתך.
19
+
14
20
  ## עדיפויות
15
21
 
16
22
  כששני כללים כאן מושכים לכיוונים שונים, זה הסדר:
@@ -162,9 +168,10 @@
162
168
  - תאריך בסוגריים: יום וחודש בלבד (למשל 25/08), בלי שנה. השנה עדיין חובה
163
169
  לוודא במקור לפני הכתיבה — היא פשוט לא מוצגת.
164
170
  - אימוג'י מועדון קבוע, בתחילת השורה, רק בסריקה (לא בכתבה, לא בצ'אט):
165
- מנצ'סטר יונייטד 😈, ברצלונה 🔵, ריאל מדריד ⚪, הפועל תל אביב 🔴,
166
- בית"ר ירושלים 💛, מכבי חיפה 🟢. פריט שלא נוגע לאף אחד מהם (למשל סאגת
167
- העברות כללית, או ישראלי במועדון אחר) בלי אימוג'י בכלל — לא ממציאים אחד.
171
+ מנצ'סטר יונייטד 👹, ברצלונה ❤️💙, ריאל מדריד 🤍, הפועל תל אביב ❤️,
172
+ בית"ר ירושלים 💛🖤, מכבי חיפה 💚, מכבי תל אביב 💛💙. פריט שלא נוגע לאף אחד
173
+ מהם (למשל סאגת העברות כללית, או ישראלי במועדון אחר) בלי אימוג'י בכלל —
174
+ לא ממציאים אחד.
168
175
  - קיבוץ: כשכמה פריטים באותה סריקה נוגעים לאותו מועדון, הם נכתבים ברצף עם
169
176
  שורת כותרת אחת לשם המועדון (בלי אימוג'י כפול על כל שורה בתוך הקבוצה,
170
177
  האימוג'י יושב פעם אחת ליד שם הכותרת). מועדון עם פריט בודד לא מקבל כותרת,
@@ -196,6 +203,11 @@
196
203
  או הערה באנגלית (למשל "Good, already up to date. Now the chat reply.")
197
204
  לא נכנס להודעה בשום צורה, גם לא כשורה ראשונה. אם יש צורך לחשוב בקול, זה
198
205
  קורה מחוץ להודעה שנשלחת, לא בתוכה.
206
+ - שורה נכתבת פעם אחת. אם באמצע הכתיבה מתברר שהפרט שגוי, מוחקים את השורה
207
+ וכותבים אותה מחדש נכון: התיקון מחליף את השורה, אף פעם לא נוסף אחריה.
208
+ בהודעה שנשלחת אין "רגע,", אין "סליחה," ואין "תיקון:". הקורא בקבוצה רואה
209
+ בוט שמתקן את עצמו בקול, והפרט השגוי נשאר מול העיניים גם אחרי התיקון. זה
210
+ אותו כלל כמו הסעיף שמעל, בעברית במקום באנגלית.
199
211
 
200
212
  ## שתיקה
201
213
 
@@ -17,7 +17,7 @@ ambient traffic is therefore **0**, member-facing checks in
17
17
  member rows from the space's earlier life are kept on purpose (owner's
18
18
  decision D7, 2026-09-02): the real group is the same people, so the rows
19
19
  become correct again at the link. Posting budget is the real-group setting
20
- already (`feed-watch.max_per_hour: 1`, `batch_minutes: 15`; D8). Task 24
20
+ already (`feed-watch.max_per_hour: 3`, `batch_minutes: 15`; D-024). Task 24
21
21
  (the 09:00 article) runs on demand here; the harness runs with
22
22
  `--allow-linked` and rewrites history.
23
23
 
@@ -117,10 +117,11 @@ extension_config:
117
117
  # setter of any profile-owned key whose live value a person wrote.
118
118
  #
119
119
  # The list a fresh space starts from, for the record. Maccabi Haifa was
120
- # missing until M2.3 — it is one of the seven coverage topics in AGENTS.md
121
- # and one of the seeded notebooks. Maccabi Tel Aviv and Hapoel Be'er Sheva
122
- # are not standing coverage topics; they are watched because "Israeli clubs
123
- # in Europe" is one.
120
+ # missing until M2.3 — it is one of the coverage topics in AGENTS.md and one
121
+ # of the seeded notebooks. Maccabi Tel Aviv joined the standing coverage on
122
+ # 2026-09-03 (the owner, from chat; D-023) and has a seeded notebook too.
123
+ # Hapoel Be'er Sheva is not a standing coverage topic; it is watched because
124
+ # "Israeli clubs in Europe" is one.
124
125
  # feed-watch.terms: |
125
126
  # ["מנצ'סטר יונייטד","מאן יונייטד","Manchester United","Man Utd",
126
127
  # "ברצלונה","Barcelona","ריאל מדריד","Real Madrid",
@@ -131,7 +132,35 @@ extension_config:
131
132
  # stripped, so `מנצ'סטר יונייטד` also catches `למנצ'סטר יונייטד`. The bare
132
133
  # word `יונייטד` is deliberately absent: it would match Newcastle and Leeds
133
134
  # every match day.
134
- feed-watch.exclude: '["נוער","נשים","fantasy","WSL","U21","U19","טוטו","הימור"]'
135
+ # The exclude list is matched over title plus summary like the terms, but
136
+ # anchored at the **start of a word** since 2026-09-06 (Hebrew prefixes
137
+ # looked through, the end of the word still open — see
138
+ # `docs/debug/minor/2026-09-06-feed-watch-hebrew-exclude-term-over-matches-a-longer-word.md`,
139
+ # where `נשים` was dropping every headline containing `אנשים`, "people").
140
+ # An exclusion still only catches headlines in the language it was written
141
+ # in. `נשים` and `WSL` said "women's football is out of scope" and let three
142
+ # English items through in two days on 2026-09-04/05
143
+ # (`docs/debug/minor/2026-09-06-football-womens-football-slips-past-feed-watch-exclude.md`),
144
+ # because `Arsenal to face Barca & Lyon in Women's Champions League` carries
145
+ # neither. Every added form is a title the live feeds actually publish:
146
+ # `Women` still covers `Women's` and `Womens` (the apostrophe is stripped,
147
+ # and only the front of the word is anchored). `NWSL` is listed in its own
148
+ # right — under the anchor `WSL` no longer reaches inside it.
149
+ #
150
+ # The Catalan `Femení` is deliberately NOT here, and neither is the shorter
151
+ # `Femen` that would have covered it. **Kiko Femenía** is a men's La Liga
152
+ # right-back, the two Marca feeds above are Spanish-language, and `normalize`
153
+ # does not strip Latin diacritics — so both `Femen` and `Femení` open the
154
+ # word `femenía` and would drop a men's item naming him, silently and with no
155
+ # trace. The front anchor does not help here: the surname *starts* with those
156
+ # letters. It is the same silent-drop failure the terms comment warns about
157
+ # (`City` inside *Capacity*), in the direction that loses a story instead of
158
+ # posting a bad one. `Femenino`/`Femenina` are safe (the `í` and the second
159
+ # `n` both differ), so Spanish is filtered and Catalan is left to the prose.
160
+ #
161
+ # The list is still only as good as its substrings — the backstop for what it
162
+ # misses is the coverage rule in `AGENTS.md § תפקיד`, not another substring.
163
+ feed-watch.exclude: '["נוער","נשים","fantasy","WSL","NWSL","U21","U19","טוטו","הימור","Women","UWCL","Lionesses","Femenino","Femenina"]'
135
164
  # Nothing is opened between 00:30 and 07:30 local; matched items still reach
136
165
  # the 09:00 article through the digest, which is the whole point of buffering
137
166
  # separately from reporting.
@@ -144,11 +173,14 @@ extension_config:
144
173
  # which answered NO_UPDATE. Lowered 2026-08-31 to 1/hour and a 15-minute
145
174
  # batch: a wider batch also merges more outlets carrying one story into a
146
175
  # single run, which is what the verify prompt asks the model to do anyway.
176
+ # Back to 3/hour on 2026-09-03 (owner's call, set from the dashboard and then
177
+ # confirmed in writing; D-024). The batch stays at 15 minutes, so at most four
178
+ # batches an hour can open and the cap only bites on a burst.
147
179
  #
148
180
  # These reduce the *number* of runs. They are not the digest fix — see
149
181
  # `docs/debug/major/2026-08-31-feed-watch-digest-persisted-and-replayed.md` for the prefix size,
150
182
  # which is the other factor in the same product.
151
- feed-watch.max_per_hour: "1"
183
+ feed-watch.max_per_hour: "3"
152
184
  feed-watch.batch_minutes: "15"
153
185
 
154
186
  # The other factor in the same product: how big each run's prompt is. The
@@ -216,3 +248,7 @@ workspace_seed:
216
248
  knowledge/episodes/real-madrid-2026-27.md: ./seed/episodes/real-madrid-2026-27.md
217
249
  knowledge/episodes/beitar-jerusalem-2026-27.md: ./seed/episodes/beitar-jerusalem-2026-27.md
218
250
  knowledge/episodes/maccabi-haifa-2026-27.md: ./seed/episodes/maccabi-haifa-2026-27.md
251
+ # Added 2026-09-03 with the club (D-023). The live space already had this
252
+ # notebook — napkin opened it on 2026-08-31 — so `apply` there writes nothing;
253
+ # the seed is for a space that starts from this profile.
254
+ knowledge/episodes/maccabi-tel-aviv-2026-27.md: ./seed/episodes/maccabi-tel-aviv-2026-27.md
@@ -13,7 +13,7 @@
13
13
  "מחברת". כאן רק הרשימה, כדי שלא אצטרך לחפש:
14
14
 
15
15
  `man-united-2026-27`, `barcelona-2026-27`, `real-madrid-2026-27`,
16
- `beitar-jerusalem-2026-27`, `maccabi-haifa-2026-27`,
16
+ `beitar-jerusalem-2026-27`, `maccabi-haifa-2026-27`, `maccabi-tel-aviv-2026-27`,
17
17
  `israeli-clubs-europe-2026-qualifiers` (וגם הפועל ת"א נמצאת שם, אין לה
18
18
  פנקס נפרד), `summer-2026-transfer-window`, `toto-cup-2026`.
19
19
 
@@ -4,7 +4,7 @@ status: active
4
4
  started: 2026-08-21
5
5
  last_mentioned: 2026-08-21
6
6
  mentions: 1
7
- keywords: ["ביתר", "בית", "ירושלים", "beitar", "jerusalem", "גדראני", "טוטו"]
7
+ keywords: ["ביתר", "ירושלים", "beitar", "jerusalem", "גדראני", "טוטו"]
8
8
  summary: בית"ר ירושלים 2026-27 — בלי אירופה אחרי ההדחה בווינה; העונה היא ליגה וגביע הטוטו.
9
9
  ---
10
10
 
@@ -0,0 +1,24 @@
1
+ ---
2
+ type: episode
3
+ status: active
4
+ started: 2026-09-03
5
+ last_mentioned: 2026-09-03
6
+ mentions: 1
7
+ keywords: ["מכבי", "תל", "אביב", "maccabi", "tel", "aviv", "בלומפילד"]
8
+ summary: מכבי תל אביב 2026-27 — הפנקס נפתח כמעט ריק ביום שהמועדון נכנס לסיקור הקבוע (03/09); מה שאני מוסר עליו נכתב כאן, עם מקור ותאריך, לפני שהוא נשלח.
9
+ ---
10
+
11
+ # מכבי תל אביב 2026-27
12
+
13
+ הפנקס הקבוע של המועדון בליגת העל ובחלון ההעברות. מכבי תל אביב נכנסה לסיקור
14
+ הקבוע ב-03/09 (הכרעת המנהל בצ'אט), ולכן הפנקס קיים גם כשאין בו כמעט כלום:
15
+ מה שאני מוסר עליה נכתב כאן, עם מקור ותאריך, לפני שהוא נשלח. המסע האירופי
16
+ של העונה יושב ב-[[israeli-clubs-europe-2026-qualifiers]] ולא כאן.
17
+
18
+ ## Current State
19
+ <!-- נכון ל-2026-09-03. הכותרת חייבת להיות בדיוק "## Current State" — סיומת בסוגריים מפילה את ההזרקה. -->
20
+ - (אין עובדה) אין עדיין פריט מאומת על הליגה או על סגל העונה.
21
+
22
+ ## History
23
+ <!-- append-only; newest first -->
24
+ - 2026-09-03: הפנקס נפתח על ידי הפרופיל ביום שהמועדון נוסף לסיקור הקבוע. נפתח כמעט ריק בכוונה — עדיף פנקס שאומר "אין לי כלום" על פני השלמה מהזיכרון.
@@ -1,55 +1,56 @@
1
1
  # מה לזקק במרחב הזה
2
2
 
3
- המרחב הזה הוא קבוצת חברים שמדברת כדורגל, והבוט בה הוא כתב. הזיקוק נועד
4
- לשרת את הכתב: מה שנשמר צריך להיות שימושי בתשובה עובדתית למחרת.
3
+ המרחב הזה הוא קבוצת חברים שמדברת כדורגל, והבוט בה הוא כתב. מה שנשמר צריך
4
+ להיות שימושי בתשובה עובדתית למחרת.
5
5
 
6
6
  ## ישויות
7
7
 
8
- - **מועדון** → פנקס קבוע אחד, בשם `<club>-2026-27.md` (למשל
9
- `man-united-2026-27.md`, `barcelona-2026-27.md`). אל תפתח פנקס שני
10
- לאותו מועדון — עדכן את הקיים.
11
- - **מפעל** (ליגה, גביע, מוקדמות) → פנקס אחד למפעל ולעונה, למשל
12
- `toto-cup-2026.md`, `israeli-clubs-europe-2026-qualifiers.md`.
13
- - **סאגה** (העברה מתמשכת, פציעה, סכסוך) → פנקס משלה, עם `status: resolved`
14
- כשהיא נסגרת.
15
- - שיחות על עבודת פיתוח, על הבוט עצמו, ומשחקי חברה בקבוצה — לא נכנסים
16
- לפנקסי המועדונים. פנקס נפרד, אם בכלל.
8
+ - **מועדון** → פנקס קבוע אחד, `<club>-2026-27.md` (`barcelona-2026-27.md`).
9
+ אל תפתח פנקס שני לאותו מועדון — עדכן את הקיים.
10
+ - **מפעל** (ליגה, גביע, מוקדמות) → פנקס אחד למפעל ולעונה, `toto-cup-2026.md`.
11
+ - **סאגה** (העברה מתמשכת, פציעה, סכסוך) → פנקס משלה, `status: resolved` בסגירה.
12
+ - שיחות על פיתוח, על הבוט עצמו ומשחקי חברה — לא נכנסים לפנקסי המועדונים.
13
+ פנקס נפרד, אם בכלל.
17
14
 
18
15
  ## מילות מפתח
19
16
 
20
17
  `keywords` הן מה שמחבר הודעה בצ'אט לפנקס, ולכן:
21
18
 
22
- - **עברית ואנגלית לאותו דבר**: `["יונייטד", "united", "ברצלונה",
23
- "barcelona"]`. חברים כותבים בשתי השפות באותה שיחה.
24
- - **מילה אחת, בלי פיסוק.** ההודעה מפוצלת למילים על רווח ועל פיסוק ASCII,
25
- ובכלל זה `'` ו-`"`, ואז מחפשים התאמה מדויקת למילה. לכן `מנצ'סטר` ו-`ת"א`
26
- לעולם לא יתאימו — הם מתפצלים לשניים. במקומם: `יונייטד`, `הפועל`,
27
- `ירושלים`, `חיפה`.
19
+ - **עברית ואנגלית לאותו דבר**: `["יונייטד", "united"]` — כותבים בשתי השפות.
20
+ - **מילה אחת, בלי פיסוק ובלי מקף.** גרש וגרשיים בתוך מילה בהודעה מוסרים
21
+ (`לבית"ר` מוצא את `ביתר`), אבל מילת מפתח לא עוברת ניקוי — `מנצ'סטר`
22
+ ו-`ת"א` לא יתאימו. במקומן: `יונייטד`, `ביתר`, `ירושלים`, `חיפה`.
23
+ - **המילה הבסיסית, פעם אחת.** אותיות שימוש (ו, ה, ב, ל, מ, ש, כ) בתחילת
24
+ המילה בהודעה מתאימות מעצמן — `ליונייטד` ו-`וכשיונייטד` מוצאים את
25
+ `יונייטד` — כל עוד נשארות ארבע אותיות, וחמש אחרי מ'. אל תוסיף צורות כמו
26
+ `ליונייטד` כמילות מפתח: כל מילת מפתח נוספת מורידה את ציון הפנקס. במילה
27
+ קצרה בדוק ו/ה/ב/ל/ש/כ + המילה (מ' מטופלת כבר): אם יוצאת מילה אחרת
28
+ קיימת, בחר אחרת.
28
29
 
29
30
  ## עובדות
30
31
 
31
32
  - הרכב, תוצאה, פציעה, תאריך משחק ומעבר הם עובדות. כל שורה נושאת **מקור
32
33
  ותאריך** בסוגריים בסופה: `(ONE, 20/08)`. שורה בלי מקור ותאריך לא נכתבת.
33
34
  - שתי שורות מותרות בלי מקור, ורק הן, ושתיהן נפתחות בסימון: `(אין עובדה)`
34
- לשורה שאומרת שאין עדיין מה לדעת, ו-`(מצביע)` לשורה שכל תוכנה הפניה
35
- לקובץ אחר. אל תמחק את הסימונים ואל תמציא סימונים נוספים.
36
- - כותרת הסעיף היא בדיוק `## Current State`, בלי שום סיומת. סיומת כמו
37
- `(as of 2026-08-20)` נראית תמימה ומפילה את כל הסעיף: המנגנון שמזריק את
38
- הפנקס לשיחה מחפש את הכותרת המדויקת ומקבל סעיף ריק, כך שהעובדות לא
39
- מגיעות לבוט בכלל. תאריך העדכון נכתב בשורה נפרדת מתחת לכותרת.
40
- - `## Current State` מחזיק **רק את מה שנכון עכשיו**. הרכב משוער שהוחלף
41
- ברשמי, תוצאה שהתעדכנה, פציעה שהחלימה — יורדים ל-`## History` עם התאריך
42
- ועם הסיבה, ולא נשארים למעלה.
35
+ לשורה שאומרת שאין עדיין מה לדעת, ו-`(מצביע)` לשורה שכולה הפניה לקובץ
36
+ אחר. אל תמחק סימונים ואל תמציא חדשים.
37
+ - כותרת הסעיף היא בדיוק `## Current State`, בלי שום סיומת. תאריך העדכון
38
+ נכתב בשורה נפרדת מתחת לכותרת.
39
+ - `## Current State` מחזיק **רק את מה שנכון עכשיו, ולא יותר מ-12 שורות**.
40
+ שורה שהוחלפה (הרכב משוער שהוחלף ברשמי, תוצאה שהתעדכנה, פציעה שהחלימה)
41
+ ושורה שהתאריך בסוגריים שלה ישן מ-14 יום — גם אם היא עדיין נכונה — יורדות
42
+ ל-`## History` עם התאריך ועם הסיבה. אם אחרי זה נשארו יותר מ-12 שורות,
43
+ מורידים את הישנות ביותר עד שנשארות 12. לא מוחקים, רק מורידים.
43
44
  - דרגת ודאות שהמקור נתן (רשמי / סגור / מתקדם / שמועה) נשמרת כמו שהיא.
44
45
  אל תשדרג שמועה לעובדה ואל תוריד עובדה לשמועה.
45
46
  - הפנקס בעברית, כמו הקבוצה.
46
47
 
47
48
  ## אנשים
48
49
 
49
- בפנקסי `people/` נשמרות העדפות והרגלים, לא מזהים: בלי מספרי טלפון, בלי
50
- מזהי וואטסאפ ובלי כתובות.
50
+ בפנקסי `people/` נשמרות העדפות והרגלים, לא מזהים: בלי טלפונים, בלי מזהי
51
+ וואטסאפ ובלי כתובות.
51
52
 
52
53
  ## תיקיית `.napkin`
53
54
 
54
55
  `.napkin/distill.md` — הקובץ הזה — הוא הנחיה, לא ידע. אל תערוך אותו, אל
55
- תעביר ממנו תוכן לפנקסים ואל תכתוב שום דבר אחר לתוך `.napkin/`.
56
+ תעביר ממנו תוכן לפנקסים ואל תכתוב דבר אחר לתוך `.napkin/`.
@@ -14,13 +14,22 @@
14
14
  "this file second, and let the test tell you if you missed one."
15
15
  ],
16
16
 
17
+ "$clubEmoji_comment": [
18
+ "The marker a scan item opens with. Since the owner's 2026-09-03 edit (D-023)",
19
+ "a marker may be TWO emoji; the lint compares the whole run at line start as",
20
+ "one unit, so ❤\uFE0F💙 is Barcelona and a bare ❤\uFE0F is Hapoel, never half of both.",
21
+ "The heart is written with an escaped U+FE0F because the bot writes it that",
22
+ "way and the selector is invisible in an editor; the lint ignores selectors",
23
+ "when it compares, so a phone that drops or adds one still matches."
24
+ ],
17
25
  "clubEmoji": [
18
- { "emoji": "😈", "names": ["מנצ'סטר יונייטד", "מאן יונייטד", "יונייטד", "Manchester United", "Man Utd"] },
19
- { "emoji": "🔵", "names": ["ברצלונה", "Barcelona", "ברסה"] },
20
- { "emoji": "⚪", "names": ["ריאל מדריד", "ריאל", "Real Madrid"] },
21
- { "emoji": "🔴", "names": ["הפועל תל אביב", "הפועל ת\"א", "Hapoel Tel Aviv"] },
22
- { "emoji": "💛", "names": ["בית\"ר ירושלים", "ביתר ירושלים", "בית\"ר", "Beitar Jerusalem"] },
23
- { "emoji": "🟢", "names": ["מכבי חיפה", "Maccabi Haifa"] }
26
+ { "emoji": "👹", "names": ["מנצ'סטר יונייטד", "מאן יונייטד", "יונייטד", "Manchester United", "Man Utd"] },
27
+ { "emoji": "❤\uFE0F💙", "names": ["ברצלונה", "Barcelona", "ברסה"] },
28
+ { "emoji": "🤍", "names": ["ריאל מדריד", "ריאל", "Real Madrid"] },
29
+ { "emoji": "❤\uFE0F", "names": ["הפועל תל אביב", "הפועל ת\"א", "Hapoel Tel Aviv"] },
30
+ { "emoji": "💛🖤", "names": ["בית\"ר ירושלים", "ביתר ירושלים", "בית\"ר", "Beitar Jerusalem"] },
31
+ { "emoji": "💚", "names": ["מכבי חיפה", "Maccabi Haifa"] },
32
+ { "emoji": "💛💙", "names": ["מכבי תל אביב", "מכבי ת\"א", "Maccabi Tel Aviv"] }
24
33
  ],
25
34
 
26
35
  "caps": {
@@ -107,6 +116,33 @@
107
116
 
108
117
  "fillerAcks": ["בוצע", "Done", "סיימתי", "בדקתי", "מקובל."],
109
118
 
119
+ "$selfCorrectionMarkers_comment": [
120
+ "N-4: the markers a published mid-sentence self-correction is written with.",
121
+ "Read by the no-self-correction lint rule. Message #4107 (2026-09-02) is the",
122
+ "case: `… ובית\"ר... רגע, על פיינורד-ברצלונה …`.",
123
+ "",
124
+ "Two lists, because each of these is also an ordinary Hebrew word and the",
125
+ "shape is what distinguishes a correction from prose:",
126
+ "",
127
+ " afterBreak - counts only behind a sentence break (… ... . ! ? — –) AND in",
128
+ " front of a comma. Either half alone has a counter-example in",
129
+ " football copy: 'ביקש סליחה, ואז' is the comma with no break,",
130
+ " 'הוא הבקיע. רגע קסם' is the break with no comma.",
131
+ " always - a correction marker wherever it stands.",
132
+ "",
133
+ "AGENTS.md § פורמט owns the rule and quotes three of these verbatim; the",
134
+ "lint's list is wider, the same trade writeClaimPhrases makes -- the prose",
135
+ "teaches the model the shape, the lint has to catch it however it is",
136
+ "written. `לא -` with an ASCII hyphen is deliberately absent: the hyphen is",
137
+ "a working connector in Hebrew (ל-3.5 שנים) and the long dash is already",
138
+ "ruled out by § פורמט's punctuation bullet."
139
+ ],
140
+ "selfCorrectionMarkers": {
141
+ "afterBreak": ["רגע", "סליחה"],
142
+ "always": ["תיקון:", "כלומר לא", "לא —", "לא –"]
143
+ },
144
+ "$selfCorrectionMarkersFromProse": ["רגע,", "סליחה,", "תיקון:"],
145
+
110
146
  "escalationPhrases": {
111
147
  "$comment": "`fromStandard` is quoted in AGENTS.md as the wrong answer; `fromDecision` is the phrasing D-016 recorded from the live failure. The lint flags either.",
112
148
  "fromStandard": ["אין לך הרשאה", "זה לא בסמכות שלי", "המנהל צריך לאשר"],
@@ -118,7 +154,7 @@
118
154
  "lineupSectionLabels": ["שוער", "הגנה", "קישור", "חוליה התקפית", "חלוץ", "חלוצים"],
119
155
 
120
156
  "$sources": {
121
- "clubEmoji": "AGENTS.md § פורמט",
157
+ "clubEmoji": "AGENTS.md § פורמט — seven clubs since 2026-09-03; a marker is the whole emoji run at line start",
122
158
  "caps.itemMaxChars": "AGENTS.md § פורמט — 'עד 200 תווים'",
123
159
  "caps.summaryMaxChars": "tasks/daily-article.md § בלוק הסיכום — 'עד 900 תווים'; must equal config.yaml longview.summary_max_chars",
124
160
  "caps.articleMinChars": "tasks/daily-article.md § אורך — '2,500–4,500 תווים'",
@@ -132,6 +168,7 @@
132
168
  "hedgePhrases": "AGENTS.md § ייחוס — 'בלי מילוי מהוסס'",
133
169
  "clickbaitWords": "tasks/daily-article.md § אסור",
134
170
  "escalationPhrases": "AGENTS.md § היקף + § סגנון (D-016)",
135
- "relativeDateWords": "AGENTS.md § טריות + D-004"
171
+ "relativeDateWords": "AGENTS.md § טריות + D-004",
172
+ "selfCorrectionMarkers": "AGENTS.md § פורמט — the 'a line is written once' bullet, which quotes רגע, / סליחה, / תיקון: as the words that never reach a sent message (N-4)"
136
173
  }
137
174
  }