Skip to content

Commit a23646d

Browse files
committed
Memory and Assistant updates
1 parent a1cac30 commit a23646d

16 files changed

Lines changed: 562 additions & 12 deletions

config.default.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
"paused": false,
1616
"requireApprovalForFacts": false
1717
},
18+
"agentLoop": {
19+
"chatHistorySearch": true,
20+
"subagentMissions": true
21+
},
1822
"heartbeat": [],
1923
"skills": { "enabledIds": [] },
2024
"searxng": { "url": "", "enabled": false },

data/chat_history_fts.db

48 KB
Binary file not shown.

lib/agentLoopTools.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
'use strict';
2+
3+
const { getConfig } = require('./config.js');
4+
const chatHistorySearch = require('./chatHistorySearch.js');
5+
const coordinator = require('./commandCenter/coordinator.js');
6+
7+
function agentLoopFlags() {
8+
const cfg = getConfig();
9+
const a = cfg.agentLoop && typeof cfg.agentLoop === 'object' ? cfg.agentLoop : {};
10+
return {
11+
chatHistorySearch: a.chatHistorySearch !== false,
12+
subagentMissions: a.subagentMissions !== false
13+
};
14+
}
15+
16+
function getExtraToolDefinitions({ isProjectChat = false } = {}) {
17+
const flags = agentLoopFlags();
18+
const out = [];
19+
if (!isProjectChat && flags.chatHistorySearch) {
20+
out.push({
21+
type: 'function',
22+
function: {
23+
name: 'search_chat_history',
24+
description:
25+
'Search the user\'s past chat messages (all threads for this account) using full-text search. Use when the user asks what was said before, to recall a decision, name, link, or topic from earlier conversations. Returns short snippets with chat title and role—not full transcripts.',
26+
parameters: {
27+
type: 'object',
28+
required: ['query'],
29+
properties: {
30+
query: { type: 'string', description: 'Keywords or phrase to find (e.g. "docker compose", "API key")' },
31+
chat_id: { type: 'string', description: 'Optional: limit search to one chat thread id if known' }
32+
}
33+
}
34+
}
35+
});
36+
}
37+
if (!isProjectChat && flags.subagentMissions) {
38+
out.push({
39+
type: 'function',
40+
function: {
41+
name: 'dispatch_subagent_mission',
42+
description:
43+
'Spawn a Command Center mission: break work into subtasks and queue background agents (research, coder, etc.). Use when the user wants parallel/delegated work, a multi-step research or coding push, or explicitly asks for "agents" or "mission mode". Tell them tasks appear on /autoagent and /command-center.',
44+
parameters: {
45+
type: 'object',
46+
required: ['mission'],
47+
properties: {
48+
mission: { type: 'string', description: 'Clear goal for the coordinator to triage into subtasks' }
49+
}
50+
}
51+
}
52+
});
53+
}
54+
return out;
55+
}
56+
57+
/**
58+
* @param {string} name
59+
* @param {object} args
60+
* @param {{ chatOwnerUser: string, missionScopeUser: string }} ctx
61+
*/
62+
async function executeExtra(name, args, ctx) {
63+
const chatOwner = String(ctx.chatOwnerUser || '').trim();
64+
const missionUser = String(ctx.missionScopeUser != null ? ctx.missionScopeUser : ctx.chatOwnerUser || '').trim();
65+
66+
if (name === 'search_chat_history') {
67+
const q = args.query != null ? String(args.query).trim() : '';
68+
const chatId = args.chat_id != null ? String(args.chat_id).trim() : '';
69+
if (!q) return 'No search query provided.';
70+
if (!chatOwner) return 'Cannot search: no user scope.';
71+
const rows = await chatHistorySearch.search(chatOwner, q, { limit: 18, chatId: chatId || undefined });
72+
if (rows.length === 0) {
73+
return 'No matches in indexed chat history. (New messages are indexed after they are saved; very recent turns may not appear yet.)';
74+
}
75+
return rows
76+
.map((r, i) => {
77+
const title = r.chatTitle || 'Chat';
78+
return `${i + 1}. [${title}] #${r.msgIdx} (${r.role}) ${r.snippet}${r.snippet.length >= 320 ? '…' : ''}`;
79+
})
80+
.join('\n');
81+
}
82+
83+
if (name === 'dispatch_subagent_mission') {
84+
const mission = args.mission != null ? String(args.mission).trim() : '';
85+
if (!mission) return 'Error: mission text is required.';
86+
const out = await coordinator.dispatchMission(mission, { user: missionUser });
87+
const lines = [
88+
`Mission: ${out.title} (${out.missionId})`,
89+
out.summary ? `Summary: ${out.summary}` : '',
90+
'Queued tasks:',
91+
...out.tasks.map((t) => ` • ${t.title} [${t.id}] status=${t.status} role=${t.role || '—'}`)
92+
].filter(Boolean);
93+
return lines.join('\n');
94+
}
95+
96+
return 'Unknown agent-loop tool: ' + name;
97+
}
98+
99+
function handles(name) {
100+
return name === 'search_chat_history' || name === 'dispatch_subagent_mission';
101+
}
102+
103+
module.exports = {
104+
getExtraToolDefinitions,
105+
executeExtra,
106+
handles
107+
};

