agent-office 0.3.0 → 0.3.2

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 CHANGED
@@ -84,7 +84,7 @@ From the TUI you can create coworkers, send them messages, browse mail, manage c
84
84
  ### 3. Chat with a coworker in the browser
85
85
 
86
86
  ```bash
87
- agent-office communicator web "Alice" --secret mysecret
87
+ agent-office communicator web "Alice" --password mysecret
88
88
  ```
89
89
 
90
90
  Opens a web-based chat interface at `http://127.0.0.1:7655` for real-time conversation with the named coworker.
@@ -183,7 +183,7 @@ Arguments:
183
183
 
184
184
  Options:
185
185
  --url <url> Server URL (default: http://127.0.0.1:7654)
186
- --secret <secret> API password (env: AGENT_OFFICE_PASSWORD)
186
+ --password <password> API password (env: AGENT_OFFICE_PASSWORD)
187
187
  --host <host> Communicator bind host (default: 127.0.0.1)
188
188
  --port <port> Communicator bind port (default: 7655)
189
189
  ```
@@ -330,7 +330,7 @@ npm run dev:serve -- --password secret
330
330
  npm run dev:manage -- --password secret
331
331
 
332
332
  # Run communicator (in another terminal)
333
- npm run dev:communicator -- "Alice" --secret secret
333
+ npm run dev:communicator -- "Alice" --password secret
334
334
 
335
335
  # Build
336
336
  npm run build
package/dist/cli.js CHANGED
@@ -16,6 +16,7 @@ program
16
16
  .option("--memory-path <path>", "Directory for memory storage (default: ./.memory)", "./.memory")
17
17
  .option("--password <password>", "REQUIRED. API password", process.env.AGENT_OFFICE_PASSWORD)
18
18
  .option("--opencode-url <url>", "URL of the OpenCode server (default: http://127.0.0.1:4096)", process.env.OPENCODE_URL ?? "http://127.0.0.1:4096")
19
+ .option("--simple-memory", "Use lightweight SQLite+BM25 memory instead of the default fastmemory (no embeddings, no model download)")
19
20
  .action(async (options) => {
20
21
  const { serve } = await import("./commands/serve.js");
21
22
  await serve(options);
@@ -34,32 +35,23 @@ const appCmd = program
34
35
  .description("[HUMAN ONLY] Interactive visual applications");
35
36
  appCmd
36
37
  .command("coworker-chat-web")
37
- .description("[HUMAN ONLY] Launch a web chat interface for a single coworker")
38
- .argument("<coworker>", "Name of the coworker to chat with (e.g. 'Howard Roark')")
38
+ .description("[HUMAN ONLY] Launch a web chat interface for coworkers")
39
39
  .option("--url <url>", "URL of the agent-office serve endpoint (e.g. http://127.0.0.1:7654)", process.env.AGENT_OFFICE_URL ?? "http://127.0.0.1:7654")
40
- .option("--secret <secret>", "API password for the agent-office server", process.env.AGENT_OFFICE_PASSWORD)
40
+ .option("--password <password>", "API password for the agent-office server", process.env.AGENT_OFFICE_PASSWORD ?? "secret")
41
41
  .option("--host <host>", "Host to bind the web server to", "127.0.0.1")
42
42
  .option("--port <port>", "Port to run the web server on", "7655")
43
- .action(async (coworker, options) => {
44
- if (!options.secret) {
45
- console.error("Error: --secret is required (or set AGENT_OFFICE_PASSWORD)");
46
- process.exit(1);
47
- }
43
+ .action(async (options) => {
48
44
  const { appCoworkerChatWeb } = await import("./commands/communicator.js");
49
- await appCoworkerChatWeb(coworker, options);
45
+ await appCoworkerChatWeb(options);
50
46
  });
51
47
  appCmd
52
48
  .command("screensaver")
53
49
  .description("[HUMAN ONLY] Launch a visualization of recent mail activity (live screensaver)")
54
50
  .option("--url <url>", "URL of the agent-office serve endpoint (e.g. http://127.0.0.1:7654)", process.env.AGENT_OFFICE_URL ?? "http://127.0.0.1:7654")
55
- .option("--secret <secret>", "API password for the agent-office server", process.env.AGENT_OFFICE_PASSWORD)
51
+ .option("--password <password>", "API password for the agent-office server", process.env.AGENT_OFFICE_PASSWORD ?? "secret")
56
52
  .option("--host <host>", "Host to bind the screensaver web server to", "127.0.0.1")
57
53
  .option("--port <port>", "Port to run the screensaver web server on", "7656")
58
54
  .action(async (options) => {
59
- if (!options.secret) {
60
- console.error("Error: --secret is required (or set AGENT_OFFICE_PASSWORD)");
61
- process.exit(1);
62
- }
63
55
  const { appScreensaver } = await import("./commands/screensaver.js");
64
56
  await appScreensaver(options);
65
57
  });
@@ -122,7 +114,8 @@ cronCmd
122
114
  .description("Create a new cron job")
123
115
  .requiredOption("--name <name>", "Cron job name")
124
116
  .requiredOption("--schedule <schedule>", "Cron expression (e.g., '0 9 * * *' for daily at 9am)")
125
- .requiredOption("--message <message>", "Message to inject when job fires")
117
+ .requiredOption("--message <message>", "Action to perform when job fires")
118
+ .requiredOption("--respond-to <respondTo>", "Who to respond to when done")
126
119
  .option("--timezone <timezone>", "IANA timezone (e.g., 'America/New_York')")
127
120
  .action(async (token, options) => {
128
121
  const { createCron } = await import("./commands/worker.js");
@@ -1,8 +1,8 @@
1
1
  interface CommunicatorOptions {
2
2
  url: string;
3
- secret: string;
3
+ password: string;
4
4
  host: string;
5
5
  port: string;
6
6
  }
7
- export declare function appCoworkerChatWeb(coworker: string, options: CommunicatorOptions): Promise<void>;
7
+ export declare function appCoworkerChatWeb(options: CommunicatorOptions): Promise<void>;
8
8
  export {};
@@ -1,11 +1,11 @@
1
1
  import express from "express";
2
2
  // ── API helpers ───────────────────────────────────────────────────────────────
3
- async function apiFetch(agentUrl, secret, path, init = {}) {
3
+ async function apiFetch(agentUrl, password, path, init = {}) {
4
4
  const res = await fetch(`${agentUrl}${path}`, {
5
5
  ...init,
6
6
  headers: {
7
7
  "Content-Type": "application/json",
8
- "Authorization": `Bearer ${secret}`,
8
+ "Authorization": `Bearer ${password}`,
9
9
  ...(init.headers ?? {}),
10
10
  },
11
11
  });
@@ -15,19 +15,22 @@ async function apiFetch(agentUrl, secret, path, init = {}) {
15
15
  }
16
16
  return res.json();
17
17
  }
18
- async function getHumanName(agentUrl, secret) {
19
- const cfg = await apiFetch(agentUrl, secret, "/config");
18
+ async function getHumanName(agentUrl, password) {
19
+ const cfg = await apiFetch(agentUrl, password, "/config");
20
20
  return cfg.human_name ?? "Human";
21
21
  }
22
- async function fetchCoworkerStatus(agentUrl, secret, coworker) {
23
- const sessions = await apiFetch(agentUrl, secret, "/sessions");
22
+ async function fetchSessions(agentUrl, password) {
23
+ return apiFetch(agentUrl, password, "/sessions");
24
+ }
25
+ async function fetchCoworkerStatus(agentUrl, password, coworker) {
26
+ const sessions = await fetchSessions(agentUrl, password);
24
27
  const session = sessions.find((s) => s.name === coworker);
25
28
  return session?.status ?? null;
26
29
  }
27
- async function fetchMessages(agentUrl, secret, humanName, coworker) {
30
+ async function fetchMessages(agentUrl, password, humanName, coworker) {
28
31
  const [sent, received] = await Promise.all([
29
- apiFetch(agentUrl, secret, `/messages/${encodeURIComponent(humanName)}?sent=true`),
30
- apiFetch(agentUrl, secret, `/messages/${encodeURIComponent(humanName)}`),
32
+ apiFetch(agentUrl, password, `/messages/${encodeURIComponent(humanName)}?sent=true`),
33
+ apiFetch(agentUrl, password, `/messages/${encodeURIComponent(humanName)}`),
31
34
  ]);
32
35
  // sent: from humanName → coworker
33
36
  const sentToCoworker = sent.filter((m) => m.to_name === coworker);
@@ -37,8 +40,8 @@ async function fetchMessages(agentUrl, secret, humanName, coworker) {
37
40
  all.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
38
41
  return all;
39
42
  }
40
- async function markRead(agentUrl, secret, id) {
41
- await apiFetch(agentUrl, secret, `/messages/${id}/read`, { method: "POST" });
43
+ async function markRead(agentUrl, password, id) {
44
+ await apiFetch(agentUrl, password, `/messages/${id}/read`, { method: "POST" });
42
45
  }
43
46
  // ── HTML helpers ──────────────────────────────────────────────────────────────
44
47
  function escapeHtml(str) {
@@ -83,14 +86,31 @@ function renderMessages(msgs, humanName) {
83
86
  return `<div id="messages-inner" data-last-id="${lastId}">${inner}</div>`;
84
87
  }
85
88
  // ── Full page ─────────────────────────────────────────────────────────────────
86
- function renderPage(coworker, msgs, humanName) {
89
+ function renderDropdown(coworker, coworkers) {
90
+ const selected = coworker ?? "";
91
+ const dropdownOptions = coworkers.filter(c => !c.isHuman);
92
+ const totalUnread = dropdownOptions.reduce((sum, c) => sum + (c.unreadMessages ?? 0), 0);
93
+ const unreadDot = totalUnread > 0 ? '<span class="unread-badge"></span>' : '';
94
+ const coworkerOptions = dropdownOptions.map(c => {
95
+ const unreadBadge = c.unreadMessages && c.unreadMessages > 0 ? ` (${c.unreadMessages})` : '';
96
+ return `<option value="${escapeHtml(c.name)}" ${c.name === selected ? 'selected' : ''}>${escapeHtml(c.name)}${unreadBadge}</option>`;
97
+ }).join('');
98
+ return `<select id="coworker-select" class="coworker-select" onchange="switchCoworker(this.value)">
99
+ <option value="">Select coworker…</option>
100
+ ${coworkerOptions}
101
+ </select>
102
+ ${unreadDot}`;
103
+ }
104
+ function renderPage(coworker, coworkers, msgs, humanName) {
87
105
  const msgsHtml = renderMessages(msgs, humanName);
106
+ const selected = coworker ?? "";
107
+ const dropdownHtml = renderDropdown(coworker, coworkers);
88
108
  return `<!DOCTYPE html>
89
109
  <html lang="en">
90
110
  <head>
91
111
  <meta charset="UTF-8">
92
112
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
93
- <title>${escapeHtml(coworker)} — agent-office</title>
113
+ <title>${selected ? escapeHtml(selected) + ' ' : ''}agent-office</title>
94
114
  <script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
95
115
  <script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
96
116
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.5.1/github-markdown-light.min.css" media="(prefers-color-scheme: light)">
@@ -162,6 +182,29 @@ function renderPage(coworker, msgs, humanName) {
162
182
  }
163
183
 
164
184
  .header-info { flex: 1; min-width: 0; }
185
+ .select-wrapper { display: inline-flex; align-items: center; position: relative; }
186
+ .coworker-select {
187
+ font-weight: 600;
188
+ font-size: 15px;
189
+ background: transparent;
190
+ border: none;
191
+ color: var(--text);
192
+ cursor: pointer;
193
+ padding: 0;
194
+ margin: 0;
195
+ outline: none;
196
+ max-width: 180px;
197
+ }
198
+ .coworker-select:focus { color: var(--accent); }
199
+ .coworker-select option { background: var(--surface); color: var(--text); }
200
+ .unread-badge {
201
+ width: 8px;
202
+ height: 8px;
203
+ background: #ff4444;
204
+ border-radius: 50%;
205
+ margin-left: 6px;
206
+ flex-shrink: 0;
207
+ }
165
208
  .header-name {
166
209
  font-weight: 600;
167
210
  font-size: 15px;
@@ -418,22 +461,28 @@ function renderPage(coworker, msgs, humanName) {
418
461
 
419
462
  <!-- Header -->
420
463
  <div class="header">
421
- <div class="avatar">${escapeHtml(coworker.charAt(0).toUpperCase())}</div>
464
+ <div class="avatar">${selected ? escapeHtml(selected.charAt(0).toUpperCase()) : '?'}</div>
422
465
  <div class="header-info">
423
- <div class="header-name">${escapeHtml(coworker)}</div>
466
+ <div class="select-wrapper" id="dropdown-wrapper"
467
+ hx-get="/dropdown?coworker=${encodeURIComponent(selected)}"
468
+ hx-trigger="load, every 5s"
469
+ hx-swap="innerHTML">
470
+ ${dropdownHtml}
471
+ </div>
424
472
  <div class="header-sub"
425
473
  id="coworker-status"
426
- hx-get="/status"
474
+ hx-get="/status?coworker=${encodeURIComponent(selected)}"
427
475
  hx-trigger="load, every 5s"
428
476
  hx-swap="innerHTML"></div>
429
477
  </div>
430
478
  <button class="reset-btn"
431
- hx-post="/reset"
479
+ hx-post="/reset?coworker=${encodeURIComponent(selected)}"
432
480
  hx-target="#reset-status"
433
481
  hx-swap="innerHTML"
434
- hx-confirm="Reset ${escapeHtml(coworker)}'s session? This will revert them to their first message and re-inject the enrollment prompt."
482
+ hx-confirm="Reset session? This will revert them to their first message and re-inject the enrollment prompt."
435
483
  hx-on::after-request="showResetStatus()"
436
- title="Reset session">
484
+ title="Reset session"
485
+ ${!selected ? 'disabled' : ''}>
437
486
  ↺ Reset
438
487
  </button>
439
488
  <div class="refresh-indicator" id="refresh-dot"
@@ -449,7 +498,7 @@ function renderPage(coworker, msgs, humanName) {
449
498
  <!-- Messages -->
450
499
  <div class="messages-outer" id="messages-outer">
451
500
  <div id="messages"
452
- hx-get="/messages"
501
+ hx-get="/messages?coworker=${encodeURIComponent(selected)}"
453
502
  hx-trigger="load, every 5s"
454
503
  hx-swap="innerHTML">
455
504
  ${msgsHtml}
@@ -466,11 +515,12 @@ function renderPage(coworker, msgs, humanName) {
466
515
  hx-on::after-request="handleSent(event)"
467
516
  hx-on::before-request="this.querySelector('.send-btn').classList.add('sending')"
468
517
  hx-encoding="application/x-www-form-urlencoded">
518
+ <input type="hidden" name="coworker" value="${escapeHtml(selected)}">
469
519
  <textarea
470
520
  class="input-textarea"
471
521
  name="body"
472
522
  id="msg-input"
473
- placeholder="Message ${escapeHtml(coworker)}"
523
+ placeholder="${selected ? 'Message ' + escapeHtml(selected) + '…' : 'Select a coworker to message…'}"
474
524
  rows="1"
475
525
  autocomplete="off"
476
526
  autocorrect="on"
@@ -554,6 +604,14 @@ function renderPage(coworker, msgs, humanName) {
554
604
  // Initial scroll
555
605
  scrollToBottom()
556
606
 
607
+ // Switch to a different coworker
608
+ function switchCoworker(name) {
609
+ if (!name) return
610
+ const url = new URL(window.location.href)
611
+ url.searchParams.set('coworker', name)
612
+ window.location.href = url.toString()
613
+ }
614
+
557
615
  // Flash the reset status toast then fade it out
558
616
  function showResetStatus() {
559
617
  const el = document.getElementById('reset-status')
@@ -569,8 +627,9 @@ function renderPage(coworker, msgs, humanName) {
569
627
  document.querySelectorAll('.markdown-body[data-markdown-b64]').forEach(el => {
570
628
  const b64 = el.getAttribute('data-markdown-b64')
571
629
  if (b64 && !el.hasAttribute('data-rendered')) {
572
- // Decode base64 to get original text with preserved newlines
573
- const text = atob(b64)
630
+ // Decode base64 to get original text with preserved newlines and UTF-8 chars
631
+ const binary = atob(b64)
632
+ const text = new TextDecoder().decode(Uint8Array.from(binary, c => c.charCodeAt(0)))
574
633
  el.innerHTML = marked.parse(text)
575
634
  el.setAttribute('data-rendered', 'true')
576
635
  }
@@ -589,8 +648,8 @@ function renderPage(coworker, msgs, humanName) {
589
648
  </html>`;
590
649
  }
591
650
  // ── Express app ───────────────────────────────────────────────────────────────
592
- export async function appCoworkerChatWeb(coworker, options) {
593
- const { url: agentUrl, secret, host, port: portStr } = options;
651
+ export async function appCoworkerChatWeb(options) {
652
+ const { url: agentUrl, password, host, port: portStr } = options;
594
653
  const port = parseInt(portStr, 10);
595
654
  if (isNaN(port) || port < 1 || port > 65535) {
596
655
  console.error(`Error: invalid port "${portStr}"`);
@@ -606,22 +665,76 @@ export async function appCoworkerChatWeb(coworker, options) {
606
665
  // Resolve human name once at startup
607
666
  let humanName = "Human";
608
667
  try {
609
- humanName = await getHumanName(agentUrl, secret);
668
+ humanName = await getHumanName(agentUrl, password);
610
669
  }
611
670
  catch (err) {
612
671
  console.error(`Warning: could not fetch human name from ${agentUrl}: ${err instanceof Error ? err.message : String(err)}`);
613
- console.error("Check that agent-office serve is running and --secret is correct.");
672
+ console.error("Check that agent-office serve is running and --password is correct.");
614
673
  }
615
- console.log(`Communicator: chatting as "${humanName}" with "${coworker}"`);
674
+ console.log(`Communicator: chatting as "${humanName}"`);
616
675
  const app = express();
617
676
  app.use(express.urlencoded({ extended: false }));
618
677
  app.use(express.json());
678
+ // ── GET /coworkers — list of coworkers (HTMX) ───────────────────────────────
679
+ app.get("/coworkers", async (_req, res) => {
680
+ res.setHeader("Content-Type", "application/json");
681
+ try {
682
+ const response = await fetch(`${agentUrl}/coworkers`, {
683
+ headers: { "Authorization": `Bearer ${password}` },
684
+ });
685
+ if (!response.ok) {
686
+ res.json([{ name: humanName, status: null, isHuman: true, unreadMessages: 0 }]);
687
+ return;
688
+ }
689
+ const coworkers = await response.json();
690
+ res.json(coworkers);
691
+ }
692
+ catch {
693
+ res.json([{ name: humanName, status: null, isHuman: true, unreadMessages: 0 }]);
694
+ }
695
+ });
696
+ // ── GET /dropdown — dropdown fragment for HTMX polling ────────────────────
697
+ app.get("/dropdown", async (req, res) => {
698
+ const coworker = req.query.coworker;
699
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
700
+ try {
701
+ const response = await fetch(`${agentUrl}/coworkers`, {
702
+ headers: { "Authorization": `Bearer ${password}` },
703
+ });
704
+ if (!response.ok) {
705
+ res.send(renderDropdown(coworker ?? null, [{ name: humanName, status: null, isHuman: true, unreadMessages: 0 }]));
706
+ return;
707
+ }
708
+ const coworkers = await response.json();
709
+ res.send(renderDropdown(coworker ?? null, coworkers));
710
+ }
711
+ catch {
712
+ res.send(renderDropdown(coworker ?? null, [{ name: humanName, status: null, isHuman: true, unreadMessages: 0 }]));
713
+ }
714
+ });
619
715
  // ── GET / — full page ────────────────────────────────────────────────────
620
- app.get("/", async (_req, res) => {
716
+ app.get("/", async (req, res) => {
621
717
  try {
622
- const msgs = await fetchMessages(agentUrl, secret, humanName, coworker);
718
+ // Fetch coworkers with unread counts from main server
719
+ const response = await fetch(`${agentUrl}/coworkers`, {
720
+ headers: { "Authorization": `Bearer ${password}` },
721
+ });
722
+ let coworkers;
723
+ if (response.ok) {
724
+ coworkers = await response.json();
725
+ }
726
+ else {
727
+ coworkers = [{ name: humanName, status: null, isHuman: true, unreadMessages: 0 }];
728
+ }
729
+ const nonHumans = coworkers.filter(c => !c.isHuman);
730
+ let coworker = req.query.coworker;
731
+ // Default to first non-human coworker if none specified
732
+ if (!coworker && nonHumans.length > 0) {
733
+ coworker = nonHumans[0].name;
734
+ }
735
+ const msgs = coworker ? await fetchMessages(agentUrl, password, humanName, coworker) : [];
623
736
  res.setHeader("Content-Type", "text/html; charset=utf-8");
624
- res.send(renderPage(coworker, msgs, humanName));
737
+ res.send(renderPage(coworker ?? null, coworkers, msgs, humanName));
625
738
  }
626
739
  catch (err) {
627
740
  const msg = err instanceof Error ? err.message : String(err);
@@ -629,12 +742,18 @@ export async function appCoworkerChatWeb(coworker, options) {
629
742
  }
630
743
  });
631
744
  // ── GET /messages — HTMX fragment (polled every 5s) ──────────────────────
632
- app.get("/messages", async (_req, res) => {
745
+ app.get("/messages", async (req, res) => {
746
+ const coworker = req.query.coworker;
747
+ if (!coworker) {
748
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
749
+ res.send(`<div class="empty-state">Select a coworker to view messages.</div>`);
750
+ return;
751
+ }
633
752
  try {
634
- const msgs = await fetchMessages(agentUrl, secret, humanName, coworker);
753
+ const msgs = await fetchMessages(agentUrl, password, humanName, coworker);
635
754
  // Mark any unread received messages as read
636
755
  const unread = msgs.filter((m) => m.from_name === coworker && !m.read);
637
- await Promise.allSettled(unread.map((m) => markRead(agentUrl, secret, m.id)));
756
+ await Promise.allSettled(unread.map((m) => markRead(agentUrl, password, m.id)));
638
757
  res.setHeader("Content-Type", "text/html; charset=utf-8");
639
758
  res.send(renderMessages(msgs, humanName));
640
759
  }
@@ -646,14 +765,20 @@ export async function appCoworkerChatWeb(coworker, options) {
646
765
  });
647
766
  // ── POST /send — HTMX form submit ────────────────────────────────────────
648
767
  app.post("/send", async (req, res) => {
649
- const body = req.body.body?.trim();
768
+ const { body: msgBody, coworker } = req.body;
769
+ const body = msgBody?.trim();
650
770
  if (!body) {
651
771
  res.setHeader("Content-Type", "text/html; charset=utf-8");
652
772
  res.send(`<span class="send-err">Message cannot be empty.</span>`);
653
773
  return;
654
774
  }
775
+ if (!coworker) {
776
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
777
+ res.send(`<span class="send-err">No coworker selected.</span>`);
778
+ return;
779
+ }
655
780
  try {
656
- await apiFetch(agentUrl, secret, "/messages", {
781
+ await apiFetch(agentUrl, password, "/messages", {
657
782
  method: "POST",
658
783
  body: JSON.stringify({ from: humanName, to: [coworker], body }),
659
784
  });
@@ -667,10 +792,15 @@ export async function appCoworkerChatWeb(coworker, options) {
667
792
  }
668
793
  });
669
794
  // ── GET /status — coworker status fragment (polled every 5s) ────────────
670
- app.get("/status", async (_req, res) => {
795
+ app.get("/status", async (req, res) => {
796
+ const coworker = req.query.coworker;
671
797
  res.setHeader("Content-Type", "text/html; charset=utf-8");
798
+ if (!coworker) {
799
+ res.send(`<span style="color:var(--text-dim)">—</span>`);
800
+ return;
801
+ }
672
802
  try {
673
- const status = await fetchCoworkerStatus(agentUrl, secret, coworker);
803
+ const status = await fetchCoworkerStatus(agentUrl, password, coworker);
674
804
  res.send(status ? escapeHtml(status) : `<span style="color:var(--text-dim)">—</span>`);
675
805
  }
676
806
  catch {
@@ -678,10 +808,15 @@ export async function appCoworkerChatWeb(coworker, options) {
678
808
  }
679
809
  });
680
810
  // ── POST /reset — revert the coworker's session to first message ─────────
681
- app.post("/reset", async (_req, res) => {
811
+ app.post("/reset", async (req, res) => {
812
+ const coworker = req.query.coworker;
682
813
  res.setHeader("Content-Type", "text/html; charset=utf-8");
814
+ if (!coworker) {
815
+ res.send(`<span style="color:var(--red)">✗ No coworker selected.</span>`);
816
+ return;
817
+ }
683
818
  try {
684
- await apiFetch(agentUrl, secret, `/sessions/${encodeURIComponent(coworker)}/revert-to-start`, {
819
+ await apiFetch(agentUrl, password, `/sessions/${encodeURIComponent(coworker)}/revert-to-start`, {
685
820
  method: "POST",
686
821
  });
687
822
  res.send(`<span style="color:var(--green)">✓ ${escapeHtml(coworker)} reset and restarted.</span>`);
@@ -1,6 +1,6 @@
1
1
  interface ScreensaverOptions {
2
2
  url: string;
3
- secret: string;
3
+ password: string;
4
4
  host: string;
5
5
  port: string;
6
6
  }
@@ -1,6 +1,6 @@
1
1
  import express from "express";
2
2
  export async function appScreensaver(options) {
3
- const { url: agentUrl, secret, host, port: portStr } = options;
3
+ const { url: agentUrl, password, host, port: portStr } = options;
4
4
  const port = parseInt(portStr, 10);
5
5
  if (isNaN(port) || port < 1 || port > 65535) {
6
6
  console.error(`Error: invalid port "${portStr}"`);
@@ -16,6 +16,26 @@ export async function appScreensaver(options) {
16
16
  console.log(`Screensaver: visualizing mail activity from ${agentUrl}/watch`);
17
17
  const app = express();
18
18
  app.use(express.json());
19
+ // ── GET /coworkers — proxies coworker list from agent-office ───────────────
20
+ app.get("/coworkers", async (_req, res) => {
21
+ res.setHeader("Content-Type", "application/json");
22
+ try {
23
+ const response = await fetch(`${agentUrl}/coworkers`, {
24
+ headers: {
25
+ "Authorization": `Bearer ${password}`,
26
+ },
27
+ });
28
+ if (!response.ok) {
29
+ res.json([]);
30
+ return;
31
+ }
32
+ const data = await response.json();
33
+ res.json(data);
34
+ }
35
+ catch {
36
+ res.json([]);
37
+ }
38
+ });
19
39
  // ── GET / — Three.js ring visualization ─────────────────────────────────────
20
40
  app.get("/", (_req, res) => {
21
41
  res.setHeader("Content-Type", "text/html; charset=utf-8");
@@ -40,7 +60,7 @@ export async function appScreensaver(options) {
40
60
  // Connect to the agent-office /watch endpoint
41
61
  const response = await fetch(`${agentUrl}/watch`, {
42
62
  headers: {
43
- "Authorization": `Bearer ${secret}`,
63
+ "Authorization": `Bearer ${password}`,
44
64
  },
45
65
  signal: abortController.signal,
46
66
  });
@@ -150,6 +170,7 @@ function renderScreensaverPage() {
150
170
  let agentNodes = new Map() // name -> { mesh, label, angle }
151
171
  let arrowObjects = [] // { from, to, mesh, arrowHead, createdAt, opacity }
152
172
  let watchState = {} // latest state from SSE
173
+ let coworkersSet = new Set() // valid coworker names from /coworkers endpoint
153
174
 
154
175
  // Track all messages we've seen: key = "from->to", value = lastSent ISO string
155
176
  let messageEdges = new Map() // "from->to" -> { lastSent: Date }
@@ -1092,11 +1113,15 @@ function renderScreensaverPage() {
1092
1113
  watchState = state
1093
1114
  const nameSet = new Set()
1094
1115
 
1095
- // Collect all agent names and message edges
1116
+ // Collect all agent names and message edges (only for valid coworkers)
1096
1117
  for (const agentName in state) {
1118
+ // Only include if this is a known coworker
1119
+ if (!coworkersSet.has(agentName)) continue
1097
1120
  nameSet.add(agentName)
1098
1121
  const senders = state[agentName] || {}
1099
1122
  for (const senderName in senders) {
1123
+ // Only include sender if they're also a known coworker
1124
+ if (!coworkersSet.has(senderName)) continue
1100
1125
  nameSet.add(senderName)
1101
1126
  const edgeKey = senderName + '->' + agentName
1102
1127
  const lastSent = new Date(senders[senderName].lastSent)
@@ -1135,6 +1160,25 @@ function renderScreensaverPage() {
1135
1160
  updateArrows()
1136
1161
  }
1137
1162
 
1163
+ // ── Fetch coworkers list on startup ──────────────────────────────────────
1164
+ async function fetchCoworkers() {
1165
+ try {
1166
+ const response = await fetch('/coworkers')
1167
+ const coworkers = await response.json()
1168
+ coworkersSet = new Set(coworkers.map(c => c.name))
1169
+ console.log('Loaded coworkers:', Array.from(coworkersSet))
1170
+ // Re-process watch state if we already have data
1171
+ if (Object.keys(watchState).length > 0) {
1172
+ processWatchState(watchState)
1173
+ }
1174
+ } catch (err) {
1175
+ console.error('Failed to fetch coworkers:', err)
1176
+ }
1177
+ }
1178
+
1179
+ // Fetch coworkers immediately
1180
+ fetchCoworkers()
1181
+
1138
1182
  // ── SSE connection ─────────────────────────────────────────────────────
1139
1183
  const es = new EventSource('/watch-stream')
1140
1184
 
@@ -6,6 +6,7 @@ interface ServeOptions {
6
6
  memoryPath: string;
7
7
  password?: string;
8
8
  opencodeUrl: string;
9
+ simpleMemory?: boolean;
9
10
  }
10
11
  export declare function serve(options: ServeOptions): Promise<void>;
11
12
  export {};
@@ -3,7 +3,7 @@ import { runMigrations } from "../db/migrate.js";
3
3
  import { OpenCodeCodingServer } from "../lib/opencode-coding-server.js";
4
4
  import { createApp } from "../server/index.js";
5
5
  import { CronScheduler } from "../server/cron.js";
6
- import { MemoryManager } from "../server/memory.js";
6
+ import { AgentOfficeFastMemory, AgentOfficeSimpleMemory } from "../server/memory.js";
7
7
  export async function serve(options) {
8
8
  const password = options.password;
9
9
  if (!password) {
@@ -44,20 +44,28 @@ export async function serve(options) {
44
44
  const agenticCodingServer = new OpenCodeCodingServer(options.opencodeUrl);
45
45
  console.log(`Connecting to OpenCode server at ${options.opencodeUrl}...`);
46
46
  const serverUrl = `http://${options.host}:${port}`;
47
- // Create memory manager and verify embedding model
48
- const memoryManager = new MemoryManager(options.memoryPath);
49
- console.log("Warming up embedding model...");
50
- try {
47
+ // Create memory manager
48
+ let memoryManager;
49
+ if (options.simpleMemory) {
50
+ console.log("Using simple SQLite+BM25 memory backend.");
51
+ memoryManager = new AgentOfficeSimpleMemory(options.memoryPath);
51
52
  await memoryManager.warmup();
52
- console.log("Embedding model ready.");
53
53
  }
54
- catch (err) {
55
- console.error("Embedding model failed to load:", err);
56
- console.error("Try deleting the model cache and restarting:");
57
- console.error(` rm -rf ${options.memoryPath}/.model-cache`);
58
- memoryManager.closeAll();
59
- await storage.close();
60
- process.exit(1);
54
+ else {
55
+ memoryManager = new AgentOfficeFastMemory(options.memoryPath);
56
+ console.log("Warming up embedding model...");
57
+ try {
58
+ await memoryManager.warmup();
59
+ console.log("Embedding model ready.");
60
+ }
61
+ catch (err) {
62
+ console.error("Embedding model failed to load:", err);
63
+ console.error("Try deleting the model cache and restarting:");
64
+ console.error(` rm -rf ${options.memoryPath}/.model-cache`);
65
+ memoryManager.closeAll();
66
+ await storage.close();
67
+ process.exit(1);
68
+ }
61
69
  }
62
70
  // Create cron scheduler
63
71
  const cronScheduler = new CronScheduler();
@@ -6,6 +6,7 @@ export declare function createCron(token: string, options: {
6
6
  name: string;
7
7
  schedule: string;
8
8
  message: string;
9
+ respondTo: string;
9
10
  timezone?: string;
10
11
  }): Promise<void>;
11
12
  export declare function deleteCron(token: string, cronId: number): Promise<void>;
@@ -96,7 +96,8 @@ export async function listCrons(token) {
96
96
  console.log(JSON.stringify(crons, null, 2));
97
97
  }
98
98
  export async function createCron(token, options) {
99
- const cron = await postWorker(token, "/worker/crons", options);
99
+ const finalMessage = `Action: ${options.message}\n\nWho to respond to when done: ${options.respondTo}`;
100
+ const cron = await postWorker(token, "/worker/crons", { name: options.name, schedule: options.schedule, message: finalMessage, timezone: options.timezone });
100
101
  console.log(JSON.stringify(cron, null, 2));
101
102
  }
102
103
  export async function deleteCron(token, cronId) {
@@ -21,6 +21,7 @@ export declare class AgentOfficePostgresqlStorage extends AgentOfficeStorageBase
21
21
  setConfig(key: string, value: string): Promise<void>;
22
22
  listMessagesForRecipient(name: string, unreadOnly: boolean): Promise<MessageRow[]>;
23
23
  listMessagesFromSender(name: string): Promise<MessageRow[]>;
24
+ countUnreadBySender(recipientName: string): Promise<Map<string, number>>;
24
25
  createMessageImpl(from: string, to: string, body: string): Promise<MessageRow>;
25
26
  markMessageAsRead(id: number): Promise<MessageRow | null>;
26
27
  markMessageAsInjected(id: number): Promise<void>;
@@ -131,6 +131,19 @@ export class AgentOfficePostgresqlStorage extends AgentOfficeStorageBase {
131
131
  ORDER BY created_at DESC
132
132
  `;
133
133
  }
134
+ async countUnreadBySender(recipientName) {
135
+ const rows = await this.sql `
136
+ SELECT from_name, COUNT(*) as count
137
+ FROM messages
138
+ WHERE to_name = ${recipientName} AND read = FALSE
139
+ GROUP BY from_name
140
+ `;
141
+ const result = new Map();
142
+ for (const row of rows) {
143
+ result.set(row.from_name, Number(row.count));
144
+ }
145
+ return result;
146
+ }
134
147
  async createMessageImpl(from, to, body) {
135
148
  const [row] = await this.sql `
136
149
  INSERT INTO messages (from_name, to_name, body)
@@ -22,6 +22,7 @@ export declare class AgentOfficeSqliteStorage extends AgentOfficeStorageBase {
22
22
  setConfig(key: string, value: string): Promise<void>;
23
23
  listMessagesForRecipient(name: string, unreadOnly: boolean): Promise<MessageRow[]>;
24
24
  listMessagesFromSender(name: string): Promise<MessageRow[]>;
25
+ countUnreadBySender(recipientName: string): Promise<Map<string, number>>;
25
26
  createMessageImpl(from: string, to: string, body: string): Promise<MessageRow>;
26
27
  markMessageAsRead(id: number): Promise<MessageRow | null>;
27
28
  markMessageAsInjected(id: number): Promise<void>;
@@ -172,6 +172,20 @@ export class AgentOfficeSqliteStorage extends AgentOfficeStorageBase {
172
172
  created_at: new Date(row.created_at + 'Z'), // Treat SQLite datetime as UTC
173
173
  }));
174
174
  }
175
+ async countUnreadBySender(recipientName) {
176
+ const stmt = this.db.prepare(`
177
+ SELECT from_name, COUNT(*) as count
178
+ FROM messages
179
+ WHERE to_name = ? AND read = FALSE
180
+ GROUP BY from_name
181
+ `);
182
+ const rows = stmt.all(recipientName);
183
+ const result = new Map();
184
+ for (const row of rows) {
185
+ result.set(row.from_name, row.count);
186
+ }
187
+ return result;
188
+ }
175
189
  async createMessageImpl(from, to, body) {
176
190
  const stmt = this.db.prepare(`
177
191
  INSERT INTO messages (from_name, to_name, body)
@@ -23,6 +23,7 @@ export declare abstract class AgentOfficeStorageBase implements AgentOfficeStora
23
23
  abstract setConfig(key: string, value: string): Promise<void>;
24
24
  abstract listMessagesForRecipient(name: string, unreadOnly: boolean): Promise<MessageRow[]>;
25
25
  abstract listMessagesFromSender(name: string): Promise<MessageRow[]>;
26
+ abstract countUnreadBySender(recipientName: string): Promise<Map<string, number>>;
26
27
  abstract markMessageAsRead(id: number): Promise<MessageRow | null>;
27
28
  abstract markMessageAsInjected(id: number): Promise<void>;
28
29
  abstract listCronJobs(): Promise<CronJobRow[]>;
@@ -32,6 +32,7 @@ export interface AgentOfficeStorage {
32
32
  setConfig(key: string, value: string): Promise<void>;
33
33
  listMessagesForRecipient(name: string, unreadOnly: boolean): Promise<MessageRow[]>;
34
34
  listMessagesFromSender(name: string): Promise<MessageRow[]>;
35
+ countUnreadBySender(recipientName: string): Promise<Map<string, number>>;
35
36
  createMessage(from: string, to: string, body: string): Promise<MessageRow>;
36
37
  markMessageAsRead(id: number): Promise<MessageRow | null>;
37
38
  markMessageAsInjected(id: number): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import type { AgentOfficeStorage } from "../db/index.js";
2
2
  import type { AgenticCodingServer } from "../lib/agentic-coding-server.js";
3
3
  import { CronScheduler } from "./cron.js";
4
- import type { MemoryManager } from "./memory.js";
5
- export declare function createApp(storage: AgentOfficeStorage, agenticCodingServer: AgenticCodingServer, password: string, serverUrl: string, cronScheduler: CronScheduler, memoryManager: MemoryManager): import("express-serve-static-core").Express;
4
+ import type { AgentOfficeMemory } from "./memory.js";
5
+ export declare function createApp(storage: AgentOfficeStorage, agenticCodingServer: AgenticCodingServer, password: string, serverUrl: string, cronScheduler: CronScheduler, memoryManager: AgentOfficeMemory): import("express-serve-static-core").Express;
@@ -1,24 +1,66 @@
1
- import { createAgentMemory, type MemoryEntry } from "fastmemory";
1
+ import { type MemoryEntry } from "fastmemory";
2
2
  export interface MemoryRecord {
3
3
  id: string;
4
4
  content: string;
5
5
  metadata: Record<string, unknown>;
6
6
  createdAt: string;
7
7
  }
8
- type MemoryStore = Awaited<ReturnType<typeof createAgentMemory>>;
8
+ export type AgentOfficeMemorySearchResult = MemoryEntry;
9
9
  /**
10
- * Manages per-session memory stores backed by fastmemory (SQLite + embeddings).
10
+ * Abstract base class for agent memory backends.
11
+ * Consumers should depend on this type rather than any concrete implementation.
12
+ */
13
+ export declare abstract class AgentOfficeMemory {
14
+ abstract addMemory(sessionName: string, content: string, metadata?: Record<string, unknown>): Promise<string>;
15
+ abstract searchMemories(sessionName: string, query: string, limit?: number): Promise<AgentOfficeMemorySearchResult[]>;
16
+ abstract listMemories(sessionName: string, limit?: number): MemoryRecord[];
17
+ abstract getMemory(sessionName: string, memoryId: string): MemoryRecord | null;
18
+ abstract deleteMemory(sessionName: string, memoryId: string): boolean;
19
+ abstract updateMemory(sessionName: string, memoryId: string, content: string, metadata?: Record<string, unknown>): Promise<boolean>;
20
+ abstract getStats(sessionName: string): {
21
+ total: number;
22
+ };
23
+ abstract warmup(): Promise<void>;
24
+ abstract closeAll(): void;
25
+ }
26
+ /**
27
+ * Lightweight memory backend using a single SQLite database file at
28
+ * <memoryPath>/simple-memory.db. All sessions share the same file; rows are
29
+ * partitioned by session_name. Full-text search is provided by SQLite's
30
+ * built-in FTS5 (BM25 ranking). No embeddings, no external dependencies
31
+ * beyond better-sqlite3.
32
+ *
33
+ * Activate with: agent-office serve --simple-memory
34
+ */
35
+ export declare class AgentOfficeSimpleMemory extends AgentOfficeMemory {
36
+ private basePath;
37
+ private db;
38
+ constructor(memoryPath: string);
39
+ private _migrate;
40
+ private newId;
41
+ private sanitizeFts5Query;
42
+ addMemory(sessionName: string, content: string, metadata?: Record<string, unknown>): Promise<string>;
43
+ searchMemories(sessionName: string, query: string, limit?: number): Promise<AgentOfficeMemorySearchResult[]>;
44
+ listMemories(sessionName: string, limit?: number): MemoryRecord[];
45
+ getMemory(sessionName: string, memoryId: string): MemoryRecord | null;
46
+ deleteMemory(sessionName: string, memoryId: string): boolean;
47
+ updateMemory(sessionName: string, memoryId: string, content: string, metadata?: Record<string, unknown>): Promise<boolean>;
48
+ getStats(sessionName: string): {
49
+ total: number;
50
+ };
51
+ warmup(): Promise<void>;
52
+ closeAll(): void;
53
+ }
54
+ /**
55
+ * Concrete AgentOfficeMemory implementation backed by fastmemory (SQLite + embeddings).
11
56
  * Each session gets its own .db file under <memoryPath>/<sessionName>.db
12
57
  */
13
- export declare class MemoryManager {
58
+ export declare class AgentOfficeFastMemory extends AgentOfficeMemory {
14
59
  private basePath;
15
60
  private stores;
16
61
  constructor(memoryPath: string);
17
62
  private dbPathFor;
18
- getStore(sessionName: string): Promise<MemoryStore>;
19
- /**
20
- * Add a memory for a session
21
- */
63
+ private getStore;
22
64
  addMemory(sessionName: string, content: string, metadata?: Record<string, unknown>): Promise<string>;
23
65
  /**
24
66
  * Sanitize a query string for FTS5 MATCH syntax.
@@ -27,29 +69,11 @@ export declare class MemoryManager {
27
69
  * treated as literals rather than FTS5 operators.
28
70
  */
29
71
  private sanitizeFts5Query;
30
- /**
31
- * Search memories using hybrid search (BM25 + vector + RRF)
32
- */
33
- searchMemories(sessionName: string, query: string, limit?: number): Promise<MemoryEntry[]>;
34
- /**
35
- * List all memories for a session (direct SQLite access since fastmemory has no list method)
36
- */
72
+ searchMemories(sessionName: string, query: string, limit?: number): Promise<AgentOfficeMemorySearchResult[]>;
37
73
  listMemories(sessionName: string, limit?: number): MemoryRecord[];
38
- /**
39
- * Get a single memory by ID
40
- */
41
74
  getMemory(sessionName: string, memoryId: string): MemoryRecord | null;
42
- /**
43
- * Delete a memory by ID (direct SQLite - fastmemory has no delete method)
44
- */
45
75
  deleteMemory(sessionName: string, memoryId: string): boolean;
46
- /**
47
- * Update a memory's content (direct SQLite, re-embeds via delete+add)
48
- */
49
76
  updateMemory(sessionName: string, memoryId: string, content: string, metadata?: Record<string, unknown>): Promise<boolean>;
50
- /**
51
- * Get stats for a session's memory store
52
- */
53
77
  getStats(sessionName: string): {
54
78
  total: number;
55
79
  };
@@ -61,4 +85,3 @@ export declare class MemoryManager {
61
85
  warmup(): Promise<void>;
62
86
  closeAll(): void;
63
87
  }
64
- export {};
@@ -3,13 +3,169 @@ import { mkdirSync, existsSync } from "fs";
3
3
  import { join, resolve } from "path";
4
4
  import Database from "better-sqlite3";
5
5
  /**
6
- * Manages per-session memory stores backed by fastmemory (SQLite + embeddings).
6
+ * Abstract base class for agent memory backends.
7
+ * Consumers should depend on this type rather than any concrete implementation.
8
+ */
9
+ export class AgentOfficeMemory {
10
+ }
11
+ // ── AgentOfficeSimpleMemory ───────────────────────────────────────────────────
12
+ /**
13
+ * Lightweight memory backend using a single SQLite database file at
14
+ * <memoryPath>/simple-memory.db. All sessions share the same file; rows are
15
+ * partitioned by session_name. Full-text search is provided by SQLite's
16
+ * built-in FTS5 (BM25 ranking). No embeddings, no external dependencies
17
+ * beyond better-sqlite3.
18
+ *
19
+ * Activate with: agent-office serve --simple-memory
20
+ */
21
+ export class AgentOfficeSimpleMemory extends AgentOfficeMemory {
22
+ basePath;
23
+ db;
24
+ constructor(memoryPath) {
25
+ super();
26
+ this.basePath = resolve(memoryPath);
27
+ if (!existsSync(this.basePath)) {
28
+ mkdirSync(this.basePath, { recursive: true });
29
+ }
30
+ const dbPath = join(this.basePath, "simple-memory.db");
31
+ this.db = new Database(dbPath);
32
+ this.db.pragma("journal_mode = WAL");
33
+ this.db.pragma("foreign_keys = ON");
34
+ this._migrate();
35
+ }
36
+ _migrate() {
37
+ this.db.exec(`
38
+ CREATE TABLE IF NOT EXISTS memories (
39
+ id TEXT PRIMARY KEY,
40
+ session_name TEXT NOT NULL,
41
+ content TEXT NOT NULL,
42
+ metadata TEXT NOT NULL DEFAULT '{}',
43
+ created_at TEXT NOT NULL
44
+ );
45
+
46
+ CREATE INDEX IF NOT EXISTS memories_session_idx ON memories (session_name);
47
+
48
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
49
+ content,
50
+ session_name UNINDEXED,
51
+ memory_id UNINDEXED,
52
+ content='memories',
53
+ content_rowid='rowid',
54
+ tokenize='porter unicode61'
55
+ );
56
+
57
+ CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
58
+ INSERT INTO memories_fts(rowid, content, session_name, memory_id)
59
+ VALUES (new.rowid, new.content, new.session_name, new.id);
60
+ END;
61
+
62
+ CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
63
+ INSERT INTO memories_fts(memories_fts, rowid, content, session_name, memory_id)
64
+ VALUES ('delete', old.rowid, old.content, old.session_name, old.id);
65
+ END;
66
+
67
+ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
68
+ INSERT INTO memories_fts(memories_fts, rowid, content, session_name, memory_id)
69
+ VALUES ('delete', old.rowid, old.content, old.session_name, old.id);
70
+ INSERT INTO memories_fts(rowid, content, session_name, memory_id)
71
+ VALUES (new.rowid, new.content, new.session_name, new.id);
72
+ END;
73
+ `);
74
+ }
75
+ newId() {
76
+ // Compact random UUID without hyphens
77
+ return crypto.randomUUID().replace(/-/g, "");
78
+ }
79
+ sanitizeFts5Query(query) {
80
+ return query
81
+ .split(/\s+/)
82
+ .filter((t) => t.length > 0)
83
+ .map((t) => `"${t.replace(/"/g, '""')}"`)
84
+ .join(" ");
85
+ }
86
+ async addMemory(sessionName, content, metadata = {}) {
87
+ const id = this.newId();
88
+ const createdAt = new Date().toISOString();
89
+ this.db.prepare(`INSERT INTO memories (id, session_name, content, metadata, created_at) VALUES (?, ?, ?, ?, ?)`).run(id, sessionName, content, JSON.stringify(metadata), createdAt);
90
+ return id;
91
+ }
92
+ async searchMemories(sessionName, query, limit = 10) {
93
+ if (!query.trim())
94
+ return [];
95
+ const safeQuery = this.sanitizeFts5Query(query);
96
+ const rows = this.db.prepare(`
97
+ SELECT m.id, m.content, m.metadata, m.created_at,
98
+ -bm25(memories_fts) AS score
99
+ FROM memories_fts
100
+ JOIN memories m ON m.id = memories_fts.memory_id
101
+ WHERE memories_fts MATCH ?
102
+ AND memories_fts.session_name = ?
103
+ ORDER BY score DESC
104
+ LIMIT ?
105
+ `).all(safeQuery, sessionName, limit);
106
+ return rows.map((r) => ({
107
+ id: r.id,
108
+ content: r.content,
109
+ metadata: r.metadata ? JSON.parse(r.metadata) : {},
110
+ createdAt: r.created_at,
111
+ score: r.score,
112
+ }));
113
+ }
114
+ listMemories(sessionName, limit = 50) {
115
+ const rows = this.db.prepare(`SELECT id, content, metadata, created_at FROM memories WHERE session_name = ? ORDER BY created_at DESC LIMIT ?`).all(sessionName, limit);
116
+ return rows.map((r) => ({
117
+ id: r.id,
118
+ content: r.content,
119
+ metadata: r.metadata ? JSON.parse(r.metadata) : {},
120
+ createdAt: r.created_at,
121
+ }));
122
+ }
123
+ getMemory(sessionName, memoryId) {
124
+ const row = this.db.prepare(`SELECT id, content, metadata, created_at FROM memories WHERE id = ? AND session_name = ?`).get(memoryId, sessionName);
125
+ if (!row)
126
+ return null;
127
+ return {
128
+ id: row.id,
129
+ content: row.content,
130
+ metadata: row.metadata ? JSON.parse(row.metadata) : {},
131
+ createdAt: row.created_at,
132
+ };
133
+ }
134
+ deleteMemory(sessionName, memoryId) {
135
+ const result = this.db.prepare(`DELETE FROM memories WHERE id = ? AND session_name = ?`).run(memoryId, sessionName);
136
+ return result.changes > 0;
137
+ }
138
+ async updateMemory(sessionName, memoryId, content, metadata) {
139
+ const existing = this.getMemory(sessionName, memoryId);
140
+ if (!existing)
141
+ return false;
142
+ const finalMetadata = metadata ?? existing.metadata;
143
+ const result = this.db.prepare(`UPDATE memories SET content = ?, metadata = ? WHERE id = ? AND session_name = ?`).run(content, JSON.stringify(finalMetadata), memoryId, sessionName);
144
+ return result.changes > 0;
145
+ }
146
+ getStats(sessionName) {
147
+ const row = this.db.prepare(`SELECT COUNT(*) as total FROM memories WHERE session_name = ?`).get(sessionName);
148
+ return { total: row.total };
149
+ }
150
+ async warmup() {
151
+ // No model to warm up — DB is already open and ready.
152
+ }
153
+ closeAll() {
154
+ try {
155
+ this.db.close();
156
+ }
157
+ catch { /* ignore */ }
158
+ }
159
+ }
160
+ /**
161
+ * Concrete AgentOfficeMemory implementation backed by fastmemory (SQLite + embeddings).
7
162
  * Each session gets its own .db file under <memoryPath>/<sessionName>.db
8
163
  */
9
- export class MemoryManager {
164
+ export class AgentOfficeFastMemory extends AgentOfficeMemory {
10
165
  basePath;
11
166
  stores = new Map();
12
167
  constructor(memoryPath) {
168
+ super();
13
169
  this.basePath = resolve(memoryPath);
14
170
  if (!existsSync(this.basePath)) {
15
171
  mkdirSync(this.basePath, { recursive: true });
@@ -34,9 +190,6 @@ export class MemoryManager {
34
190
  this.stores.set(sessionName, store);
35
191
  return store;
36
192
  }
37
- /**
38
- * Add a memory for a session
39
- */
40
193
  async addMemory(sessionName, content, metadata = {}) {
41
194
  const store = await this.getStore(sessionName);
42
195
  return await store.add(content, metadata);
@@ -54,17 +207,11 @@ export class MemoryManager {
54
207
  .map((t) => `"${t.replace(/"/g, '""')}"`)
55
208
  .join(" ");
56
209
  }
57
- /**
58
- * Search memories using hybrid search (BM25 + vector + RRF)
59
- */
60
210
  async searchMemories(sessionName, query, limit = 10) {
61
211
  const store = await this.getStore(sessionName);
62
212
  const safeQuery = this.sanitizeFts5Query(query);
63
213
  return await store.searchHybrid(safeQuery, limit);
64
214
  }
65
- /**
66
- * List all memories for a session (direct SQLite access since fastmemory has no list method)
67
- */
68
215
  listMemories(sessionName, limit = 50) {
69
216
  const dbPath = this.dbPathFor(sessionName);
70
217
  if (!existsSync(dbPath))
@@ -84,9 +231,6 @@ export class MemoryManager {
84
231
  db.close();
85
232
  }
86
233
  }
87
- /**
88
- * Get a single memory by ID
89
- */
90
234
  getMemory(sessionName, memoryId) {
91
235
  const dbPath = this.dbPathFor(sessionName);
92
236
  if (!existsSync(dbPath))
@@ -108,9 +252,6 @@ export class MemoryManager {
108
252
  db.close();
109
253
  }
110
254
  }
111
- /**
112
- * Delete a memory by ID (direct SQLite - fastmemory has no delete method)
113
- */
114
255
  deleteMemory(sessionName, memoryId) {
115
256
  const dbPath = this.dbPathFor(sessionName);
116
257
  if (!existsSync(dbPath))
@@ -125,9 +266,6 @@ export class MemoryManager {
125
266
  db.close();
126
267
  }
127
268
  }
128
- /**
129
- * Update a memory's content (direct SQLite, re-embeds via delete+add)
130
- */
131
269
  async updateMemory(sessionName, memoryId, content, metadata) {
132
270
  const existing = this.getMemory(sessionName, memoryId);
133
271
  if (!existing)
@@ -140,14 +278,10 @@ export class MemoryManager {
140
278
  this.stores.delete(sessionName);
141
279
  }
142
280
  const store = await this.getStore(sessionName);
143
- // Re-add with new content but preserve metadata if not provided
144
281
  const finalMetadata = metadata ?? existing.metadata;
145
282
  await store.add(content, finalMetadata);
146
283
  return true;
147
284
  }
148
- /**
149
- * Get stats for a session's memory store
150
- */
151
285
  getStats(sessionName) {
152
286
  const dbPath = this.dbPathFor(sessionName);
153
287
  if (!existsSync(dbPath))
@@ -177,7 +311,7 @@ export class MemoryManager {
177
311
  });
178
312
  try {
179
313
  // Force an embedding by adding and searching
180
- const id = await store.add("warmup test memory");
314
+ await store.add("warmup test memory");
181
315
  const results = await store.searchHybrid("warmup test", 1);
182
316
  if (results.length === 0) {
183
317
  throw new Error("Embedding model warmup: search returned no results");
@@ -2,7 +2,7 @@ import { Router } from "express";
2
2
  import type { AgentOfficeStorage } from "../db/index.js";
3
3
  import type { AgenticCodingServer } from "../lib/agentic-coding-server.js";
4
4
  import { CronScheduler } from "./cron.js";
5
- import type { MemoryManager } from "./memory.js";
5
+ import type { AgentOfficeMemory } from "./memory.js";
6
6
  /**
7
7
  * Build the persistent system-prompt briefing for a worker session.
8
8
  * This is injected as the `system` field on every `promptAsync` call so the
@@ -10,5 +10,5 @@ import type { MemoryManager } from "./memory.js";
10
10
  * without consuming a user-message turn.
11
11
  */
12
12
  export declare function generateSystemPrompt(name: string, status: string | null, humanName: string, humanDescription: string, token: string): string;
13
- export declare function createRouter(storage: AgentOfficeStorage, agenticCodingServer: AgenticCodingServer, serverUrl: string, scheduler: CronScheduler, memoryManager: MemoryManager): Router;
14
- export declare function createWorkerRouter(storage: AgentOfficeStorage, agenticCodingServer: AgenticCodingServer, serverUrl: string, memoryManager: MemoryManager): Router;
13
+ export declare function createRouter(storage: AgentOfficeStorage, agenticCodingServer: AgenticCodingServer, serverUrl: string, scheduler: CronScheduler, memoryManager: AgentOfficeMemory): Router;
14
+ export declare function createWorkerRouter(storage: AgentOfficeStorage, agenticCodingServer: AgenticCodingServer, serverUrl: string, memoryManager: AgentOfficeMemory): Router;
@@ -4,17 +4,12 @@ const MAIL_INJECTION_BLURB = [
4
4
  ``,
5
5
  `---`,
6
6
  `You have a new message. Please review the injected message above and respond accordingly.`,
7
- `IMPORTANT: When reading or responding, note that dollar signs ($) and other special`,
8
- `characters may be interpreted as markdown. The sender may have included them but they`,
9
- `could appear differently in your session view. Interpret context accordingly.`,
10
7
  ``,
11
8
  `When responding to the sender:`,
12
9
  `- Use the \`agent-office worker send-message\` tool so they can see your reply`,
13
10
  `- Avoid excessive length - keep responses concise`,
14
- `- Use markdown front-matter with a "choices" array if offering options`,
15
- ``,
16
- `Tip: For currency or prices, use code blocks. Example: put numbers in single or`,
17
- `double quotes to preserve formatting characters like dollar signs.`,
11
+ `- Feel free to use markdown formatting`,
12
+ `- IMPORTANT: Remember when using bash commands certain characters (like dollar signs) may need to be escaped or wrapped in quotes`,
18
13
  ].join("\n");
19
14
  /**
20
15
  * Build the persistent system-prompt briefing for a worker session.
@@ -78,6 +73,23 @@ export function generateSystemPrompt(name, status, humanName, humanDescription,
78
73
  ` agent-office worker cron \\`,
79
74
  ` ${token}`,
80
75
  ``,
76
+ ` Create a cron job (scheduled task)`,
77
+ ` agent-office worker cron create \\`,
78
+ ` --name <job-name> \\`,
79
+ ` --schedule "<cron-expression>" \\`,
80
+ ` --message "<action-to-perform>" \\`,
81
+ ` --respond-to "<who-to-respond-to-when-done>" \\`,
82
+ ` ${token}`,
83
+ ``,
84
+ ` Example: Daily standup reminder at 9am`,
85
+ ` --schedule "0 9 * * *" \\`,
86
+ ` --message "Prepare your standup update" \\`,
87
+ ` --respond-to "${humanName} in the standup channel"`,
88
+ ``,
89
+ ` The final injected message will be formatted as:`,
90
+ ` Action: <message>`,
91
+ ` Who to respond to when done: <respond-to>`,
92
+ ``,
81
93
  ` Store a memory (persistent across sessions)`,
82
94
  ` agent-office worker memory add \\`,
83
95
  ` --content "your memory here" \\`,
@@ -192,6 +204,27 @@ export function createRouter(storage, agenticCodingServer, serverUrl, scheduler,
192
204
  res.status(500).json({ error: "Internal server error" });
193
205
  }
194
206
  });
207
+ router.get("/coworkers", async (_req, res) => {
208
+ try {
209
+ const sessions = await storage.listSessions();
210
+ const humanName = await storage.getConfig('human_name') ?? "Human";
211
+ const unreadCounts = await storage.countUnreadBySender(humanName);
212
+ const coworkers = [
213
+ { name: humanName, status: null, isHuman: true, unreadMessages: 0 },
214
+ ...sessions.map(s => ({
215
+ name: s.name,
216
+ status: s.status,
217
+ isHuman: false,
218
+ unreadMessages: unreadCounts.get(s.name) ?? 0
219
+ }))
220
+ ];
221
+ res.json(coworkers);
222
+ }
223
+ catch (err) {
224
+ console.error("GET /coworkers error:", err);
225
+ res.status(500).json({ error: "Internal server error" });
226
+ }
227
+ });
195
228
  router.post("/sessions", async (req, res) => {
196
229
  const { name, agent: agentArg } = req.body;
197
230
  if (!name || typeof name !== "string" || !name.trim()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-office",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "An office for your AI agents",
5
5
  "type": "module",
6
6
  "license": "MIT",