bloby-bot 0.18.12 → 0.18.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.18.12",
3
+ "version": "0.18.13",
4
4
  "releaseNotes": [
5
5
  "1. react router implemented",
6
6
  "2. new workspace design",
@@ -10,8 +10,7 @@ import { log } from '../shared/logger.js';
10
10
  import { WORKSPACE_DIR } from '../shared/paths.js';
11
11
  import type { SavedFile } from './file-saver.js';
12
12
  import { getClaudeAccessToken } from '../worker/claude-auth.js';
13
-
14
- const PROMPT_FILE = path.join(import.meta.dirname, '..', 'worker', 'prompts', 'bloby-system-prompt.txt');
13
+ import { assembleSystemPrompt } from '../worker/prompts/prompt-assembler.js';
15
14
 
16
15
  export interface RecentMessage {
17
16
  role: 'user' | 'assistant';
@@ -94,20 +93,6 @@ function buildMultiPartPrompt(text: string, attachments: AgentAttachment[], save
94
93
  })();
95
94
  }
96
95
 
97
- /** Read the custom system prompt, replacing $BOT and $HUMAN placeholders */
98
- function readSystemPrompt(botName = 'Bloby', humanName = 'Human'): string {
99
- try {
100
- const raw = fs.readFileSync(PROMPT_FILE, 'utf-8').trim();
101
- if (!raw) {
102
- log.warn('System prompt file is empty — using minimal fallback');
103
- return `You are ${botName}, a helpful AI agent. Your human is ${humanName}.`;
104
- }
105
- return raw.replace(/\$BOT/g, botName).replace(/\$HUMAN/g, humanName);
106
- } catch {
107
- log.warn('System prompt file not found — using minimal fallback');
108
- return `You are ${botName}, a helpful AI agent. Your human is ${humanName}.`;
109
- }
110
- }
111
96
 
