@stevederico/dotbot 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +136 -0
- package/README.md +380 -0
- package/bin/dotbot.js +461 -0
- package/core/agent.js +779 -0
- package/core/compaction.js +261 -0
- package/core/cron_handler.js +262 -0
- package/core/events.js +229 -0
- package/core/failover.js +193 -0
- package/core/gptoss_tool_parser.js +173 -0
- package/core/init.js +154 -0
- package/core/normalize.js +324 -0
- package/core/trigger_handler.js +148 -0
- package/docs/core.md +103 -0
- package/docs/protected-files.md +59 -0
- package/examples/sqlite-session-example.js +69 -0
- package/index.js +341 -0
- package/observer/index.js +164 -0
- package/package.json +42 -0
- package/storage/CronStore.js +145 -0
- package/storage/EventStore.js +71 -0
- package/storage/MemoryStore.js +175 -0
- package/storage/MongoAdapter.js +291 -0
- package/storage/MongoCronAdapter.js +347 -0
- package/storage/MongoTaskAdapter.js +242 -0
- package/storage/MongoTriggerAdapter.js +158 -0
- package/storage/SQLiteAdapter.js +382 -0
- package/storage/SQLiteCronAdapter.js +562 -0
- package/storage/SQLiteEventStore.js +300 -0
- package/storage/SQLiteMemoryAdapter.js +240 -0
- package/storage/SQLiteTaskAdapter.js +419 -0
- package/storage/SQLiteTriggerAdapter.js +262 -0
- package/storage/SessionStore.js +149 -0
- package/storage/TaskStore.js +100 -0
- package/storage/TriggerStore.js +90 -0
- package/storage/cron_constants.js +48 -0
- package/storage/index.js +21 -0
- package/tools/appgen.js +311 -0
- package/tools/browser.js +634 -0
- package/tools/code.js +101 -0
- package/tools/events.js +145 -0
- package/tools/files.js +201 -0
- package/tools/images.js +253 -0
- package/tools/index.js +97 -0
- package/tools/jobs.js +159 -0
- package/tools/memory.js +332 -0
- package/tools/messages.js +135 -0
- package/tools/notify.js +42 -0
- package/tools/tasks.js +404 -0
- package/tools/triggers.js +159 -0
- package/tools/weather.js +82 -0
- package/tools/web.js +283 -0
- package/utils/providers.js +136 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Observer — in-memory snapshot store and agent tool.
|
|
3
|
+
*
|
|
4
|
+
* The frontend pushes structured browser-state snapshots via POST /api/agent/observer.
|
|
5
|
+
* The agent reads the latest snapshot via the `browser_observe` tool to understand
|
|
6
|
+
* what the user is currently doing in the browser.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const SNAPSHOT_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
10
|
+
|
|
11
|
+
/** @type {Map<string, Object>} userID → { ...snapshot, receivedAt } */
|
|
12
|
+
const snapshots = new Map();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Store the latest browser snapshot for a user.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} userID - Authenticated user ID
|
|
18
|
+
* @param {Object} snapshot - Structured browser state from the frontend
|
|
19
|
+
*/
|
|
20
|
+
export function storeSnapshot(userID, snapshot) {
|
|
21
|
+
snapshots.set(userID, { ...snapshot, receivedAt: Date.now() });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Retrieve the latest snapshot for a user, or null if stale/missing.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} userID - Authenticated user ID
|
|
28
|
+
* @returns {Object|null} Snapshot with receivedAt, or null
|
|
29
|
+
*/
|
|
30
|
+
export function getSnapshot(userID) {
|
|
31
|
+
const entry = snapshots.get(userID);
|
|
32
|
+
if (!entry) return null;
|
|
33
|
+
if (Date.now() - entry.receivedAt > SNAPSHOT_TTL_MS) {
|
|
34
|
+
snapshots.delete(userID);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return entry;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Remove a user's snapshot (cleanup on logout, etc.).
|
|
42
|
+
*
|
|
43
|
+
* @param {string} userID - Authenticated user ID
|
|
44
|
+
*/
|
|
45
|
+
export function clearSnapshot(userID) {
|
|
46
|
+
snapshots.delete(userID);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Format a snapshot into plain-text for LLM consumption.
|
|
51
|
+
*
|
|
52
|
+
* @param {Object} snap - Snapshot object from the store
|
|
53
|
+
* @param {boolean} includeActions - Whether to include recent actions
|
|
54
|
+
* @returns {string} Human-readable state description
|
|
55
|
+
*/
|
|
56
|
+
function formatSnapshot(snap, includeActions = true) {
|
|
57
|
+
const ageSec = Math.round((Date.now() - snap.timestamp) / 1000);
|
|
58
|
+
const lines = [];
|
|
59
|
+
|
|
60
|
+
lines.push(`Browser state (${ageSec}s ago):`);
|
|
61
|
+
lines.push('');
|
|
62
|
+
|
|
63
|
+
// Windows
|
|
64
|
+
if (snap.windows && snap.windows.length > 0) {
|
|
65
|
+
lines.push(`Open apps (${snap.windowCount || snap.windows.length}):`);
|
|
66
|
+
for (const w of snap.windows) {
|
|
67
|
+
const focus = w.isFocused ? ' [focused]' : '';
|
|
68
|
+
lines.push(` - ${w.app}${w.title ? ': ' + w.title : ''}${focus}`);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
lines.push('No apps open.');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (snap.focusedApp) {
|
|
75
|
+
lines.push(`Focused: ${snap.focusedApp}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Docked panel
|
|
79
|
+
if (snap.isDottieDocked) {
|
|
80
|
+
lines.push('DotBot panel: docked (sidebar)');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Input bar
|
|
84
|
+
if (snap.isInputElevated) {
|
|
85
|
+
lines.push(`Input bar: elevated${snap.inputValue ? ' — "' + snap.inputValue + '"' : ''}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Voice
|
|
89
|
+
if (snap.voiceState && snap.voiceState !== 'idle') {
|
|
90
|
+
lines.push(`Voice: ${snap.voiceState}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Streaming
|
|
94
|
+
if (snap.isStreaming) {
|
|
95
|
+
lines.push('Agent: streaming response');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Last tool call
|
|
99
|
+
if (snap.lastToolCall) {
|
|
100
|
+
const tc = snap.lastToolCall;
|
|
101
|
+
const tcAge = Math.round((Date.now() - tc.timestamp) / 1000);
|
|
102
|
+
lines.push(`Last tool: ${tc.name} (${tc.status}, ${tcAge}s ago)`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Messages
|
|
106
|
+
if (snap.messageCount > 0) {
|
|
107
|
+
lines.push(`Messages in session: ${snap.messageCount}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Provider/model
|
|
111
|
+
if (snap.currentProvider || snap.currentModel) {
|
|
112
|
+
lines.push(`Model: ${snap.currentProvider || '?'}/${snap.currentModel || '?'}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Layout + dock
|
|
116
|
+
lines.push(`Layout: ${snap.layoutMode || 'desktop'}`);
|
|
117
|
+
if (snap.dockApps && snap.dockApps.length > 0) {
|
|
118
|
+
lines.push(`Dock: ${snap.dockApps.join(', ')}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Viewport
|
|
122
|
+
if (snap.viewport) {
|
|
123
|
+
lines.push(`Viewport: ${snap.viewport.width}x${snap.viewport.height}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Recent actions
|
|
127
|
+
if (includeActions && snap.recentActions && snap.recentActions.length > 0) {
|
|
128
|
+
lines.push('');
|
|
129
|
+
lines.push('Recent actions:');
|
|
130
|
+
for (const a of snap.recentActions) {
|
|
131
|
+
const aAge = Math.round((Date.now() - a.timestamp) / 1000);
|
|
132
|
+
const detail = a.app ? ` (${a.app})` : a.tool ? ` (${a.tool})` : '';
|
|
133
|
+
lines.push(` - ${a.action}${detail} — ${aAge}s ago`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return lines.join('\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Agent tool definitions for the browser observer. */
|
|
141
|
+
export const observerTools = [
|
|
142
|
+
{
|
|
143
|
+
name: 'browser_observe',
|
|
144
|
+
description:
|
|
145
|
+
"See what the user is currently doing in Dottie OS — open apps, focused window, voice state, recent actions. " +
|
|
146
|
+
"Call this when you need context about the user's current activity, or when they reference 'this', 'what I'm looking at', or 'current'.",
|
|
147
|
+
parameters: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: {
|
|
150
|
+
include_actions: {
|
|
151
|
+
type: 'boolean',
|
|
152
|
+
description: 'Include recent user actions (default true)',
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
execute: async (input, signal, context) => {
|
|
157
|
+
if (!context?.userID) return 'Error: user context not available';
|
|
158
|
+
const snap = getSnapshot(context.userID);
|
|
159
|
+
if (!snap) return 'No browser state available. The user may have the tab in the background or just opened the page.';
|
|
160
|
+
const includeActions = input.include_actions !== false;
|
|
161
|
+
return formatSnapshot(snap, includeActions);
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stevederico/dotbot",
|
|
3
|
+
"version": "0.16.0",
|
|
4
|
+
"description": "AI agent CLI and library for Node.js — streaming, multi-provider, tool execution, autonomous tasks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"dotbot": "./bin/dotbot.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./index.js",
|
|
12
|
+
"./core/*": "./core/*.js",
|
|
13
|
+
"./tools/*": "./tools/*.js",
|
|
14
|
+
"./storage/*": "./storage/*.js",
|
|
15
|
+
"./utils/*": "./utils/*.js"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ai",
|
|
19
|
+
"agent",
|
|
20
|
+
"llm",
|
|
21
|
+
"anthropic",
|
|
22
|
+
"openai",
|
|
23
|
+
"xai",
|
|
24
|
+
"chatbot",
|
|
25
|
+
"autonomous",
|
|
26
|
+
"tasks",
|
|
27
|
+
"triggers"
|
|
28
|
+
],
|
|
29
|
+
"author": "Steve Derico",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"mongodb": "^6.0.0"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"playwright": "^1.58.2"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/stevederico/dotbot.git"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CronStore Interface
|
|
3
|
+
*
|
|
4
|
+
* Abstract interface for scheduled task storage. Implementations must provide
|
|
5
|
+
* all methods defined here.
|
|
6
|
+
*/
|
|
7
|
+
export class CronStore {
|
|
8
|
+
/**
|
|
9
|
+
* Initialize the cron store
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} options - Store-specific initialization options
|
|
12
|
+
* @param {Function} options.onTaskFire - Callback when a task fires: (task) => Promise<void>
|
|
13
|
+
*/
|
|
14
|
+
async init(options = {}) {
|
|
15
|
+
throw new Error('CronStore.init() must be implemented');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Stop the cron polling loop
|
|
20
|
+
*/
|
|
21
|
+
stop() {
|
|
22
|
+
throw new Error('CronStore.stop() must be implemented');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a scheduled task
|
|
27
|
+
*
|
|
28
|
+
* @param {Object} params
|
|
29
|
+
* @param {string} params.name - Short task name
|
|
30
|
+
* @param {string} params.prompt - Message to inject when task fires
|
|
31
|
+
* @param {string} [params.sessionId] - Session to inject into
|
|
32
|
+
* @param {string} [params.userId] - Owner user ID
|
|
33
|
+
* @param {string} params.runAt - ISO 8601 datetime for first run
|
|
34
|
+
* @param {number} [params.intervalMs] - Repeat interval in milliseconds
|
|
35
|
+
* @param {boolean} [params.recurring] - Whether task repeats
|
|
36
|
+
* @param {string} [params.taskId] - Associated task ID
|
|
37
|
+
* @returns {Promise<Object>} Created task document
|
|
38
|
+
*/
|
|
39
|
+
async createTask({ name, prompt, sessionId, userId, runAt, intervalMs, recurring, taskId }) {
|
|
40
|
+
throw new Error('CronStore.createTask() must be implemented');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* List tasks for a session
|
|
45
|
+
*
|
|
46
|
+
* @param {string} [sessionId] - Session ID to filter by
|
|
47
|
+
* @returns {Promise<Array>} Task list sorted by next run time
|
|
48
|
+
*/
|
|
49
|
+
async listTasks(sessionId) {
|
|
50
|
+
throw new Error('CronStore.listTasks() must be implemented');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* List tasks for multiple session IDs
|
|
55
|
+
*
|
|
56
|
+
* @param {string[]} sessionIds - Array of session IDs
|
|
57
|
+
* @param {string} [userId] - User ID to filter by
|
|
58
|
+
* @returns {Promise<Array>} Task list sorted by next run time
|
|
59
|
+
*/
|
|
60
|
+
async listTasksBySessionIds(sessionIds, userId) {
|
|
61
|
+
throw new Error('CronStore.listTasksBySessionIds() must be implemented');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get a task by ID
|
|
66
|
+
*
|
|
67
|
+
* @param {string} id - Task document ID
|
|
68
|
+
* @returns {Promise<Object|null>} Task document or null
|
|
69
|
+
*/
|
|
70
|
+
async getTask(id) {
|
|
71
|
+
throw new Error('CronStore.getTask() must be implemented');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Delete a task by its ID
|
|
76
|
+
*
|
|
77
|
+
* @param {string} id - Task document ID
|
|
78
|
+
* @returns {Promise<any>} Delete result
|
|
79
|
+
*/
|
|
80
|
+
async deleteTask(id) {
|
|
81
|
+
throw new Error('CronStore.deleteTask() must be implemented');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Toggle a task's enabled/disabled state
|
|
86
|
+
*
|
|
87
|
+
* @param {string} id - Task document ID
|
|
88
|
+
* @param {boolean} enabled - Whether the task should be enabled
|
|
89
|
+
* @returns {Promise<any>} Update result
|
|
90
|
+
*/
|
|
91
|
+
async toggleTask(id, enabled) {
|
|
92
|
+
throw new Error('CronStore.toggleTask() must be implemented');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Update a task's details
|
|
97
|
+
*
|
|
98
|
+
* @param {string} id - Task document ID
|
|
99
|
+
* @param {Object} updates - Fields to update
|
|
100
|
+
* @returns {Promise<any>} Update result
|
|
101
|
+
*/
|
|
102
|
+
async updateTask(id, updates) {
|
|
103
|
+
throw new Error('CronStore.updateTask() must be implemented');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Ensure a single recurring heartbeat task exists for a user
|
|
108
|
+
*
|
|
109
|
+
* @param {string} userId - User ID
|
|
110
|
+
* @returns {Promise<Object|null>} Created task or null if already exists
|
|
111
|
+
*/
|
|
112
|
+
async ensureHeartbeat(userId) {
|
|
113
|
+
throw new Error('CronStore.ensureHeartbeat() must be implemented');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get heartbeat status for a user
|
|
118
|
+
*
|
|
119
|
+
* @param {string} userId - User ID
|
|
120
|
+
* @returns {Promise<Object|null>} Heartbeat task or null
|
|
121
|
+
*/
|
|
122
|
+
async getHeartbeatStatus(userId) {
|
|
123
|
+
throw new Error('CronStore.getHeartbeatStatus() must be implemented');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Reset/update an existing heartbeat to use the latest prompt
|
|
128
|
+
*
|
|
129
|
+
* @param {string} userId - User ID
|
|
130
|
+
* @returns {Promise<Object|null>} New heartbeat task or null
|
|
131
|
+
*/
|
|
132
|
+
async resetHeartbeat(userId) {
|
|
133
|
+
throw new Error('CronStore.resetHeartbeat() must be implemented');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Manually trigger the heartbeat task immediately
|
|
138
|
+
*
|
|
139
|
+
* @param {string} userId - User ID
|
|
140
|
+
* @returns {Promise<boolean>} True if heartbeat was fired
|
|
141
|
+
*/
|
|
142
|
+
async triggerHeartbeatNow(userId) {
|
|
143
|
+
throw new Error('CronStore.triggerHeartbeatNow() must be implemented');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventStore Interface
|
|
3
|
+
*
|
|
4
|
+
* Abstract interface for event/analytics storage. Implementations must provide
|
|
5
|
+
* all methods defined here.
|
|
6
|
+
*/
|
|
7
|
+
export class EventStore {
|
|
8
|
+
/**
|
|
9
|
+
* Initialize the event store
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} db - Database instance (implementation-specific)
|
|
12
|
+
* @param {Object} options - Store-specific initialization options
|
|
13
|
+
*/
|
|
14
|
+
async init(db, options = {}) {
|
|
15
|
+
throw new Error('EventStore.init() must be implemented');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Log an event
|
|
20
|
+
*
|
|
21
|
+
* @param {Object} params
|
|
22
|
+
* @param {string} params.userId - User ID
|
|
23
|
+
* @param {string} params.type - Event type (message_sent, message_received, tool_call, task_created, task_completed, trigger_fired)
|
|
24
|
+
* @param {Object} [params.data] - Event-specific data (e.g., { tool: 'web_search' })
|
|
25
|
+
* @param {number} [params.timestamp] - Unix ms timestamp (defaults to now)
|
|
26
|
+
* @returns {Promise<Object>} Created event document
|
|
27
|
+
*/
|
|
28
|
+
async logEvent({ userId, type, data, timestamp }) {
|
|
29
|
+
throw new Error('EventStore.logEvent() must be implemented');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Query events with filters
|
|
34
|
+
*
|
|
35
|
+
* @param {Object} params
|
|
36
|
+
* @param {string} params.userId - User ID
|
|
37
|
+
* @param {string} [params.type] - Filter by event type
|
|
38
|
+
* @param {string} [params.startDate] - ISO date start (inclusive)
|
|
39
|
+
* @param {string} [params.endDate] - ISO date end (inclusive)
|
|
40
|
+
* @param {number} [params.limit=100] - Max results
|
|
41
|
+
* @returns {Promise<Array>} Matching events
|
|
42
|
+
*/
|
|
43
|
+
async query({ userId, type, startDate, endDate, limit }) {
|
|
44
|
+
throw new Error('EventStore.query() must be implemented');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get aggregated usage statistics
|
|
49
|
+
*
|
|
50
|
+
* @param {Object} params
|
|
51
|
+
* @param {string} params.userId - User ID
|
|
52
|
+
* @param {string} [params.startDate] - ISO date start
|
|
53
|
+
* @param {string} [params.endDate] - ISO date end
|
|
54
|
+
* @param {string} [params.groupBy='type'] - Group by: type, day, week, month
|
|
55
|
+
* @returns {Promise<Object>} Summary statistics
|
|
56
|
+
*/
|
|
57
|
+
async summary({ userId, startDate, endDate, groupBy }) {
|
|
58
|
+
throw new Error('EventStore.summary() must be implemented');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Delete events older than a given date
|
|
63
|
+
*
|
|
64
|
+
* @param {string} userId - User ID
|
|
65
|
+
* @param {string} beforeDate - ISO date cutoff
|
|
66
|
+
* @returns {Promise<Object>} Delete result with count
|
|
67
|
+
*/
|
|
68
|
+
async deleteOldEvents(userId, beforeDate) {
|
|
69
|
+
throw new Error('EventStore.deleteOldEvents() must be implemented');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { SessionStore } from './SessionStore.js';
|
|
3
|
+
import { defaultSystemPrompt } from './MongoAdapter.js';
|
|
4
|
+
import { toStandardFormat } from '../core/normalize.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* In-memory SessionStore implementation for testing
|
|
8
|
+
*/
|
|
9
|
+
export class MemorySessionStore extends SessionStore {
|
|
10
|
+
constructor() {
|
|
11
|
+
super();
|
|
12
|
+
this.sessions = new Map();
|
|
13
|
+
this.prefsFetcher = null;
|
|
14
|
+
this.systemPromptBuilder = defaultSystemPrompt;
|
|
15
|
+
this.heartbeatEnsurer = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async init(options = {}) {
|
|
19
|
+
this.prefsFetcher = options.prefsFetcher || null;
|
|
20
|
+
this.systemPromptBuilder = options.systemPromptBuilder || defaultSystemPrompt;
|
|
21
|
+
this.heartbeatEnsurer = options.heartbeatEnsurer || null;
|
|
22
|
+
console.log('[sessions] initialized with in-memory store');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async buildSystemPrompt(owner) {
|
|
26
|
+
const prefs = this.prefsFetcher ? await this.prefsFetcher(owner) : {};
|
|
27
|
+
return this.systemPromptBuilder(prefs);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async createSession(owner, model = 'gpt-oss:20b', provider = 'ollama') {
|
|
31
|
+
const session = {
|
|
32
|
+
id: crypto.randomUUID(),
|
|
33
|
+
owner,
|
|
34
|
+
title: '',
|
|
35
|
+
messages: [{ role: 'system', content: await this.buildSystemPrompt(owner) }],
|
|
36
|
+
model,
|
|
37
|
+
provider,
|
|
38
|
+
createdAt: new Date(),
|
|
39
|
+
updatedAt: new Date(),
|
|
40
|
+
};
|
|
41
|
+
this.sessions.set(session.id, session);
|
|
42
|
+
return session;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async getOrCreateDefaultSession(owner) {
|
|
46
|
+
// Find most recent session for this owner
|
|
47
|
+
const userSessions = Array.from(this.sessions.values())
|
|
48
|
+
.filter(s => s.owner === owner)
|
|
49
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
50
|
+
|
|
51
|
+
let session = userSessions[0];
|
|
52
|
+
if (!session) {
|
|
53
|
+
session = await this.createSession(owner);
|
|
54
|
+
} else {
|
|
55
|
+
// Refresh system prompt timestamp
|
|
56
|
+
session.messages[0] = { role: 'system', content: await this.buildSystemPrompt(owner) };
|
|
57
|
+
}
|
|
58
|
+
if (this.heartbeatEnsurer) {
|
|
59
|
+
this.heartbeatEnsurer(owner).catch((err) => {
|
|
60
|
+
console.error(`[session] failed to ensure heartbeat for ${owner}:`, err.message);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return session;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async getSession(sessionId, owner) {
|
|
67
|
+
const session = this.sessions.get(sessionId);
|
|
68
|
+
if (!session || session.owner !== owner) return null;
|
|
69
|
+
|
|
70
|
+
// Refresh system prompt timestamp
|
|
71
|
+
session.messages[0] = { role: 'system', content: await this.buildSystemPrompt(owner) };
|
|
72
|
+
return session;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async getSessionInternal(sessionId) {
|
|
76
|
+
const session = this.sessions.get(sessionId);
|
|
77
|
+
if (!session) return null;
|
|
78
|
+
|
|
79
|
+
session.messages[0] = { role: 'system', content: await this.buildSystemPrompt(session.owner) };
|
|
80
|
+
return session;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Save session with normalized messages.
|
|
85
|
+
* Converts any provider-specific message formats to standard format before persisting.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} sessionId - Session UUID
|
|
88
|
+
* @param {Array} messages - Messages (provider-specific or standard format)
|
|
89
|
+
* @param {string} model - Model identifier
|
|
90
|
+
* @param {string} [provider] - Provider name
|
|
91
|
+
*/
|
|
92
|
+
async saveSession(sessionId, messages, model, provider) {
|
|
93
|
+
const session = this.sessions.get(sessionId);
|
|
94
|
+
if (!session) throw new Error(`Session ${sessionId} not found`);
|
|
95
|
+
|
|
96
|
+
session.messages = toStandardFormat(messages);
|
|
97
|
+
session.model = model;
|
|
98
|
+
session.updatedAt = new Date();
|
|
99
|
+
if (provider) session.provider = provider;
|
|
100
|
+
|
|
101
|
+
// Auto-populate title from first user message if empty
|
|
102
|
+
if (!session.title) {
|
|
103
|
+
const firstUserMsg = messages.find((m) => m.role === 'user');
|
|
104
|
+
if (firstUserMsg) {
|
|
105
|
+
session.title = firstUserMsg.content.slice(0, 60);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Add a message to a session, normalizing to standard format before saving.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} sessionId - Session UUID
|
|
114
|
+
* @param {Object} message - Message object (any provider format)
|
|
115
|
+
* @returns {Promise<Object>} Updated session
|
|
116
|
+
*/
|
|
117
|
+
async addMessage(sessionId, message) {
|
|
118
|
+
const session = await this.getSessionInternal(sessionId);
|
|
119
|
+
if (!session) throw new Error(`Session ${sessionId} not found`);
|
|
120
|
+
if (!message._ts) message._ts = Date.now();
|
|
121
|
+
const normalized = toStandardFormat([message]);
|
|
122
|
+
session.messages.push(...normalized);
|
|
123
|
+
await this.saveSession(sessionId, session.messages, session.model);
|
|
124
|
+
return session;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async setModel(sessionId, model) {
|
|
128
|
+
const session = this.sessions.get(sessionId);
|
|
129
|
+
if (session) {
|
|
130
|
+
session.model = model;
|
|
131
|
+
session.updatedAt = new Date();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async setProvider(sessionId, provider) {
|
|
136
|
+
const session = this.sessions.get(sessionId);
|
|
137
|
+
if (session) {
|
|
138
|
+
session.provider = provider;
|
|
139
|
+
session.updatedAt = new Date();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async clearSession(sessionId) {
|
|
144
|
+
const session = this.sessions.get(sessionId);
|
|
145
|
+
if (session) {
|
|
146
|
+
session.messages = [{ role: 'system', content: await this.buildSystemPrompt(session.owner) }];
|
|
147
|
+
session.updatedAt = new Date();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async listSessions(owner) {
|
|
152
|
+
return Array.from(this.sessions.values())
|
|
153
|
+
.filter(s => s.owner === owner)
|
|
154
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
155
|
+
.slice(0, 50)
|
|
156
|
+
.map(s => ({
|
|
157
|
+
id: s.id,
|
|
158
|
+
title: s.title || '',
|
|
159
|
+
model: s.model,
|
|
160
|
+
provider: s.provider || 'ollama',
|
|
161
|
+
createdAt: s.createdAt,
|
|
162
|
+
updatedAt: s.updatedAt,
|
|
163
|
+
messageCount: s.messages.length,
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async deleteSession(sessionId, owner) {
|
|
168
|
+
const session = this.sessions.get(sessionId);
|
|
169
|
+
if (session && session.owner === owner) {
|
|
170
|
+
this.sessions.delete(sessionId);
|
|
171
|
+
return { deletedCount: 1 };
|
|
172
|
+
}
|
|
173
|
+
return { deletedCount: 0 };
|
|
174
|
+
}
|
|
175
|
+
}
|