lib/chatHistorySearch.js

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
'use strict';
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
const sqlite3 = require('sqlite3').verbose();
6+
const chatStore = require('./chatStore.js');
7+
8+
const DATA_DIR = path.join(__dirname, '..', 'data');
9+
const CHATS_DIR = path.join(DATA_DIR, 'chats');
10+
const DB_PATH = path.join(DATA_DIR, 'chat_history_fts.db');
11+
12+
const MAX_BODY = 12000;
13+
const SNIPPET = 320;
14+
15+
let dbPromise = null;
16+
17+
function ensureDataDir() {
18+
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
19+
}
20+
21+
function openDb() {
22+
ensureDataDir();
23+
return new Promise((resolve, reject) => {
24+
const db = new sqlite3.Database(DB_PATH, (err) => {
25+
if (err) return reject(err);
26+
db.serialize(() => {
27+
db.run(
28+
`CREATE VIRTUAL TABLE IF NOT EXISTS chat_hist_fts USING fts5(
29+
username UNINDEXED,
30+
chat_id UNINDEXED,
31+
msg_idx UNINDEXED,
32+
role UNINDEXED,
33+
chat_title,
34+
body,
35+
tokenize = 'porter unicode61'
36+
)`,
37+
(e2) => {
38+
if (e2) return reject(e2);
39+
resolve(db);
40+
}
41+
);
42+
});
43+
});
44+
});
45+
}
46+
47+
function getDb() {
48+
if (!dbPromise) dbPromise = openDb();
49+
return dbPromise;
50+
}
51+
52+
function run(db, sql, params = []) {
53+
return new Promise((resolve, reject) => {
54+
db.run(sql, params, function (err) {
55+
if (err) return reject(err);
56+
resolve(this);
57+
});
58+
});
59+
}
60+
61+
function all(db, sql, params = []) {
62+
return new Promise((resolve, reject) => {
63+
db.all(sql, params, (err, rows) => {
64+
if (err) return reject(err);
65+
resolve(rows || []);
66+
});
67+
});
68+
}
69+
70+
/** Build a safe FTS5 MATCH string from user input (token AND). */
71+
function ftsMatchQuery(raw) {
72+
const s = String(raw || '').trim().slice(0, 400);
73+
if (!s) return '';
74+
const parts = s.split(/\s+/).filter(Boolean).slice(0, 14);
75+
if (parts.length === 0) return '';
76+
return parts
77+
.map((p) => {
78+
const esc = p.replace(/"/g, '""');
79+
return `body : "${esc}"`;
80+
})
81+
.join(' AND ');
82+
}
83+
84+
function normalizeUsername(u) {
85+
return chatStore.safeUsername(u);
86+
}
87+
88+
/**
89+
* Replace FTS rows for one chat with current messages.
90+
*/
91+
async function reindexChat(username, chatId) {
92+
const user = normalizeUsername(username);
93+
const chat = chatStore.getChat(user, chatId);
94+
if (!chat) return;
95+
const db = await getDb();
96+
await run(db, 'DELETE FROM chat_hist_fts WHERE username = ? AND chat_id = ?', [user, chatId]);
97+
const title = String(chat.title || 'Chat').slice(0, 200);
98+
const messages = Array.isArray(chat.messages) ? chat.messages : [];
99+
let idx = 0;
100+
for (const m of messages) {
101+
const role = String(m && m.role ? m.role : '').slice(0, 32);
102+
const body = String(m && m.content != null ? m.content : '').slice(0, MAX_BODY);
103+
if (!body.trim()) {
104+
idx++;
105+
continue;
106+
}
107+
await run(
108+
db,
109+
`INSERT INTO chat_hist_fts (username, chat_id, msg_idx, role, chat_title, body) VALUES (?, ?, ?, ?, ?, ?)`,
110+
[user, chatId, idx, role, title, body]
111+
);
112+
idx++;
113+
}
114+
}
115+
116+
async function removeChat(username, chatId) {
117+
const user = normalizeUsername(username);
118+
const db = await getDb();
119+
await run(db, 'DELETE FROM chat_hist_fts WHERE username = ? AND chat_id = ?', [user, chatId]);
120+
}
121+
122+
/**
123+
* @param {string} username - chat store user id
124+
* @param {string} query
125+
* @param {{ limit?: number, chatId?: string }} [opts]
126+
* @returns {Promise<Array<{ chatId: string, chatTitle: string, role: string, msgIdx: number, snippet: string }>>}
127+
*/
128+
async function search(username, query, opts = {}) {
129+
const user = normalizeUsername(username);
130+
const match = ftsMatchQuery(query);
131+
if (!match) return [];
132+
const limit = Math.min(40, Math.max(1, Number(opts.limit) || 15));
133+
const db = await getDb();
134+
const chatId = opts.chatId ? String(opts.chatId).trim() : '';
135+
const sql = chatId
136+
? `SELECT username, chat_id, msg_idx, role, chat_title, body FROM chat_hist_fts WHERE chat_hist_fts MATCH ? AND username = ? AND chat_id = ? LIMIT ?`
137+
: `SELECT username, chat_id, msg_idx, role, chat_title, body FROM chat_hist_fts WHERE chat_hist_fts MATCH ? AND username = ? LIMIT ?`;
138+
const params = chatId ? [match, user, chatId, limit] : [match, user, limit];
139+
const rows = await all(db, sql, params);
140+
return rows.map((r) => ({
141+
chatId: r.chat_id,
142+
chatTitle: r.chat_title || 'Chat',
143+
role: r.role || '',
144+
msgIdx: Number(r.msg_idx) || 0,
145+
snippet: String(r.body || '').replace(/\s+/g, ' ').trim().slice(0, SNIPPET)
146+
}));
147+
}
148+
149+
async function reindexAllUsers() {
150+
ensureDataDir();
151+
if (!fs.existsSync(CHATS_DIR)) return;
152+
const files = fs.readdirSync(CHATS_DIR).filter((f) => f.endsWith('.json'));
153+
for (const file of files) {
154+
const safeName = file.slice(0, -5);
155+
let data;
156+
try {
157+
data = JSON.parse(fs.readFileSync(path.join(CHATS_DIR, file), 'utf8'));
158+
} catch (_) {
159+
continue;
160+
}
161+
if (!data || !Array.isArray(data.chats)) continue;
162+
for (const c of data.chats) {
163+
if (c && c.id) {
164+
try {
165+
await reindexChat(safeName, c.id);
166+
} catch (e) {
167+
/* ignore per-chat errors */
168+
}
169+
}
170+
}
171+
}
172+
}
173+
174+
module.exports = {
175+
search,
176+
reindexChat,
177+
removeChat,
178+
reindexAllUsers,
179+
ftsMatchQuery
180+
};

lib/chatRunner.js

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const emailLib = require('./email.js');
1111
const logger = require('./logger.js');
1212
const structuredMemory = require('./structuredMemory.js');
1313
const { executeSchedulerTool, getSchedulerToolDefinitions } = require('./toolHandlers.js');
14+
const agentLoopTools = require('./agentLoopTools.js');
1415

1516
/**
1617
* Run one assistant turn (non-streaming). Uses same tools and logic as the web chat.
@@ -114,7 +115,17 @@ async function runChatTurn(options) {
114115
}
115116
}
116117
};
117-
const tools = [appendMemoryTool, getMemoryTool, setMemoryTool, ...(webSearchTool ? [webSearchTool] : []), fetchUrlTool, ...(sendEmailTool ? [sendEmailTool] : []), ...skillTools, ...getSchedulerToolDefinitions()];
118+
const tools = [
119+
appendMemoryTool,
120+
getMemoryTool,
121+
setMemoryTool,
122+
...(webSearchTool ? [webSearchTool] : []),
123+
fetchUrlTool,
124+
...(sendEmailTool ? [sendEmailTool] : []),
125+
...skillTools,
126+
...getSchedulerToolDefinitions(),
127+
...agentLoopTools.getExtraToolDefinitions({ isProjectChat: false })
128+
];
118129

119130
let content = '';
120131
let tokenStats = null;
@@ -197,8 +208,13 @@ async function runChatTurn(options) {
197208
structuredMemory.setMemory(key, value, memoryScopeUser);
198209
toolContent = `Stored structured memory for key \"${key}\".`;
199210
}
200-
} else if (['create_skill', 'add_heartbeat_job', 'update_skill', 'update_heartbeat_job', 'list_heartbeat_jobs'].includes(name)) {
211+
} else if (['create_skill', 'add_heartbeat_job', 'update_skill', 'update_heartbeat_job', 'list_heartbeat_jobs', 'delete_heartbeat_job'].includes(name)) {
201212
toolContent = await executeSchedulerTool(name, args);
213+
} else if (agentLoopTools.handles(name)) {
214+
toolContent = await agentLoopTools.executeExtra(name, args, {
215+
chatOwnerUser: user,
216+
missionScopeUser: memoryScopeUser
217+
});
202218
} else {
203219
const result = await skillsLib.runSkill(name, args);
204220
toolContent = typeof result === 'object' ? JSON.stringify(result) : String(result);

lib/chatStore.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ const fs = require('fs');
22
const path = require('path');
33
const crypto = require('crypto');
44

5+
function scheduleChatSearchReindex(username, chatId) {
6+
if (!chatId) return;
7+
setImmediate(() => {
8+
try {
9+
const chatHistorySearch = require('./chatHistorySearch.js');
10+
chatHistorySearch.reindexChat(username, chatId).catch(() => {});
11+
} catch (_) {}
12+
});
13+
}
14+
515
const DATA_DIR = path.join(__dirname, '..', 'data');
616
const CHATS_DIR = path.join(DATA_DIR, 'chats');
717

@@ -155,6 +165,7 @@ function saveChatMessages(username, chatId, messages) {
155165
chat.title = String(firstUser.content || '').trim().slice(0, 48).trim() || 'New chat';
156166
}
157167
saveRaw(username, data);
168+
scheduleChatSearchReindex(username, chatId);
158169
}
159170

160171
function deleteChat(username, chatId) {
@@ -166,6 +177,12 @@ function deleteChat(username, chatId) {
166177
data.currentChatId = data.chats.length ? data.chats[0].id : null;
167178
}
168179
saveRaw(username, data);
180+
setImmediate(() => {
181+
try {
182+
const chatHistorySearch = require('./chatHistorySearch.js');
183+
chatHistorySearch.removeChat(username, chatId).catch(() => {});
184+
} catch (_) {}
185+
});
169186
return true;
170187
}
171188

@@ -181,6 +198,7 @@ function updateChat(username, chatId, updates) {
181198
}
182199
chat.updatedAt = new Date().toISOString();
183200
saveRaw(username, data);
201+
if (updates.title !== undefined) scheduleChatSearchReindex(username, chatId);
184202
return true;
185203
}
186204

lib/config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ function updateConfig(updates) {
102102
if (updates.agent !== undefined) {
103103
config.agent = { ...(config.agent || {}), ...updates.agent };
104104
}
105+
if (updates.agentLoop !== undefined && typeof updates.agentLoop === 'object') {
106+
config.agentLoop = { ...(config.agentLoop || {}), ...updates.agentLoop };
107+
}
105108
if (updates.timezone !== undefined) {
106109
config.timezone = typeof updates.timezone === 'string' ? updates.timezone.trim() : '';
107110
}

0 commit comments

Comments
 (0)