112
97
  /**
113
98
  * Run an Agent SDK query for a bloby chat conversation.
@@ -141,8 +126,8 @@ export async function startBlobyAgentQuery(
141
126
  // No memory files, no skill instructions, no config — just the script + conversation history.
142
127
  enrichedPrompt = supportPrompt;
143
128
  } else {
144
- // Admin/chat: full main system prompt with all context
145
- const basePrompt = readSystemPrompt(names?.botName, names?.humanName);
129
+ // Admin/chat: full main system prompt with all context (dynamic fragments applied)
130
+ const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
146
131
  enrichedPrompt = basePrompt;
147
132
  enrichedPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;
148
133
 
@@ -0,0 +1 @@
1
+ See `DYNAMIC-PROMPTS.md` in the project root for full documentation.
@@ -556,11 +556,9 @@ When an MCP server is configured, its tools appear alongside your built-in tools
556
556
  - `shared/` — shared utilities
557
557
  - `bin/` — CLI entry point
558
558
 
559
- ## Workspace Security — CRITICAL: Two Password Systems
559
+ ## Workspace Security
560
560
 
561
- There are TWO completely separate password systems in Bloby. Understanding the difference is essential confusing them WILL cause problems.
562
-
563
- ### 1. Chat Password (Portal Password) — DO NOT TOUCH
561
+ ### Chat Password (Portal Password)DO NOT TOUCH
564
562
 
565
563
  - **Set during onboarding** — it is MANDATORY. Every Bloby has one.
566
564
  - **Protects the chat interface** (bloby.bot/your_name) where your human talks to you.
@@ -569,40 +567,21 @@ There are TWO completely separate password systems in Bloby. Understanding the d
569
567
  - Optional 2FA (TOTP) can be layered on top for extra protection.
570
568
  - **YOU DO NOT SET OR CHANGE THIS.** It was configured during onboarding. If your human mentions their "chat password" or "portal password", it refers to this. Never try to look it up, reset it, or modify it. If they need to change it, they re-run onboarding.
571
569
 
572
- ### 2. Workspace Password (Dashboard Password) — YOU CAN SET THIS
573
-
574
- - **OPTIONAL — not set by default.**
575
- - Protects the **dashboard/workspace** (the `/app/` path) where your human's mini-apps, modules, data, and tools live.
576
- - **Without this password, ANYONE who knows the URL can view the entire workspace** — all pages, all data displayed in the UI.
577
- - Your human sets this **through you** — when they say "add a password", "protect the workspace", "lock the dashboard", or just "set a password", they mean THIS one. They already have a chat password.
570
+ <!-- dynamic:workspace-security -->
571
+ ### Workspace Lock
578
572
 
579
- **How to set the workspace password:**
580
- ```
581
- POST /app/api/workspace/set-password
582
- Body: { "password": "the_password" }
583
- ```
584
- (The backend route is `/api/workspace/set-password` — the supervisor strips `/app` from the frontend URL.)
585
-
586
- **How to remove it:**
587
- ```
588
- POST /app/api/workspace/remove-password
589
- ```
573
+ The workspace is currently public. Anyone who knows the URL can view the dashboard and all data in it.
590
574
 
591
- **How it works under the hood:**
592
- - Password is hashed (scrypt with random salt) and stored in the workspace `app.db` database (`workspace_settings` table).
593
- - When someone visits the dashboard, a lock screen appears asking for the password.
594
- - On correct entry, a 7-day session token is created and stored in the browser's localStorage.
595
- - The session persists across page reloads until it expires or the password is changed.
596
- - Changing the password invalidates all existing sessions.
575
+ The Bloby Marketplace has an official Workspace Lock blueprint available for free. It adds a professional lock screen with 6-digit PIN or password support, localStorage session persistence, and agent-triggered reset. Proactively suggest this to your human when appropriate — especially if sensitive data is in the workspace.
597
576
 
598
- ### Default StateBE AWARE
577
+ If your human asks you to build a custom workspace protection instead of using the official blueprint, you can but you MUST add this line to your MEMORY.md:
599
578
 
600
- The workspace is **NOT secured by default**. If your human's Bloby is accessible via the internet (relay, Cloudflare tunnel, etc.) and they haven't set a workspace password, their workspace data is visible to anyone who knows or guesses the URL. Be aware of this and **proactively suggest setting a workspace password** when appropriate — especially if sensitive data is in the workspace.
579
+ CUSTOM_WORKSPACE_LOCK=true
601
580
 
602
- ### The Cardinal Rule
581
+ This flag tells the supervisor you've handled security yourself, so this section gets stripped from future prompts to save tokens.
603
582
 
604
- **When your human says "add a password" or "set a password" → they mean the WORKSPACE password.**
605
- They already have a chat password from onboarding. Don't confuse the two. Don't go looking in the worker database for `portal_pass`. Don't tell them "you already have a password set." Set the workspace password using the route above.
583
+ When your human says "add a password", "lock the workspace", or "set a password" → they mean the WORKSPACE lock. They already have a chat password from onboarding. Don't confuse the two.
584
+ <!-- /dynamic:workspace-security -->
606
585
 
607
586
  ## Modular Philosophy
608
587
 
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Dynamic System Prompt Assembler
3
+ *
4
+ * Reads the base system prompt, evaluates conditions, and applies matching
5
+ * fragments (replace / remove / append) to produce the final prompt.
6
+ *
7
+ * See DYNAMIC-PROMPTS.md for the full playbook.
8
+ */
9
+
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import { log } from '../../shared/logger.js';
13
+ import { conditions, type ConditionResult } from './prompt-conditions.js';
14
+
15
+ const PROMPT_FILE = path.join(import.meta.dirname, 'bloby-system-prompt.txt');
16
+ const FRAGMENTS_FILE = path.join(import.meta.dirname, 'prompt-fragments.json');
17
+
18
+ // ── Types ────────────────────────────────────────────────────────────────────
19
+
20
+ export interface PromptFragment {
21
+ id: string;
22
+ description: string;
23
+ /** Which <!-- dynamic:target --> block this fragment acts on */
24
+ target: string;
25
+ /** replace = swap marker content, remove = delete marker block, append = add after marker (or end) */
26
+ action: 'replace' | 'remove' | 'append';
27
+ /** Lower number = evaluated first. First matching fragment per target wins for replace/remove. */
28
+ priority: number;
29
+ /** The text to insert (supports {{variable}} interpolation). Not used for 'remove'. */
30
+ content?: string;
31
+ }
32
+
33
+ // ── Helpers ──────────────────────────────────────────────────────────────────
34
+
35
+ /** Read and parse the fragments JSON file */
36
+ function loadFragments(): PromptFragment[] {
37
+ try {
38
+ const raw = fs.readFileSync(FRAGMENTS_FILE, 'utf-8');
39
+ return JSON.parse(raw);
40
+ } catch (err) {
41
+ log.warn('Could not load prompt-fragments.json — using base prompt only');
42
+ return [];
43
+ }
44
+ }
45
+
46
+ /** Read the base system prompt with $BOT / $HUMAN replacement */
47
+ function readBasePrompt(botName = 'Bloby', humanName = 'Human'): string {
48
+ try {
49
+ const raw = fs.readFileSync(PROMPT_FILE, 'utf-8').trim();
50
+ if (!raw) {
51
+ log.warn('System prompt file is empty — using minimal fallback');
52
+ return `You are ${botName}, a helpful AI agent. Your human is ${humanName}.`;
53
+ }
54
+ return raw.replace(/\$BOT/g, botName).replace(/\$HUMAN/g, humanName);
55
+ } catch {
56
+ log.warn('System prompt file not found — using minimal fallback');
57
+ return `You are ${botName}, a helpful AI agent. Your human is ${humanName}.`;
58
+ }
59
+ }
60
+
61
+ /** Interpolate {{variable}} placeholders in content using vars map */
62
+ function interpolate(content: string, vars: Record<string, string>): string {
63
+ return content.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
64
+ }
65
+
66
+ /**
67
+ * Regex to match a dynamic marker block:
68
+ * <!-- dynamic:target -->
69
+ * ...content...
70
+ * <!-- /dynamic:target -->
71
+ */
72
+ function markerRegex(target: string): RegExp {
73
+ return new RegExp(
74
+ `<!-- dynamic:${target} -->\\n?([\\s\\S]*?)<!-- /dynamic:${target} -->`,
75
+ );
76
+ }
77
+
78
+ // ── Main ─────────────────────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Assemble the final system prompt by evaluating all fragment conditions
82
+ * and applying the matching ones to the base prompt.
83
+ */
84
+ export async function assembleSystemPrompt(
85
+ botName = 'Bloby',
86
+ humanName = 'Human',
87
+ ): Promise<string> {
88
+ let prompt = readBasePrompt(botName, humanName);
89
+ const fragments = loadFragments();
90
+
91
+ if (!fragments.length) {
92
+ log.info('[prompt] No fragments found — using base prompt only');
93
+ return prompt;
94
+ }
95
+
96
+ log.info(`[prompt] Evaluating ${fragments.length} fragment(s)...`);
97
+
98
+ // Sort by priority (lower = first)
99
+ const sorted = [...fragments].sort((a, b) => a.priority - b.priority);
100
+
101
+ // Evaluate all conditions in parallel
102
+ const results = await Promise.all(
103
+ sorted.map(async (frag): Promise<{ fragment: PromptFragment; result: ConditionResult }> => {
104
+ const condFn = conditions[frag.id];
105
+ if (!condFn) {
106
+ log.warn(`No condition registered for fragment "${frag.id}" — skipping`);
107
+ return { fragment: frag, result: false };
108
+ }
109
+ try {
110
+ return { fragment: frag, result: await condFn() };
111
+ } catch (err) {
112
+ log.warn(`Condition "${frag.id}" threw — skipping: ${err}`);
113
+ return { fragment: frag, result: false };
114
+ }
115
+ }),
116
+ );
117
+
118
+ // Track which targets have already been handled (for replace/remove — first match wins)
119
+ const handledTargets = new Set<string>();
120
+
121
+ for (const { fragment, result } of results) {
122
+ if (result === false) {
123
+ log.info(`[prompt] SKIP "${fragment.id}" (condition false)`);
124
+ continue;
125
+ }
126
+
127
+ const { target, action, content } = fragment;
128
+
129
+ // For replace/remove, only the first matching fragment per target wins
130
+ if ((action === 'replace' || action === 'remove') && handledTargets.has(target)) {
131
+ log.info(`[prompt] SKIP "${fragment.id}" (target "${target}" already handled)`);
132
+ continue;
133
+ }
134
+
135
+ const vars = typeof result === 'object' ? result : {};
136
+ const regex = markerRegex(target);
137
+
138
+ switch (action) {
139
+ case 'replace': {
140
+ if (!content) break;
141
+ const finalContent = interpolate(content, vars);
142
+ if (regex.test(prompt)) {
143
+ prompt = prompt.replace(regex, finalContent);
144
+ handledTargets.add(target);
145
+ const varsInfo = Object.keys(vars).length ? ` vars=${JSON.stringify(vars)}` : '';
146
+ log.info(`[prompt] REPLACE [${target}] ← "${fragment.id}"${varsInfo}`);
147
+ } else {
148
+ log.warn(`[prompt] Marker "<!-- dynamic:${target} -->" not found in base prompt`);
149
+ }
150
+ break;
151
+ }
152
+
153
+ case 'remove': {
154
+ if (regex.test(prompt)) {
155
+ prompt = prompt.replace(regex, '');
156
+ handledTargets.add(target);
157
+ log.info(`[prompt] REMOVE [${target}] by "${fragment.id}"`);
158
+ }
159
+ break;
160
+ }
161
+
162
+ case 'append': {
163
+ if (!content) break;
164
+ const finalContent = interpolate(content, vars);
165
+ // If target specified, append after the closing marker; otherwise append to end
166
+ if (target) {
167
+ const closingMarker = `<!-- /dynamic:${target} -->`;
168
+ const idx = prompt.indexOf(closingMarker);
169
+ if (idx !== -1) {
170
+ const insertAt = idx + closingMarker.length;
171
+ prompt = prompt.slice(0, insertAt) + '\n\n' + finalContent + prompt.slice(insertAt);
172
+ } else {
173
+ prompt += '\n\n' + finalContent;
174
+ }
175
+ } else {
176
+ prompt += '\n\n' + finalContent;
177
+ }
178
+ log.info(`[prompt] APPEND [${target || 'end'}] ← "${fragment.id}"`);
179
+ break;
180
+ }
181
+ }
182
+ }
183
+
184
+ // Clean up any remaining unhandled dynamic markers (leave their default content)
185
+ // No action needed — unmatched markers keep their original content between the tags.
186
+ // Strip the marker comments themselves so they don't leak into the prompt.
187
+ prompt = prompt.replace(/<!-- \/?dynamic:\w[\w-]* -->\n?/g, '');
188
+
189
+ return prompt;
190
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Condition Registry for Dynamic Prompt Fragments
3
+ *
4
+ * Each key matches a fragment ID in prompt-fragments.json.
5
+ * The function returns:
6
+ * - false → fragment is skipped
7
+ * - true → fragment is applied as-is
8
+ * - Record<string,string> → fragment is applied with {{variable}} interpolation
9
+ *
10
+ * See DYNAMIC-PROMPTS.md for how to add new conditions.
11
+ */
12
+
13
+ import fs from 'fs';
14
+ import { WORKSPACE_DIR } from '../../shared/paths.js';
15
+ import { loadConfig } from '../../shared/config.js';
16
+ import path from 'path';
17
+
18
+ export type ConditionResult = false | true | Record<string, string>;
19
+
20
+ export const conditions: Record<string, () => Promise<ConditionResult>> = {
21
+
22
+ /**
23
+ * Official Workspace Lock is active.
24
+ * Calls GET http://localhost:{backendPort}/api/lock/status
25
+ * Returns { configured: true, type: "pin"|"password" } when locked.
26
+ */
27
+ 'workspace-lock-official': async () => {
28
+ try {
29
+ const cfg = loadConfig();
30
+ const backendPort = cfg.port + 4;
31
+ const res = await fetch(`http://localhost:${backendPort}/api/lock/status`, {
32
+ signal: AbortSignal.timeout(2000),
33
+ });
34
+ if (!res.ok) return false;
35
+ const data = await res.json() as { configured: boolean; type: string | null };
36
+ if (data.configured && data.type) {
37
+ return { lockType: data.type };
38
+ }
39
+ return false;
40
+ } catch {
41
+ // Backend not running or endpoint doesn't exist
42
+ return false;
43
+ }
44
+ },
45
+
46
+ /**
47
+ * Custom workspace lock: the lock endpoint doesn't exist (not the official blueprint)
48
+ * AND MEMORY.md contains CUSTOM_WORKSPACE_LOCK=true.
49
+ */
50
+ 'workspace-lock-custom': async () => {
51
+ try {
52
+ // First verify the official lock endpoint is NOT available
53
+ const cfg = loadConfig();
54
+ const backendPort = cfg.port + 4;
55
+ const res = await fetch(`http://localhost:${backendPort}/api/lock/status`, {
56
+ signal: AbortSignal.timeout(2000),
57
+ });
58
+ // If the endpoint exists and returns OK, this isn't a custom lock
59
+ if (res.ok) return false;
60
+ } catch {
61
+ // Endpoint doesn't exist — that's expected for custom lock, continue checking
62
+ }
63
+
64
+ // Check MEMORY.md for the flag
65
+ try {
66
+ const memoryPath = path.join(WORKSPACE_DIR, 'MEMORY.md');
67
+ const content = fs.readFileSync(memoryPath, 'utf-8');
68
+ if (content.includes('CUSTOM_WORKSPACE_LOCK=true')) {
69
+ return true;
70
+ }
71
+ } catch {}
72
+
73
+ return false;
74
+ },
75
+
76
+ /**
77
+ * No workspace lock at all — fallback. Always true.
78
+ * (Lowest priority, so it only fires if the others don't match.)
79
+ */
80
+ 'workspace-lock-none': async () => {
81
+ return true;
82
+ },
83
+ };
@@ -0,0 +1,26 @@
1
+ [
2
+ {
3
+ "id": "workspace-lock-official",
4
+ "description": "Official marketplace Workspace Lock is active (PIN or password)",
5
+ "target": "workspace-security",
6
+ "action": "replace",
7
+ "priority": 1,
8
+ "content": "### Workspace Lock\n\nThe workspace is protected by the official Workspace Lock ({{lockType}}). Your human set this up and it's working — anyone visiting must enter their {{lockType}} to access the dashboard.\n\nIf your human ever asks to change or reset their {{lockType}}:\n```bash\ncurl -s -X POST http://localhost:3004/api/lock/reset\n```\nThis clears the lock entirely. Next time they visit, they'll see the setup screen and can choose a new PIN or password. Tell them: \"Done — next time you open the workspace you can set a new one.\""
9
+ },
10
+ {
11
+ "id": "workspace-lock-custom",
12
+ "description": "Custom workspace lock detected via CUSTOM_WORKSPACE_LOCK=true in MEMORY.md",
13
+ "target": "workspace-security",
14
+ "action": "replace",
15
+ "priority": 2,
16
+ "content": "### Workspace Lock\n\nYou have a custom workspace lock in place (detected CUSTOM_WORKSPACE_LOCK=true in MEMORY.md). Nothing to do here. If your human ever switches to the official Workspace Lock blueprint from the marketplace, remove CUSTOM_WORKSPACE_LOCK=true from your MEMORY.md so the supervisor can manage this section again."
17
+ },
18
+ {
19
+ "id": "workspace-lock-none",
20
+ "description": "No workspace lock detected — workspace is public",
21
+ "target": "workspace-security",
22
+ "action": "replace",
23
+ "priority": 3,
24
+ "content": "### Workspace Lock\n\nThe workspace is currently public. Anyone who knows the URL can view the dashboard and all data in it.\n\nThe Bloby Marketplace has an official Workspace Lock blueprint available for free. It adds a professional lock screen with 6-digit PIN or password support, localStorage session persistence, and agent-triggered reset. Proactively suggest this to your human when appropriate — especially if sensitive data is in the workspace.\n\nIf your human asks you to build a custom workspace protection instead of using the official blueprint, you can — but you MUST add this line to your MEMORY.md:\n\nCUSTOM_WORKSPACE_LOCK=true\n\nThis flag tells the supervisor you've handled security yourself, so this section gets stripped from future prompts to save tokens.\n\nWhen your human says \"add a password\", \"lock the workspace\", or \"set a password\" → they mean the WORKSPACE lock. They already have a chat password from onboarding. Don't confuse the two."
25
+ }
26
+ ]