mercury-agent 0.16.2 → 0.17.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 (62) hide show
  1. package/README.md +12 -0
  2. package/container/Dockerfile +1 -0
  3. package/container/Dockerfile.base +1 -0
  4. package/docs/autopilot/config.yaml +43 -0
  5. package/docs/behavior-layers.md +26 -12
  6. package/docs/container-lifecycle.md +1 -0
  7. package/docs/extensions.md +43 -0
  8. package/docs/goals/football-reporter-profile/decisions.md +37 -1
  9. package/docs/goals/football-reporter-profile/roadmap.md +63 -5
  10. package/docs/permissions.md +15 -0
  11. package/docs/profile-guide.md +304 -18
  12. package/docs/skills-guide.md +24 -0
  13. package/examples/extensions/feed-watch/config.ts +36 -0
  14. package/examples/extensions/feed-watch/digest.ts +230 -32
  15. package/examples/extensions/feed-watch/index.ts +8 -0
  16. package/examples/extensions/feed-watch/items.ts +48 -4
  17. package/examples/extensions/overview/actions.ts +264 -0
  18. package/examples/extensions/overview/gate.ts +82 -0
  19. package/examples/extensions/overview/index.ts +123 -0
  20. package/examples/extensions/overview/skill/SKILL.md +80 -0
  21. package/examples/profiles/_template/config.yaml +34 -0
  22. package/examples/profiles/football-reporter/AGENTS.md +24 -6
  23. package/examples/profiles/football-reporter/README.md +9 -1
  24. package/examples/profiles/football-reporter/config.yaml +31 -2
  25. package/examples/profiles/football-reporter/tasks/daily-article.md +6 -4
  26. package/package.json +7 -3
  27. package/resources/templates/mercury.example.yaml +5 -0
  28. package/src/adapters/whatsapp-media.ts +80 -3
  29. package/src/agent/container-entry.ts +143 -21
  30. package/src/agent/container-env.ts +11 -0
  31. package/src/agent/container-error.ts +12 -1
  32. package/src/agent/container-runner.ts +91 -9
  33. package/src/agent/pi-jsonl-parser.ts +166 -5
  34. package/src/bridges/discord.ts +4 -0
  35. package/src/bridges/slack.ts +4 -0
  36. package/src/bridges/teams.ts +4 -0
  37. package/src/bridges/telegram.ts +33 -1
  38. package/src/cli/build-image-tags.ts +34 -0
  39. package/src/cli/mercury.ts +51 -4
  40. package/src/config-file.ts +6 -0
  41. package/src/config.ts +28 -3
  42. package/src/core/attachment-notes.ts +4 -0
  43. package/src/core/confirmation.ts +3 -1
  44. package/src/core/handler.ts +2 -1
  45. package/src/core/media.ts +104 -2
  46. package/src/core/outbox.ts +23 -3
  47. package/src/core/reply-context.ts +61 -0
  48. package/src/core/routes/chat.ts +70 -3
  49. package/src/core/routes/config-builtin.ts +7 -0
  50. package/src/core/routes/dashboard.ts +91 -5
  51. package/src/core/runtime.ts +170 -22
  52. package/src/core/task-output.ts +36 -18
  53. package/src/core/task-scheduler.ts +18 -1
  54. package/src/extensions/catalog.ts +1 -0
  55. package/src/extensions/hooks.ts +8 -0
  56. package/src/extensions/loader.ts +68 -7
  57. package/src/extensions/types.ts +35 -2
  58. package/src/main.ts +19 -0
  59. package/src/server.ts +15 -8
  60. package/src/storage/db.ts +231 -12
  61. package/src/text/sanitize-text.ts +118 -0
  62. package/src/types.ts +73 -1
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The five read-only actions behind `mrctl capability overview <action>`.
3
+ *
4
+ * Every action is backed by a plain SELECT. Nothing here may call a `Db` method
5
+ * that writes — `listMutes` is deliberately absent because it deletes expired
6
+ * rows as a side effect, which would quietly make this module a writer.
7
+ *
8
+ * Caps (limit ceiling, content truncation) are enforced here, on the host. They
9
+ * are not advice to the agent: a single pull must not be able to dump an entire
10
+ * space into the management space's context.
11
+ */
12
+
13
+ import type {
14
+ CapabilityResult,
15
+ MercuryExtensionContext,
16
+ } from "mercury-agent/extensions/types";
17
+ import type { ScheduledTask, StoredMessage } from "mercury-agent/types";
18
+
19
+ /** `src/storage/db.ts` is not in the package `exports` map — and must not be. */
20
+ export type OverviewDb = MercuryExtensionContext["db"];
21
+
22
+ export const OVERVIEW_ACTIONS = [
23
+ "list",
24
+ "recent",
25
+ "search",
26
+ "tasks",
27
+ "roles",
28
+ ] as const;
29
+
30
+ /** Chars of message body returned before truncation kicks in. */
31
+ export const CONTENT_MAX_CHARS = 500;
32
+
33
+ const DEFAULT_LIMIT = 20;
34
+ const MAX_LIMIT = 100;
35
+
36
+ export interface OverviewTask {
37
+ id: number;
38
+ spaceId: string;
39
+ name: string | null;
40
+ cron: string | null;
41
+ at: string | null;
42
+ active: boolean | number;
43
+ silent: boolean | number;
44
+ timezone: string | null;
45
+ nextRunAt: number;
46
+ lastRunAt: number | null;
47
+ lastStatus: string | null;
48
+ lastError: string | null;
49
+ prompt: string;
50
+ truncated: boolean;
51
+ }
52
+
53
+ export interface OverviewMessage {
54
+ id: number;
55
+ spaceId: string;
56
+ role: string;
57
+ authorName: string | null;
58
+ content: string;
59
+ truncated: boolean;
60
+ createdAt: number;
61
+ }
62
+
63
+ type Body = Record<string, unknown> | null;
64
+
65
+ const bad = (error: string): CapabilityResult => ({
66
+ status: 400,
67
+ data: { error },
68
+ });
69
+
70
+ /**
71
+ * Cut free text at CONTENT_MAX_CHARS *code points*, not UTF-16 code units: a
72
+ * plain slice can split a surrogate pair or an emoji sequence and emit a lone
73
+ * surrogate, and this deployment carries Hebrew and emoji content.
74
+ */
75
+ function cut(raw: string | null | undefined): {
76
+ text: string;
77
+ truncated: boolean;
78
+ } {
79
+ const full = raw ?? "";
80
+ const points = [...full];
81
+ if (points.length <= CONTENT_MAX_CHARS)
82
+ return { text: full, truncated: false };
83
+ return { text: points.slice(0, CONTENT_MAX_CHARS).join(""), truncated: true };
84
+ }
85
+
86
+ function toMessage(m: StoredMessage): OverviewMessage {
87
+ const { text, truncated } = cut(m.content);
88
+ return {
89
+ id: m.id,
90
+ spaceId: m.spaceId,
91
+ role: m.role,
92
+ authorName: m.authorName ?? null,
93
+ content: text,
94
+ truncated,
95
+ createdAt: m.createdAt,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Tasks get the same treatment messages do. A `ScheduledTask` row carries
101
+ * `prompt` and `lastError` as unbounded free text — task prompts in this repo
102
+ * run to hundreds of lines — so returning rows verbatim would be the one pull
103
+ * that can still flood the management space's context.
104
+ */
105
+ function toTask(t: ScheduledTask): OverviewTask {
106
+ const prompt = cut(t.prompt);
107
+ const lastError = cut(t.lastError);
108
+ return {
109
+ id: t.id,
110
+ spaceId: t.spaceId,
111
+ name: t.name ?? null,
112
+ cron: t.cron ?? null,
113
+ at: t.at ?? null,
114
+ active: t.active,
115
+ silent: t.silent,
116
+ timezone: t.timezone ?? null,
117
+ nextRunAt: t.nextRunAt,
118
+ lastRunAt: t.lastRunAt ?? null,
119
+ lastStatus: t.lastStatus ?? null,
120
+ lastError: t.lastError === null ? null : lastError.text,
121
+ prompt: prompt.text,
122
+ truncated: prompt.truncated || lastError.truncated,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * A missing limit is the default; a present-but-unusable one is a 400 rather
128
+ * than a silent clamp, so a typo in the agent's JSON is visible instead of
129
+ * being answered with a differently-sized result than it asked for.
130
+ */
131
+ function parseLimit(raw: unknown): number | { error: string } {
132
+ if (raw === undefined || raw === null) return DEFAULT_LIMIT;
133
+ if (
134
+ typeof raw !== "number" ||
135
+ !Number.isFinite(raw) ||
136
+ !Number.isInteger(raw)
137
+ ) {
138
+ return { error: "limit must be an integer" };
139
+ }
140
+ if (raw < 1) return { error: "limit must be at least 1" };
141
+ return Math.min(raw, MAX_LIMIT);
142
+ }
143
+
144
+ function requireSpace(
145
+ db: OverviewDb,
146
+ raw: unknown,
147
+ ): { spaceId: string } | { error: string; status: 400 | 404 } {
148
+ if (typeof raw !== "string" || raw.trim() === "") {
149
+ return { error: "spaceId is required", status: 400 };
150
+ }
151
+ const spaceId = raw.trim();
152
+ if (db.getSpace(spaceId) === null) {
153
+ return { error: `unknown space: ${spaceId}`, status: 404 };
154
+ }
155
+ return { spaceId };
156
+ }
157
+
158
+ export function runOverviewAction(
159
+ action: string,
160
+ body: Body,
161
+ db: OverviewDb,
162
+ ): CapabilityResult {
163
+ const b = body ?? {};
164
+
165
+ switch (action) {
166
+ case "list": {
167
+ const spaces = db
168
+ .listSpaces()
169
+ .map((s) => {
170
+ const conversations = db.getSpaceConversations(s.id);
171
+ return {
172
+ id: s.id,
173
+ name: s.name,
174
+ tags: s.tags,
175
+ lastActivity: s.updatedAt,
176
+ messageCount: db.countSpaceMessages(s.id),
177
+ conversationCount: conversations.length,
178
+ platforms: [...new Set(conversations.map((c) => c.platform))],
179
+ };
180
+ })
181
+ .sort((a, z) => z.lastActivity - a.lastActivity);
182
+ return { data: { spaces } };
183
+ }
184
+
185
+ case "recent": {
186
+ const space = requireSpace(db, b.spaceId);
187
+ if ("error" in space) {
188
+ return { status: space.status, data: { error: space.error } };
189
+ }
190
+ const limit = parseLimit(b.limit);
191
+ if (typeof limit !== "number") return bad(limit.error);
192
+
193
+ return {
194
+ data: {
195
+ spaceId: space.spaceId,
196
+ messages: db.listSpaceMessages(space.spaceId, limit).map(toMessage),
197
+ },
198
+ };
199
+ }
200
+
201
+ case "search": {
202
+ const q = typeof b.q === "string" ? b.q.trim() : "";
203
+ if (q === "") return bad("q is required");
204
+
205
+ const limit = parseLimit(b.limit);
206
+ if (typeof limit !== "number") return bad(limit.error);
207
+
208
+ let spaceIds: string[];
209
+ if (b.spaceId === undefined || b.spaceId === null) {
210
+ spaceIds = db.listSpaces().map((s) => s.id);
211
+ } else {
212
+ const space = requireSpace(db, b.spaceId);
213
+ if ("error" in space) {
214
+ return { status: space.status, data: { error: space.error } };
215
+ }
216
+ spaceIds = [space.spaceId];
217
+ }
218
+
219
+ const results = spaceIds
220
+ .flatMap((id) => db.searchMessages(id, q, limit))
221
+ .map(toMessage)
222
+ .sort((a, z) => z.createdAt - a.createdAt)
223
+ .slice(0, limit);
224
+
225
+ return { data: { query: q, results } };
226
+ }
227
+
228
+ case "tasks": {
229
+ const limit = parseLimit(b.limit);
230
+ if (typeof limit !== "number") return bad(limit.error);
231
+
232
+ let rows: ScheduledTask[];
233
+ if (b.spaceId === undefined || b.spaceId === null) {
234
+ rows = db.listTasks();
235
+ } else {
236
+ const space = requireSpace(db, b.spaceId);
237
+ if ("error" in space) {
238
+ return { status: space.status, data: { error: space.error } };
239
+ }
240
+ rows = db.listTasks(space.spaceId);
241
+ }
242
+
243
+ return { data: { tasks: rows.slice(0, limit).map(toTask) } };
244
+ }
245
+
246
+ case "roles": {
247
+ const space = requireSpace(db, b.spaceId);
248
+ if ("error" in space) {
249
+ return { status: space.status, data: { error: space.error } };
250
+ }
251
+ return {
252
+ data: { spaceId: space.spaceId, roles: db.listRoles(space.spaceId) },
253
+ };
254
+ }
255
+
256
+ default:
257
+ return {
258
+ status: 400,
259
+ data: {
260
+ error: `overview: unknown action "${action}" (expected one of ${OVERVIEW_ACTIONS.join(", ")})`,
261
+ },
262
+ };
263
+ }
264
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The three gates every `overview` request passes before any data is read.
3
+ *
4
+ * Pure and dependency-light on purpose: this is the whole security boundary of
5
+ * the extension, and it must be testable without a host, a container or a
6
+ * network. `index.ts` calls it once, above the action switch, so a new action
7
+ * is structurally incapable of reaching the database before it.
8
+ */
9
+
10
+ import { isGlobalAdmin } from "mercury-agent/core/global-admin";
11
+ import type { MercuryExtensionContext } from "mercury-agent/extensions/types";
12
+
13
+ /** Env var naming the one space allowed to read the others. Host-only. */
14
+ export const MANAGEMENT_SPACE_ENV = "MERCURY_MANAGEMENT_SPACE_ID";
15
+
16
+ /** The slice of the extension context the gate needs. */
17
+ export type GateContext = Pick<
18
+ MercuryExtensionContext,
19
+ "db" | "config" | "log"
20
+ >;
21
+
22
+ export interface Denial {
23
+ status: 403;
24
+ data: { error: string };
25
+ }
26
+
27
+ /**
28
+ * Returns `null` when the caller may read other spaces, or a `403` denial.
29
+ *
30
+ * Note what this function does NOT rely on: the `overview` permission checked
31
+ * by the capability route is not a scope. `admin` is granted every registered
32
+ * permission in its own space, so every space admin in the deployment passes
33
+ * `checkPerm("overview")`. These three checks are the only real gates.
34
+ */
35
+ export function checkOverviewAccess(opts: {
36
+ callerId: string;
37
+ spaceId: string;
38
+ managementSpaceId: string | undefined;
39
+ ctx: GateContext;
40
+ }): Denial | null {
41
+ const { callerId, spaceId, managementSpaceId, ctx } = opts;
42
+
43
+ // Gate 1 — absence denies. An unconfigured deployment grants nothing; there
44
+ // is no "unset means allow" branch and no default value anywhere.
45
+ const configured = managementSpaceId?.trim() ?? "";
46
+ if (configured === "") {
47
+ ctx.log.warn("overview denied: no management space configured", {
48
+ callerId,
49
+ spaceId,
50
+ env: MANAGEMENT_SPACE_ENV,
51
+ });
52
+ return {
53
+ status: 403,
54
+ data: { error: "overview: no management space configured" },
55
+ };
56
+ }
57
+
58
+ // Gate 2 — exact string equality. Space ids are canonical already; the loose
59
+ // matching in matchesConfiguredId exists for user ids, not for this.
60
+ if (spaceId !== configured) {
61
+ ctx.log.warn("overview denied: caller is not in the management space", {
62
+ callerId,
63
+ spaceId,
64
+ });
65
+ return {
66
+ status: 403,
67
+ data: { error: "overview: not the management space" },
68
+ };
69
+ }
70
+
71
+ // Gate 3 — global admin, not space admin. A promoted admin of the management
72
+ // space would otherwise inherit read access to every space in the deployment.
73
+ if (!isGlobalAdmin(callerId, ctx.config, ctx.db)) {
74
+ ctx.log.warn("overview denied: caller is not a global admin", {
75
+ callerId,
76
+ spaceId,
77
+ });
78
+ return { status: 403, data: { error: "overview: requires global admin" } };
79
+ }
80
+
81
+ return null;
82
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * overview — a read-only cross-space window for ONE designated space.
3
+ *
4
+ * Mercury scopes every agent surface to a single space: `mrctl` reads and
5
+ * writes only the container's own space. This extension opens exactly one door,
6
+ * in one direction: from the space named by `MERCURY_MANAGEMENT_SPACE_ID`, a
7
+ * global admin can *read* the other spaces — list them, pull recent messages,
8
+ * search, and inspect tasks and roles. Nothing here writes to another space,
9
+ * and no other space gains anything at all.
10
+ *
11
+ * The handler runs host-side (see `src/core/routes/capability.ts`), so the
12
+ * agent container never receives another space's data except as the JSON it
13
+ * asked for, and never learns which space is the management space.
14
+ *
15
+ * Install deliberately — this is not a builtin:
16
+ * mercury add ./examples/extensions/overview
17
+ * MERCURY_MANAGEMENT_SPACE_ID=<space id>
18
+ */
19
+
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import type {
24
+ CapabilityRequest,
25
+ MercuryExtensionAPI,
26
+ MercuryExtensionContext,
27
+ } from "mercury-agent/extensions/types";
28
+ import { runOverviewAction } from "./actions.js";
29
+ import { checkOverviewAccess, MANAGEMENT_SPACE_ENV } from "./gate.js";
30
+
31
+ const EXT_DIR = dirname(fileURLToPath(import.meta.url));
32
+ const SKILL_SOURCE = join(EXT_DIR, "skill", "SKILL.md");
33
+
34
+ /** Read at call time, never cached: an operator may change it and restart. */
35
+ const managementSpaceId = (): string | undefined =>
36
+ process.env[MANAGEMENT_SPACE_ENV];
37
+
38
+ /**
39
+ * Install the skill into the management space's own workspace.
40
+ *
41
+ * NOT `mercury.skill()`: that copies into `.mercury/global/skills/`, which is
42
+ * mounted into *every* container — every space's agent would then be told about
43
+ * a capability only one of them can use.
44
+ *
45
+ * `workspace_init` fires on every container spawn (not once at space creation),
46
+ * so a management space that predates the install picks the skill up on its
47
+ * next turn. That also makes idempotency mandatory: write only when the content
48
+ * differs, so a space's workspace is not churned on every message.
49
+ */
50
+ function installSpaceSkill(
51
+ workspace: string,
52
+ log: MercuryExtensionContext["log"],
53
+ ): void {
54
+ const targetDir = join(workspace, ".pi", "skills", "overview");
55
+ const target = join(targetDir, "SKILL.md");
56
+
57
+ const desired = readFileSync(SKILL_SOURCE, "utf8");
58
+ if (existsSync(target) && readFileSync(target, "utf8") === desired) return;
59
+
60
+ mkdirSync(targetDir, { recursive: true });
61
+ writeFileSync(target, desired, "utf8");
62
+ log.info("overview: installed space-local skill", { target });
63
+ }
64
+
65
+ export default function (mercury: MercuryExtensionAPI) {
66
+ // defaultRoles is empty on purpose. It keeps members out, and nothing more:
67
+ // `admin` is granted every registered permission in its own space, so this
68
+ // is not a scope. The gates in gate.ts are the real boundary.
69
+ mercury.permission({ defaultRoles: [] });
70
+
71
+ // hostOnly: the management space id must never enter a container.
72
+ mercury.env({ from: MANAGEMENT_SPACE_ENV, hostOnly: true });
73
+
74
+ mercury.on("startup", async (_event, ctx) => {
75
+ const configured = managementSpaceId()?.trim() ?? "";
76
+ if (configured === "") {
77
+ ctx.log.info(
78
+ `overview: ${MANAGEMENT_SPACE_ENV} is not set — every cross-space read is denied`,
79
+ );
80
+ return undefined;
81
+ }
82
+ if (ctx.db.getSpace(configured) === null) {
83
+ // Warn, never throw: the space may simply not exist yet.
84
+ ctx.log.warn(
85
+ `overview: ${MANAGEMENT_SPACE_ENV} names a space that does not exist`,
86
+ { spaceId: configured },
87
+ );
88
+ }
89
+ return undefined;
90
+ });
91
+
92
+ mercury.on("workspace_init", async (event, ctx) => {
93
+ const configured = managementSpaceId()?.trim() ?? "";
94
+ if (configured !== "" && event.spaceId === configured) {
95
+ installSpaceSkill(event.workspace, ctx.log);
96
+ }
97
+ return undefined;
98
+ });
99
+
100
+ mercury.capability(
101
+ "overview",
102
+ async (req: CapabilityRequest, ctx: MercuryExtensionContext) => {
103
+ // One gate block, above the dispatch: an action added later cannot reach
104
+ // the database without passing it.
105
+ const denied = checkOverviewAccess({
106
+ callerId: req.callerId,
107
+ spaceId: req.spaceId,
108
+ managementSpaceId: managementSpaceId(),
109
+ ctx,
110
+ });
111
+ if (denied) return denied;
112
+
113
+ const body =
114
+ req.body !== null &&
115
+ typeof req.body === "object" &&
116
+ !Array.isArray(req.body)
117
+ ? (req.body as Record<string, unknown>)
118
+ : null;
119
+
120
+ return runOverviewAction(req.action, body, ctx.db);
121
+ },
122
+ );
123
+ }
@@ -0,0 +1,80 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # Cross-space overview
7
+
8
+ This space is the deployment's **management space**. From here you can *read*
9
+ the other spaces through the host-side `overview` capability.
10
+
11
+ Read-only, always. There is no action that posts, configures, or changes
12
+ anything in another space, and there is no way to make one from here. If you are
13
+ asked to send a message or change a setting in another space, say that it is not
14
+ possible from this space.
15
+
16
+ The capability may be inert — it answers `403` when this deployment has not
17
+ designated a management space, or when the caller is not a global admin. That is
18
+ configuration, not a bug: report the error and stop.
19
+
20
+ ## Usage
21
+
22
+ ```bash
23
+ mrctl capability overview <action> '<json body>'
24
+ ```
25
+
26
+ ### `list` — every space, with activity
27
+
28
+ ```bash
29
+ mrctl capability overview list
30
+ ```
31
+
32
+ Returns `id`, `name`, `tags`, `lastActivity`, `messageCount`,
33
+ `conversationCount` and `platforms` per space, most recently active first.
34
+ Start here: the other actions need a space `id` from this list.
35
+
36
+ ### `recent` — a space's latest messages
37
+
38
+ ```bash
39
+ mrctl capability overview recent '{"spaceId":"football-friends","limit":30}'
40
+ ```
41
+
42
+ Newest first. `limit` defaults to 20, maximum 100.
43
+
44
+ ### `search` — find where something was discussed
45
+
46
+ ```bash
47
+ mrctl capability overview search '{"q":"invoice"}'
48
+ mrctl capability overview search '{"q":"invoice","spaceId":"acme-client","limit":50}'
49
+ ```
50
+
51
+ Without `spaceId` it searches every space. Matching is a plain case-insensitive
52
+ substring — not a search engine, so prefer a distinctive word over a phrase.
53
+
54
+ ### `tasks` — scheduled tasks
55
+
56
+ ```bash
57
+ mrctl capability overview tasks
58
+ mrctl capability overview tasks '{"spaceId":"football-friends","limit":50}'
59
+ ```
60
+
61
+ Without `spaceId`, every space's tasks. `limit` behaves as it does for `recent`,
62
+ and a task's `prompt` is truncated the same way a message body is.
63
+
64
+ ### `roles` — who holds which role
65
+
66
+ ```bash
67
+ mrctl capability overview roles '{"spaceId":"football-friends"}'
68
+ ```
69
+
70
+ ## Things to tell the user
71
+
72
+ - **Long text is truncated at 500 characters.** Message bodies, and a task's
73
+ `prompt` and `lastError`. A result with `"truncated": true` is a fragment:
74
+ never present one as the whole text, and say it is cut when it matters.
75
+ - **What you pull lands in this conversation.** Another space's content becomes
76
+ part of this space's history and is sent to the model on later turns. Pull what
77
+ the question needs, not whole conversations.
78
+ - **Errors are plain.** `404 unknown space` means the id is wrong — re-run
79
+ `list`. `400` means a bad argument. `403` means this space or this caller is
80
+ not authorised; do not retry it with different arguments.
@@ -51,7 +51,41 @@ extension_config: {}
51
51
  # context.window_size: "10" # user turns; scheduled runs are user turns too
52
52
  # longview.enabled: "true"
53
53
  # longview.threshold_chars: "2000"
54
+ #
55
+ # # One voice: this AGENTS.md is the only persona the model hears — the host
56
+ # # leaves the owner's global Bot Character out of the prompt and does not
57
+ # # mount the global AGENTS.md for this space. Set it when the space owns its
58
+ # # whole standard (a reporter, a product bot); leave it unset for a space that
59
+ # # should still sound like the deployment's assistant.
60
+ # persona.exclusive: "true"
61
+ #
62
+ # # Member permissions, audited against the room. Start from the live value
63
+ # # (`mrctl config get role.member.permissions`) and REMOVE, never copy a DM
64
+ # # space's row: `tasks.delete` lets a member delete the daily task,
65
+ # # `prefs.set` turns every "from now on…" into a real preference write.
66
+ # role.member.permissions: "prompt,prefs.get,media.receive,media.send,model.list"
67
+ #
68
+ # # Scheduled-run budget. Every run — including one that answers NO_UPDATE —
69
+ # # pays for the full prompt stack (this AGENTS.md, MEMORY.md, the injected
70
+ # # notes, the digest) before it says anything, so "empty runs are cheap" is
71
+ # # false. Cap the number of runs, not just their length, and read the cost
72
+ # # per run from the task_runs ledger after the first day.
73
+ # feed-watch.max_per_hour: "1"
74
+ # feed-watch.batch_minutes: "10"
54
75
 
55
76
  # Stray items in the space workspace that apply deletes (and logs). Relative
56
77
  # to the space directory; may not escape it.
57
78
  workspace_remove: []
79
+
80
+ # Files the profile writes ONCE and then never touches — memory the bot (and
81
+ # napkin's nightly distillation) will go on writing. `apply` writes a seed only
82
+ # when the target is absent; `check` reports a missing seed, never a changed
83
+ # one — a note the bot rewrote is the feature working. To push a corrected
84
+ # seed, delete the live file and re-apply, accepting the loss of what the bot
85
+ # wrote into it. A path may not appear in both workspace_seed and
86
+ # workspace_remove. Seeds are thin where the evidence is thin: one sourced
87
+ # fact and an explicit "nothing verified yet" line beat a page from memory.
88
+ workspace_seed: {}
89
+ # MEMORY.md: ./seed/MEMORY.md # standing facts, dated decisions, the notebook index — NOT rules (AGENTS.md owns those)
90
+ # knowledge/episodes/<topic>.md: ./seed/episodes/<topic>.md # one note per topic, napkin's shape: frontmatter keywords, `## Current State`, `## History`
91
+ # knowledge/.napkin/distill.md: ./seed/napkin-distill.md # per-space addendum to napkin's distillation prompt (≤ 4 KB)
@@ -39,7 +39,8 @@
39
39
  או כל גרסה של זה. אם אין לך מה להוסיף, אל תוסיף.
40
40
  - ייחוס ודרגות ודאות חלים על סריקה, על כתבה ועל תשובה עובדתית. הם לא חלים על
41
41
  התבדחות. אל תדרוש מקור לבדיחה ואל תצרף מקור לתשובה שהיא בדיחה.
42
- - בצ'אט מותר אימוג'י אחד אם הוא באמת מצחיק. בסריקה ובכתבה: אפס.
42
+ - בצ'אט מותר אימוג'י אחד אם הוא באמת מצחיק. בכתבה: אפס. בסריקה: רק אימוג'י
43
+ המועדון הקבוע שבסעיף "פורמט", ושום אימוג'י אחר.
43
44
 
44
45
  מתערב רק בהתעללות אמיתית: הטרדה ממוקדת בחבר, ספאם, או ניסיון לחלץ ממך
45
46
  סודות. קללות וזלזול הדדי בין חברים הם לא זה, ולא צריך להעיר עליהם.
@@ -139,18 +140,31 @@
139
140
 
140
141
  ## טריות
141
142
 
142
- - כל פריט נושא את תאריך הפרסום שקראת במקור.
143
+ - כל פריט נושא את תאריך הפרסום שקראת במקור, מתועד ומאומת עם שנה מלאה
144
+ (גם כשמה שמוצג למשתמש הוא יום וחודש בלבד — ראו "פורמט").
143
145
  - פריט בלי תאריך, או מחוץ לחלון, נמחק. החלון: בסריקה, מאז העדכון האחרון;
144
146
  בכתבה, 24 שעות.
145
147
  - לפני שמדווחים על העברה כחיה, לבדוק באיזה מועדון השחקן נמצא עכשיו.
146
148
 
147
149
  ## פורמט
148
150
 
149
- - עברית. בלי תגיות HTML, בלי טבלאות, ובלי `**`. אימוג'ים: אפס בסריקה
150
- ובכתבה, ובצ'אט לפי הסעיף "סגנון".
151
- - סריקה: שורה אחת לכל פריט. פריט הוא משפט, לא פסקה. בלי כותרת, בלי פתיח
151
+ - עברית. בלי תגיות HTML, בלי טבלאות, ובלי `**`. אימוג'ים: אפס בכתבה, ובצ'אט
152
+ לפי הסעיף "סגנון". בסריקה: אפס, חוץ מאימוג'י המועדון הקבוע שבטבלה למטה.
153
+ - סריקה: שורה אחת לכל פריט, עד 200 תווים (כולל המקור והתאריך בסוגריים).
154
+ פריט שחורג מהמכסה מקוצר לעובדה המרכזית, לא נחתך באמצע משפט. בלי פתיח
152
155
  ובלי שורת סיכום בסוף. כמה פריטים באותה סריקה זה בסדר גמור — ההודעה גדלה
153
- במספר הפריטים, אף פעם לא באורך של פריט.
156
+ במספר הפריטים, אף פעם לא באורך של פריט. בין פריט לפריט: שורה ריקה, כדי
157
+ שההודעה תיקרא בנוחות בטלפון.
158
+ - תאריך בסוגריים: יום וחודש בלבד (למשל 25/08), בלי שנה. השנה עדיין חובה
159
+ לוודא במקור לפני הכתיבה — היא פשוט לא מוצגת.
160
+ - אימוג'י מועדון קבוע, בתחילת השורה, רק בסריקה (לא בכתבה, לא בצ'אט):
161
+ מנצ'סטר יונייטד 😈, ברצלונה 🔵, ריאל מדריד ⚪, הפועל תל אביב 🔴,
162
+ בית"ר ירושלים 💛, מכבי חיפה 🟢. פריט שלא נוגע לאף אחד מהם (למשל סאגת
163
+ העברות כללית, או ישראלי במועדון אחר) בלי אימוג'י בכלל — לא ממציאים אחד.
164
+ - קיבוץ: כשכמה פריטים באותה סריקה נוגעים לאותו מועדון, הם נכתבים ברצף עם
165
+ שורת כותרת אחת לשם המועדון (בלי אימוג'י כפול על כל שורה בתוך הקבוצה,
166
+ האימוג'י יושב פעם אחת ליד שם הכותרת). מועדון עם פריט בודד לא מקבל כותרת,
167
+ רק את שורת האימוג'י-פריט הרגילה.
154
168
  - המבחן לאורך: מישהו קורא את זה בטלפון, בקבוצת חברים, בין הודעות של אנשים.
155
169
  הודעה שצריך לגלול בה כדי לסיים אותה היא ארוכה מדי, וזה נכון גם כשכל פריט
156
170
  בה מוצדק. עומק הולך לכתבה. סריקה מוסרת מה קרה, לא מסבירה למה זה חשוב.
@@ -174,6 +188,10 @@
174
188
  קבוצות בסריקה אחת זה שני מערכים, לא פסקה של עשרים ושניים שמות.
175
189
  - בלי הודעות מערכת: בלי "בדקתי", בלי "סיימתי", בלי לציין שזו משימה מתוזמנת,
176
190
  ובלי לתאר מה עשית.
191
+ - ההודעה שנשלחת היא רק התוכן הסופי בעברית. שום שריד של חשיבה פנימית, תכנון
192
+ או הערה באנגלית (למשל "Good, already up to date. Now the chat reply.")
193
+ לא נכנס להודעה בשום צורה, גם לא כשורה ראשונה. אם יש צורך לחשוב בקול, זה
194
+ קורה מחוץ להודעה שנשלחת, לא בתוכה.
177
195
 
178
196
  ## שתיקה
179
197
 
@@ -17,7 +17,7 @@ One rule lives in exactly one layer. That is the whole point; a rule repeated
17
17
  | Standing facts | `seed/MEMORY.md` → space `MEMORY.md` | What the group decided and when, open commitments, verified spellings. **Seeded once, then the bot's** | Injected verbatim every turn |
18
18
  | Topic notes | `seed/episodes/*.md` → `knowledge/episodes/` | One note per club, competition and saga: `## Current State` of dated, sourced facts. **Seeded once, then the bot's and napkin's** | Injected when the message matches the note's keywords |
19
19
  | Distiller guidance | `seed/napkin-distill.md` → `knowledge/.napkin/distill.md` | The entity schema and keyword rules napkin uses for *this* space. **Seeded once** | Appended to napkin's distillation prompt on each nightly run |
20
- | Extension config | `config.yaml` → `extension_config` | `longview.*`, `feed-watch.*` except `enabled`, `role.member.permissions` | Per run |
20
+ | Extension config | `config.yaml` → `extension_config` | `longview.*`, `feed-watch.*` except `enabled`, `role.member.permissions`, `persona.exclusive` (this `AGENTS.md` is the only persona: the global character and the global `AGENTS.md` stay out of this space's prompt) | Per run |
21
21
  | Behaviour preferences | *not in this folder* | `language`, `timezone` | Per run |
22
22
 
23
23
  The two behaviour preferences are deliberately out of scope: they are not
@@ -122,6 +122,14 @@ Change the file here, commit, then `apply`. Never edit the live database or the
122
122
  live `AGENTS.md` directly — `check` will call it drift, and the next `apply`
123
123
  will overwrite it.
124
124
 
125
+ **If the live file was edited anyway** (it happened on 2026-08-27, four days of
126
+ tuning the scan format on the box), do not `apply` over it. Adopt it: copy the
127
+ live file into this folder, `git diff` to read exactly what changed, read the
128
+ *whole* file for the contradiction the edit introduced (the 08-27 edit allowed
129
+ club emoji in `## פורמט` while `## סגנון` still said zero), fix that here,
130
+ commit, then `apply` — which now writes back only the reconciliation. Decision
131
+ record: [D-020](../../../docs/goals/football-reporter-profile/decisions.md).
132
+
125
133
  `tasks/scan.md` and task 28 are **gone as of M2.1**: `feed-watch` polls the
126
134
  feeds on the host and opens a verify run only when something matched, so there
127
135
  is no blind scan left to write a prompt for. `tasks/daily-article.md` now