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 +3 -3
- package/dist/cli.js +8 -15
- package/dist/commands/communicator.d.ts +2 -2
- package/dist/commands/communicator.js +175 -40
- package/dist/commands/screensaver.d.ts +1 -1
- package/dist/commands/screensaver.js +47 -3
- package/dist/commands/serve.d.ts +1 -0
- package/dist/commands/serve.js +21 -13
- package/dist/commands/worker.d.ts +1 -0
- package/dist/commands/worker.js +2 -1
- package/dist/db/postgresql-storage.d.ts +1 -0
- package/dist/db/postgresql-storage.js +13 -0
- package/dist/db/sqlite-storage.d.ts +1 -0
- package/dist/db/sqlite-storage.js +14 -0
- package/dist/db/storage-base.d.ts +1 -0
- package/dist/db/storage.d.ts +1 -0
- package/dist/server/index.d.ts +2 -2
- package/dist/server/memory.d.ts +51 -28
- package/dist/server/memory.js +159 -25
- package/dist/server/routes.d.ts +3 -3
- package/dist/server/routes.js +40 -7
- package/package.json +1 -1
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" --
|
|
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
|
-
--
|
|
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" --
|
|
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
|
|
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("--
|
|
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 (
|
|
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(
|
|
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("--
|
|
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>", "
|
|
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
|
-
|
|
3
|
+
password: string;
|
|
4
4
|
host: string;
|
|
5
5
|
port: string;
|
|
6
6
|
}
|
|
7
|
-
export declare function appCoworkerChatWeb(
|
|
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,
|
|
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 ${
|
|
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,
|
|
19
|
-
const cfg = await apiFetch(agentUrl,
|
|
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
|
|
23
|
-
|
|
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,
|
|
30
|
+
async function fetchMessages(agentUrl, password, humanName, coworker) {
|
|
28
31
|
const [sent, received] = await Promise.all([
|
|
29
|
-
apiFetch(agentUrl,
|
|
30
|
-
apiFetch(agentUrl,
|
|
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,
|
|
41
|
-
await apiFetch(agentUrl,
|
|
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
|
|
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(
|
|
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(
|
|
464
|
+
<div class="avatar">${selected ? escapeHtml(selected.charAt(0).toUpperCase()) : '?'}</div>
|
|
422
465
|
<div class="header-info">
|
|
423
|
-
<div class="
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
593
|
-
const { url: agentUrl,
|
|
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,
|
|
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 --
|
|
672
|
+
console.error("Check that agent-office serve is running and --password is correct.");
|
|
614
673
|
}
|
|
615
|
-
console.log(`Communicator: chatting as "${humanName}"
|
|
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 (
|
|
716
|
+
app.get("/", async (req, res) => {
|
|
621
717
|
try {
|
|
622
|
-
|
|
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 (
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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 (
|
|
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,
|
|
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 (
|
|
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,
|
|
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
|
import express from "express";
|
|
2
2
|
export async function appScreensaver(options) {
|
|
3
|
-
const { url: agentUrl,
|
|
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 ${
|
|
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
|
|
package/dist/commands/serve.d.ts
CHANGED
package/dist/commands/serve.js
CHANGED
|
@@ -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 {
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
console.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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();
|
package/dist/commands/worker.js
CHANGED
|
@@ -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
|
|
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[]>;
|
package/dist/db/storage.d.ts
CHANGED
|
@@ -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>;
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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 {
|
|
5
|
-
export declare function createApp(storage: AgentOfficeStorage, agenticCodingServer: AgenticCodingServer, password: string, serverUrl: string, cronScheduler: CronScheduler, memoryManager:
|
|
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;
|
package/dist/server/memory.d.ts
CHANGED
|
@@ -1,24 +1,66 @@
|
|
|
1
|
-
import {
|
|
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
|
|
8
|
+
export type AgentOfficeMemorySearchResult = MemoryEntry;
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
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
|
|
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
|
|
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 {};
|
package/dist/server/memory.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
|
|
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");
|
package/dist/server/routes.d.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
14
|
-
export declare function createWorkerRouter(storage: AgentOfficeStorage, agenticCodingServer: AgenticCodingServer, serverUrl: string, memoryManager:
|
|
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;
|
package/dist/server/routes.js
CHANGED
|
@@ -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
|
-
`-
|
|
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()) {
|