mercury-agent 0.5.19 → 0.6.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.
- package/README.md +24 -0
- package/docs/authoring-profiles.md +1 -1
- package/docs/context-architecture.md +22 -5
- package/package.json +1 -1
- package/resources/templates/AGENTS.md +19 -0
- package/src/agent/container-entry.ts +8 -2
- package/src/agent/container-runner.ts +2 -1
- package/src/cli/mrctl.ts +42 -1
- package/src/core/api.ts +2 -0
- package/src/core/global-admin.ts +36 -0
- package/src/core/profiles.ts +16 -11
- package/src/core/routes/broadcast.ts +2 -28
- package/src/core/routes/character.ts +69 -0
- package/src/core/routes/dashboard.ts +8 -0
- package/src/core/routes/index.ts +1 -0
- package/src/core/runtime.ts +15 -2
- package/src/main.ts +2 -5
- package/src/storage/db.ts +43 -0
package/README.md
CHANGED
|
@@ -460,6 +460,30 @@ curl -X POST http://localhost:8787/api/broadcast \
|
|
|
460
460
|
|
|
461
461
|
Returns `{ total, delivered, failed, errors }`. Only global admins (`admins` or `dm_auto_space.admin_ids`) may broadcast. Messages are sent as literal text — no LLM processing.
|
|
462
462
|
|
|
463
|
+
#### Bot Character
|
|
464
|
+
|
|
465
|
+
Global admins can set a bot-wide character (tone, greeting style, personality) that applies to all spaces — including every DM auto-space. Stored in the database; survives profile deploys.
|
|
466
|
+
|
|
467
|
+
The recommended flow is conversational: tell the bot to change its character, it drafts the update, confirms with you, then saves via `mrctl character set --file <path>`. Programmatic access:
|
|
468
|
+
|
|
469
|
+
```bash
|
|
470
|
+
# Set
|
|
471
|
+
curl -X PUT http://localhost:8787/api/character \
|
|
472
|
+
-H "Authorization: Bearer $API_SECRET" \
|
|
473
|
+
-H "X-Mercury-Caller: <admin-id>" \
|
|
474
|
+
-H "X-Mercury-Space: main" \
|
|
475
|
+
-H "Content-Type: application/json" \
|
|
476
|
+
-d '{"text": "Be warm and professional. Greet with שלום וברכה."}'
|
|
477
|
+
|
|
478
|
+
# Get current character
|
|
479
|
+
curl http://localhost:8787/api/character ...
|
|
480
|
+
|
|
481
|
+
# Clear
|
|
482
|
+
curl -X DELETE http://localhost:8787/api/character ...
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Only global admins may set the character (same gate as broadcast). Per-space tone adjustments use the existing per-space `system_prompt` in dashboard settings.
|
|
486
|
+
|
|
463
487
|
### Per-space Config
|
|
464
488
|
|
|
465
489
|
Conversations are discovered from incoming traffic. Unlinked conversations stay idle until you attach them to a space via `mercury link <conversation-id> <space-id>` or the dashboard.
|
|
@@ -44,7 +44,7 @@ env:
|
|
|
44
44
|
default: "09:00-18:00"
|
|
45
45
|
|
|
46
46
|
# Project-wide agent persona, injected into every container's system prompt.
|
|
47
|
-
|
|
47
|
+
profile_prompt: |
|
|
48
48
|
You are a meeting room booking assistant. Help each user book, view, and
|
|
49
49
|
cancel ONLY their own reservations. Never reveal other users' bookings.
|
|
50
50
|
|
|
@@ -45,15 +45,31 @@ Every request runs with `--no-session` (no pi session file). Continuity across r
|
|
|
45
45
|
|
|
46
46
|
The session boundary (`chat_state.min_message_id`) excludes messages older than the last `compact` call from the sliding window. Run `mrctl compact` to reset the boundary and start fresh.
|
|
47
47
|
|
|
48
|
-
###
|
|
48
|
+
### System prompt layering (broadest → most specific)
|
|
49
49
|
|
|
50
50
|
```
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
</system>
|
|
51
|
+
1. pi built-in prompt (Claude Code identity, tool definitions)
|
|
52
|
+
— or Mercury's override when OVERRIDE_PI_SYSTEM_PROMPT is set
|
|
54
53
|
|
|
55
|
-
|
|
54
|
+
2. Mercury platform additions (buildMercuryAdditions)
|
|
55
|
+
— identity, inbox/outbox, permissions, moderation, capabilities, memory guidance
|
|
56
|
+
|
|
57
|
+
3. MERCURY_EXT_SYSTEM_PROMPT (assembled on host, passed as env var):
|
|
58
|
+
a. Extension before_container fragments
|
|
59
|
+
b. profile_prompt (capability playbook — deploy-owned)
|
|
60
|
+
c. Bot Character (global voice — owner-owned, via project_config DB)
|
|
61
|
+
d. Per-space system_prompt (space refinement — space-admin-owned)
|
|
62
|
+
|
|
63
|
+
4. AGENTS.md files (pi auto-discovers from PI_CODING_AGENT_DIR + workspace cwd)
|
|
64
|
+
— global (:ro) + per-space
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### User prompt structure (inside container)
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
<caller id="…" name="…" role="…" space="…" />
|
|
56
71
|
<episodic_memory>…</episodic_memory> ← MEMORY.md (if present)
|
|
72
|
+
<active_episodes>…</active_episodes> ← relevance-scored episode snippets
|
|
57
73
|
<history> ← sliding window from DB
|
|
58
74
|
<turn timestamp="…">
|
|
59
75
|
<user>…</user>
|
|
@@ -64,6 +80,7 @@ The session boundary (`chat_state.min_message_id`) excludes messages older than
|
|
|
64
80
|
<ambient_messages>…</ambient_messages>
|
|
65
81
|
<preferences>…</preferences>
|
|
66
82
|
<attachments>…</attachments>
|
|
83
|
+
<reply_anchor>…</reply_anchor> ← only on replies
|
|
67
84
|
|
|
68
85
|
[user prompt text]
|
|
69
86
|
```
|
package/package.json
CHANGED
|
@@ -155,3 +155,22 @@ You can delegate tasks to specialized sub-agents:
|
|
|
155
155
|
|
|
156
156
|
### Chained Workflow
|
|
157
157
|
"Use a chain: first have explore find the code, then have worker implement the fix"
|
|
158
|
+
|
|
159
|
+
## Character
|
|
160
|
+
|
|
161
|
+
The system prompt may include a "Bot Character" section — the owner-defined voice for
|
|
162
|
+
all conversations. Always follow it.
|
|
163
|
+
|
|
164
|
+
When a user asks you to change your personality, tone, greeting style, or character:
|
|
165
|
+
1. Read the current character: `mrctl character get`
|
|
166
|
+
2. Draft the FULL updated character text — merge their request with the existing
|
|
167
|
+
character into one coherent text. Do not append contradictory fragments.
|
|
168
|
+
3. Show the draft and ask for explicit confirmation.
|
|
169
|
+
4. On confirmation, write the draft to a temp file and run:
|
|
170
|
+
`mrctl character set --file <path>`
|
|
171
|
+
5. Relay the result. If the API returns 403, tell the user that only the bot owner
|
|
172
|
+
can change the global character.
|
|
173
|
+
|
|
174
|
+
Only global admins (configured on the host) can change the character — the API
|
|
175
|
+
enforces this. Per-space tone adjustments go in this space's `system_prompt`,
|
|
176
|
+
which is set from the dashboard (Spaces settings).
|
|
@@ -266,7 +266,7 @@ function buildCapabilitySection(
|
|
|
266
266
|
* lines plus all Mercury platform content. This is appended after pi's own default system prompt.
|
|
267
267
|
*
|
|
268
268
|
* In override mode (skipIdentity=true): omits the "You are Claude Code" preamble since the outer
|
|
269
|
-
* wrapper in buildSystemPrompt provides the identity.
|
|
269
|
+
* wrapper in buildSystemPrompt provides the identity. Bot identity (from BOT_USERNAME env) and all
|
|
270
270
|
* platform content (inbox/outbox, docs reference, permissions, moderation) are retained.
|
|
271
271
|
*/
|
|
272
272
|
function buildMercuryAdditions(
|
|
@@ -279,9 +279,12 @@ function buildMercuryAdditions(
|
|
|
279
279
|
const claudeCodePreamble = `You are Claude Code, Anthropic's official CLI for Claude.
|
|
280
280
|
Prioritize practical outputs and explicit assumptions.`;
|
|
281
281
|
|
|
282
|
+
const raw = process.env.BOT_USERNAME || "Mercury";
|
|
283
|
+
const botName = raw.charAt(0).toUpperCase() + raw.slice(1);
|
|
284
|
+
|
|
282
285
|
const mercuryPlatform = `Files received from users (images, documents, voice notes) are saved to the \`inbox/\` directory in the current workspace. To send files back with your reply, write them to the \`outbox/\` directory — any files created or modified there during this run will be automatically attached to your response.
|
|
283
286
|
|
|
284
|
-
You are Mercury
|
|
287
|
+
You are ${botName}. You run on the Mercury platform (https://github.com/Avishai-Tsabari/mercury). When users ask about the platform — what it can do, how to configure it, scheduling, permissions, extensions, or anything about Mercury — you MUST read from \`/docs/mercury/\` before answering. Start with \`/docs/mercury/README.md\` for an overview, then check \`/docs/mercury/docs/\` for detailed guides.
|
|
285
288
|
|
|
286
289
|
## Permissions & Security
|
|
287
290
|
Each run is triggered by a specific caller with a role (admin or member). The caller's identity and role are provided in the user prompt as a <caller /> tag.
|
|
@@ -707,6 +710,9 @@ function buildBwrapArgs(
|
|
|
707
710
|
"--bind",
|
|
708
711
|
"/home/mercury",
|
|
709
712
|
"/home/mercury",
|
|
713
|
+
"--ro-bind",
|
|
714
|
+
"/home/mercury/.pi/agent",
|
|
715
|
+
"/home/mercury/.pi/agent",
|
|
710
716
|
"--proc",
|
|
711
717
|
"/proc",
|
|
712
718
|
"--dev",
|
|
@@ -871,6 +871,7 @@ export class AgentContainerRunner {
|
|
|
871
871
|
key: "OVERRIDE_PI_SYSTEM_PROMPT",
|
|
872
872
|
value: this.config.overridePiSystemPrompt ? "true" : "false",
|
|
873
873
|
},
|
|
874
|
+
{ key: "BOT_USERNAME", value: this.config.botUsername },
|
|
874
875
|
].filter((x): x is { key: string; value: string } => Boolean(x.value));
|
|
875
876
|
|
|
876
877
|
const containerName = this.generateContainerName();
|
|
@@ -965,7 +966,7 @@ export class AgentContainerRunner {
|
|
|
965
966
|
"-v",
|
|
966
967
|
`${innerSpaceDir}:/spaces/${input.spaceId}`,
|
|
967
968
|
"-v",
|
|
968
|
-
`${innerGlobalDir}:/home/mercury/.pi/agent`,
|
|
969
|
+
`${innerGlobalDir}:/home/mercury/.pi/agent:ro`,
|
|
969
970
|
"-v",
|
|
970
971
|
`${readmePath}:/docs/mercury/README.md:ro`,
|
|
971
972
|
"-v",
|
package/src/cli/mrctl.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import type { StorageResponse } from "../core/routes/storage.js";
|
|
6
6
|
import { buildRequestInit } from "./mrctl-http.js";
|
|
@@ -96,6 +96,7 @@ Built-in commands:
|
|
|
96
96
|
mrctl compact
|
|
97
97
|
mrctl clear
|
|
98
98
|
mrctl recall <search text> [--limit N]
|
|
99
|
+
mrctl character get|set|clear
|
|
99
100
|
mrctl capability <name> <action> [json-body]
|
|
100
101
|
mrctl tts synthesize --text "Hello" --out outbox/reply.mp3 \\
|
|
101
102
|
[--language auto|he-IL|en-US] [--provider google|azure|auto]
|
|
@@ -133,6 +134,46 @@ async function main() {
|
|
|
133
134
|
break;
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
case "character": {
|
|
138
|
+
if (!sub) usage();
|
|
139
|
+
switch (sub) {
|
|
140
|
+
case "get": {
|
|
141
|
+
const data = (await api("GET", "/api/character")) as {
|
|
142
|
+
character: string | null;
|
|
143
|
+
};
|
|
144
|
+
if (data.character === null) {
|
|
145
|
+
process.stdout.write("(not set)\n");
|
|
146
|
+
} else {
|
|
147
|
+
process.stdout.write(`${data.character}\n`);
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case "set": {
|
|
152
|
+
const filePath = parseFlag(args, "--file");
|
|
153
|
+
let text: string;
|
|
154
|
+
if (filePath) {
|
|
155
|
+
text = readFileSync(filePath, "utf-8");
|
|
156
|
+
} else {
|
|
157
|
+
const fileIdx = args.indexOf("--file");
|
|
158
|
+
const words = args.slice(2).filter((_, i) => {
|
|
159
|
+
const absIdx = i + 2;
|
|
160
|
+
return absIdx !== fileIdx && absIdx !== fileIdx + 1;
|
|
161
|
+
});
|
|
162
|
+
if (words.length === 0) fatal("Missing text or --file <path>");
|
|
163
|
+
text = words.join(" ");
|
|
164
|
+
}
|
|
165
|
+
print(await api("PUT", "/api/character", { text }));
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case "clear":
|
|
169
|
+
print(await api("DELETE", "/api/character"));
|
|
170
|
+
break;
|
|
171
|
+
default:
|
|
172
|
+
fatal(`Unknown character subcommand: ${sub}`);
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
|
|
136
177
|
case "capability": {
|
|
137
178
|
// mrctl capability <name> <action> [json-body]
|
|
138
179
|
const name = requireArg(args, 1, "capability name");
|
package/src/core/api.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveRole } from "./permissions.js";
|
|
|
6
6
|
import {
|
|
7
7
|
broadcast,
|
|
8
8
|
capability,
|
|
9
|
+
character,
|
|
9
10
|
config,
|
|
10
11
|
connections,
|
|
11
12
|
control,
|
|
@@ -113,6 +114,7 @@ export function createApiApp(apiCtx: ApiContext): Hono<Env> {
|
|
|
113
114
|
app.route("/tts", tts);
|
|
114
115
|
app.route("/capability", capability);
|
|
115
116
|
app.route("/broadcast", broadcast);
|
|
117
|
+
app.route("/character", character);
|
|
116
118
|
|
|
117
119
|
// ─── Fallback ───────────────────────────────────────────────────────────
|
|
118
120
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if a caller is a global admin (configured in mercury.yaml / env).
|
|
3
|
+
* Global admins are identified by `config.admins` and `config.dmAutoSpaceAdminIds`.
|
|
4
|
+
* Platform-specific ID prefixes, + signs, and @domain suffixes are normalized.
|
|
5
|
+
*/
|
|
6
|
+
export function isGlobalAdmin(
|
|
7
|
+
callerId: string,
|
|
8
|
+
config: { admins?: string; dmAutoSpaceAdminIds?: string },
|
|
9
|
+
): boolean {
|
|
10
|
+
const globalAdmins = [
|
|
11
|
+
...(config.admins
|
|
12
|
+
? config.admins
|
|
13
|
+
.split(",")
|
|
14
|
+
.map((s) => s.trim())
|
|
15
|
+
.filter(Boolean)
|
|
16
|
+
: []),
|
|
17
|
+
...(config.dmAutoSpaceAdminIds
|
|
18
|
+
? config.dmAutoSpaceAdminIds
|
|
19
|
+
.split(",")
|
|
20
|
+
.map((s) => s.trim())
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
: []),
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const normalize = (s: string) =>
|
|
26
|
+
s
|
|
27
|
+
.replace(/^[^:]+:/, "")
|
|
28
|
+
.replace(/^[+]+/, "")
|
|
29
|
+
.replace(/@.*$/, "");
|
|
30
|
+
|
|
31
|
+
const callerNormalized = normalize(callerId);
|
|
32
|
+
|
|
33
|
+
return globalAdmins.some(
|
|
34
|
+
(id) => id === callerId || normalize(id) === callerNormalized,
|
|
35
|
+
);
|
|
36
|
+
}
|
package/src/core/profiles.ts
CHANGED
|
@@ -68,7 +68,7 @@ export const profileSchema = z.object({
|
|
|
68
68
|
*/
|
|
69
69
|
member_permissions: z.array(z.string()).optional(),
|
|
70
70
|
/** Profile-specific agent persona, injected into the container. */
|
|
71
|
-
|
|
71
|
+
profile_prompt: z.string().optional(),
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
export type MercuryProfile = z.infer<typeof profileSchema>;
|
|
@@ -178,7 +178,7 @@ export interface ActiveProfile {
|
|
|
178
178
|
/** Exhaustive member permission set, or null when the profile doesn't scope members. */
|
|
179
179
|
memberPermissions: string[] | null;
|
|
180
180
|
/** Profile agent persona, or null. */
|
|
181
|
-
|
|
181
|
+
profilePrompt: string | null;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
const ACTIVE_PROFILE_FILE = "active-profile.json";
|
|
@@ -191,7 +191,7 @@ export function persistActiveProfile(
|
|
|
191
191
|
const activation: ActiveProfile = {
|
|
192
192
|
name: profile.name,
|
|
193
193
|
memberPermissions: profile.member_permissions ?? null,
|
|
194
|
-
|
|
194
|
+
profilePrompt: profile.profile_prompt ?? null,
|
|
195
195
|
};
|
|
196
196
|
mkdirSync(dataDir, { recursive: true });
|
|
197
197
|
writeFileSync(
|
|
@@ -205,7 +205,12 @@ export function loadActiveProfile(dataDir: string): ActiveProfile | null {
|
|
|
205
205
|
const file = join(dataDir, ACTIVE_PROFILE_FILE);
|
|
206
206
|
if (!existsSync(file)) return null;
|
|
207
207
|
try {
|
|
208
|
-
|
|
208
|
+
const raw = JSON.parse(readFileSync(file, "utf-8"));
|
|
209
|
+
return {
|
|
210
|
+
name: raw.name,
|
|
211
|
+
memberPermissions: raw.memberPermissions ?? null,
|
|
212
|
+
profilePrompt: raw.profilePrompt ?? raw.systemPrompt ?? null,
|
|
213
|
+
};
|
|
209
214
|
} catch {
|
|
210
215
|
return null;
|
|
211
216
|
}
|
|
@@ -216,16 +221,16 @@ export function loadActiveProfile(dataDir: string): ActiveProfile | null {
|
|
|
216
221
|
* every container's system prompt. Held here so the runtime can read it without
|
|
217
222
|
* re-reading the manifest per message.
|
|
218
223
|
*/
|
|
219
|
-
let
|
|
224
|
+
let activeProfilePromptValue: string | null = null;
|
|
220
225
|
|
|
221
|
-
/** Set (or clear, with null) the active profile's
|
|
222
|
-
export function
|
|
223
|
-
|
|
226
|
+
/** Set (or clear, with null) the active profile's prompt. */
|
|
227
|
+
export function setActiveProfilePrompt(prompt: string | null): void {
|
|
228
|
+
activeProfilePromptValue = prompt;
|
|
224
229
|
}
|
|
225
230
|
|
|
226
|
-
/** The active profile's
|
|
227
|
-
export function
|
|
228
|
-
return
|
|
231
|
+
/** The active profile's prompt, or null when no profile scopes it. */
|
|
232
|
+
export function getActiveProfilePrompt(): string | null {
|
|
233
|
+
return activeProfilePromptValue;
|
|
229
234
|
}
|
|
230
235
|
|
|
231
236
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { logger } from "../../logger.js";
|
|
3
3
|
import { type Env, getApiCtx, getAuth } from "../api-types.js";
|
|
4
|
+
import { isGlobalAdmin } from "../global-admin.js";
|
|
4
5
|
|
|
5
6
|
export const broadcast = new Hono<Env>();
|
|
6
7
|
|
|
@@ -12,34 +13,7 @@ broadcast.post("/", async (c) => {
|
|
|
12
13
|
return c.json({ error: "dm_auto_space is not enabled" }, 503);
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
...(config.admins
|
|
17
|
-
? config.admins
|
|
18
|
-
.split(",")
|
|
19
|
-
.map((s) => s.trim())
|
|
20
|
-
.filter(Boolean)
|
|
21
|
-
: []),
|
|
22
|
-
...(config.dmAutoSpaceAdminIds
|
|
23
|
-
? config.dmAutoSpaceAdminIds
|
|
24
|
-
.split(",")
|
|
25
|
-
.map((s) => s.trim())
|
|
26
|
-
.filter(Boolean)
|
|
27
|
-
: []),
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
const normalize = (s: string) =>
|
|
31
|
-
s
|
|
32
|
-
.replace(/^[^:]+:/, "")
|
|
33
|
-
.replace(/^[+]+/, "")
|
|
34
|
-
.replace(/@.*$/, "");
|
|
35
|
-
|
|
36
|
-
const callerNormalized = normalize(callerId);
|
|
37
|
-
|
|
38
|
-
const isAdmin = globalAdmins.some(
|
|
39
|
-
(id) => id === callerId || normalize(id) === callerNormalized,
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
if (!isAdmin) {
|
|
16
|
+
if (!isGlobalAdmin(callerId, config)) {
|
|
43
17
|
logger.warn("Broadcast denied — caller is not a global admin", {
|
|
44
18
|
callerId,
|
|
45
19
|
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { logger } from "../../logger.js";
|
|
3
|
+
import { type Env, getApiCtx, getAuth } from "../api-types.js";
|
|
4
|
+
import { isGlobalAdmin } from "../global-admin.js";
|
|
5
|
+
|
|
6
|
+
const MAX_CHARACTER_LENGTH = 4000;
|
|
7
|
+
|
|
8
|
+
export const character = new Hono<Env>();
|
|
9
|
+
|
|
10
|
+
character.get("/", (c) => {
|
|
11
|
+
const { callerId } = getAuth(c);
|
|
12
|
+
const { config, db } = getApiCtx(c);
|
|
13
|
+
|
|
14
|
+
if (!isGlobalAdmin(callerId, config)) {
|
|
15
|
+
logger.warn("Character get denied — caller is not a global admin", {
|
|
16
|
+
callerId,
|
|
17
|
+
});
|
|
18
|
+
return c.json({ error: "Forbidden: requires global admin" }, 403);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const value = db.getProjectConfig("character");
|
|
22
|
+
const updatedBy = db.getProjectConfigUpdatedBy("character");
|
|
23
|
+
|
|
24
|
+
return c.json({ character: value, updatedBy });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
character.put("/", async (c) => {
|
|
28
|
+
const { callerId } = getAuth(c);
|
|
29
|
+
const { config, db } = getApiCtx(c);
|
|
30
|
+
|
|
31
|
+
if (!isGlobalAdmin(callerId, config)) {
|
|
32
|
+
logger.warn("Character set denied — caller is not a global admin", {
|
|
33
|
+
callerId,
|
|
34
|
+
});
|
|
35
|
+
return c.json({ error: "Forbidden: requires global admin" }, 403);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const body = (await c.req.json().catch(() => ({}))) as { text?: string };
|
|
39
|
+
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
40
|
+
|
|
41
|
+
if (!text) {
|
|
42
|
+
return c.json({ error: "Missing or empty 'text' field" }, 400);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (text.length > MAX_CHARACTER_LENGTH) {
|
|
46
|
+
return c.json(
|
|
47
|
+
{ error: `Text exceeds ${MAX_CHARACTER_LENGTH} character limit` },
|
|
48
|
+
400,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
db.setProjectConfig("character", text, callerId);
|
|
53
|
+
return c.json({ ok: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
character.delete("/", (c) => {
|
|
57
|
+
const { callerId } = getAuth(c);
|
|
58
|
+
const { config, db } = getApiCtx(c);
|
|
59
|
+
|
|
60
|
+
if (!isGlobalAdmin(callerId, config)) {
|
|
61
|
+
logger.warn("Character clear denied — caller is not a global admin", {
|
|
62
|
+
callerId,
|
|
63
|
+
});
|
|
64
|
+
return c.json({ error: "Forbidden: requires global admin" }, 403);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
db.deleteProjectConfig("character");
|
|
68
|
+
return c.json({ ok: true });
|
|
69
|
+
});
|
|
@@ -1030,6 +1030,14 @@ export function createDashboardRoutes(ctx: DashboardContext) {
|
|
|
1030
1030
|
</div>
|
|
1031
1031
|
</div>
|
|
1032
1032
|
|
|
1033
|
+
${raw(
|
|
1034
|
+
(() => {
|
|
1035
|
+
const charText = core.db.getProjectConfig("character");
|
|
1036
|
+
if (!charText) return "";
|
|
1037
|
+
return `<div class="panel"><div class="panel-header">Bot Character</div><div class="panel-body"><pre style="white-space:pre-wrap;word-break:break-word;margin:0;font-family:inherit;font-size:13px;color:var(--fg-muted)">${escapeHtml(charText)}</pre></div></div>`;
|
|
1038
|
+
})(),
|
|
1039
|
+
)}
|
|
1040
|
+
|
|
1033
1041
|
${raw(renderExtensionWidgets())}
|
|
1034
1042
|
`);
|
|
1035
1043
|
});
|
package/src/core/routes/index.ts
CHANGED
package/src/core/runtime.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
} from "../types.js";
|
|
26
26
|
import { formatCategoryHelp, formatHelp } from "./commands.js";
|
|
27
27
|
import { hasPermission, resolveRole } from "./permissions.js";
|
|
28
|
-
import {
|
|
28
|
+
import { getActiveProfilePrompt } from "./profiles.js";
|
|
29
29
|
import { RateLimiter } from "./rate-limiter.js";
|
|
30
30
|
import { type RouteResult, routeInput } from "./router.js";
|
|
31
31
|
import { SpaceQueue } from "./space-queue.js";
|
|
@@ -1290,7 +1290,7 @@ export class MercuryCoreRuntime {
|
|
|
1290
1290
|
|
|
1291
1291
|
// Inject the active applicative profile persona (project-wide), ahead of
|
|
1292
1292
|
// any per-space system prompt so a space-specific prompt refines it.
|
|
1293
|
-
const profilePrompt =
|
|
1293
|
+
const profilePrompt = getActiveProfilePrompt();
|
|
1294
1294
|
if (profilePrompt) {
|
|
1295
1295
|
const existing = extraEnv?.MERCURY_EXT_SYSTEM_PROMPT;
|
|
1296
1296
|
extraEnv = {
|
|
@@ -1301,6 +1301,19 @@ export class MercuryCoreRuntime {
|
|
|
1301
1301
|
};
|
|
1302
1302
|
}
|
|
1303
1303
|
|
|
1304
|
+
// Inject global character (owner-set voice, deploy-safe, global-admin gated).
|
|
1305
|
+
const characterPrompt = this.db.getProjectConfig("character");
|
|
1306
|
+
if (characterPrompt) {
|
|
1307
|
+
const existing = extraEnv?.MERCURY_EXT_SYSTEM_PROMPT;
|
|
1308
|
+
const block = `## Bot Character (set by the owner — applies to all conversations)\n${characterPrompt}`;
|
|
1309
|
+
extraEnv = {
|
|
1310
|
+
...extraEnv,
|
|
1311
|
+
MERCURY_EXT_SYSTEM_PROMPT: existing
|
|
1312
|
+
? `${existing}\n\n${block}`
|
|
1313
|
+
: block,
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1304
1317
|
// Inject per-space system prompt (set via console Spaces settings or at provision time).
|
|
1305
1318
|
const spacePrompt = this.db.getSpaceConfig(spaceId, "system_prompt");
|
|
1306
1319
|
if (spacePrompt) {
|
package/src/main.ts
CHANGED
|
@@ -35,10 +35,7 @@ import { createChatShim } from "./chat-shim.js";
|
|
|
35
35
|
import { loadConfig, resolveProjectPath } from "./config.js";
|
|
36
36
|
import { createMessageHandler } from "./core/handler.js";
|
|
37
37
|
import { setActiveProfileMemberPermissions } from "./core/permissions.js";
|
|
38
|
-
import {
|
|
39
|
-
loadActiveProfile,
|
|
40
|
-
setActiveProfileSystemPrompt,
|
|
41
|
-
} from "./core/profiles.js";
|
|
38
|
+
import { loadActiveProfile, setActiveProfilePrompt } from "./core/profiles.js";
|
|
42
39
|
import { MercuryCoreRuntime } from "./core/runtime.js";
|
|
43
40
|
import { runStorageCleanup } from "./core/storage-cleanup.js";
|
|
44
41
|
import { isOverQuota } from "./core/storage-guard.js";
|
|
@@ -225,7 +222,7 @@ async function main() {
|
|
|
225
222
|
const activeProfile = loadActiveProfile(resolveProjectPath(config.dataDir));
|
|
226
223
|
if (activeProfile) {
|
|
227
224
|
setActiveProfileMemberPermissions(activeProfile.memberPermissions ?? null);
|
|
228
|
-
|
|
225
|
+
setActiveProfilePrompt(activeProfile.profilePrompt ?? null);
|
|
229
226
|
logger.info("Applicative profile active", {
|
|
230
227
|
profile: activeProfile.name,
|
|
231
228
|
memberPermissions:
|
package/src/storage/db.ts
CHANGED
|
@@ -206,6 +206,14 @@ export class Db {
|
|
|
206
206
|
updated_at INTEGER NOT NULL,
|
|
207
207
|
PRIMARY KEY (space_id, platform_user_id, date)
|
|
208
208
|
);
|
|
209
|
+
|
|
210
|
+
CREATE TABLE IF NOT EXISTS project_config (
|
|
211
|
+
key TEXT PRIMARY KEY,
|
|
212
|
+
value TEXT NOT NULL,
|
|
213
|
+
updated_by TEXT NOT NULL,
|
|
214
|
+
created_at INTEGER NOT NULL,
|
|
215
|
+
updated_at INTEGER NOT NULL
|
|
216
|
+
);
|
|
209
217
|
`);
|
|
210
218
|
this.ensureMessagesRunMetaColumn();
|
|
211
219
|
this.ensureChatStateClearBoundaryColumn();
|
|
@@ -1327,6 +1335,41 @@ export class Db {
|
|
|
1327
1335
|
return res.changes > 0;
|
|
1328
1336
|
}
|
|
1329
1337
|
|
|
1338
|
+
// --- Project Config (global, not tied to any space) ---
|
|
1339
|
+
|
|
1340
|
+
getProjectConfig(key: string): string | null {
|
|
1341
|
+
const row = this.db
|
|
1342
|
+
.query("SELECT value FROM project_config WHERE key = ?")
|
|
1343
|
+
.get(key) as { value: string } | null;
|
|
1344
|
+
return row?.value ?? null;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
getProjectConfigUpdatedBy(key: string): string | null {
|
|
1348
|
+
const row = this.db
|
|
1349
|
+
.query("SELECT updated_by FROM project_config WHERE key = ?")
|
|
1350
|
+
.get(key) as { updated_by: string } | null;
|
|
1351
|
+
return row?.updated_by ?? null;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
setProjectConfig(key: string, value: string, updatedBy: string): void {
|
|
1355
|
+
const now = Date.now();
|
|
1356
|
+
this.db
|
|
1357
|
+
.query(
|
|
1358
|
+
`INSERT INTO project_config(key, value, updated_by, created_at, updated_at)
|
|
1359
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1360
|
+
ON CONFLICT(key)
|
|
1361
|
+
DO UPDATE SET value = excluded.value, updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
|
|
1362
|
+
)
|
|
1363
|
+
.run(key, value, updatedBy, now, now);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
deleteProjectConfig(key: string): boolean {
|
|
1367
|
+
const res = this.db
|
|
1368
|
+
.query("DELETE FROM project_config WHERE key = ?")
|
|
1369
|
+
.run(key);
|
|
1370
|
+
return res.changes > 0;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1330
1373
|
// --- Space preferences (chat-managed) ---
|
|
1331
1374
|
|
|
1332
1375
|
getSpacePreference(spaceId: string, key: string): string | null {
|