@sriinnu/harmon-store 0.1.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/LICENSE +661 -0
- package/README.md +44 -0
- package/SKILL.md +41 -0
- package/dist/index.d.ts +108 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +473 -0
- package/dist/index.js.map +1 -0
- package/logo.svg +12 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @sriinnu/harmon-store
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
> SQLite persistence layer for sessions, journal entries, events, and settings.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @sriinnu/harmon-store
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { createStore } from '@sriinnu/harmon-store';
|
|
17
|
+
|
|
18
|
+
const store = await createStore({ dbPath: '.harmon.db' });
|
|
19
|
+
const sessionId = await store.createSession(JSON.stringify(policy));
|
|
20
|
+
await store.logEvent('track.started', { trackId: '123' }, sessionId);
|
|
21
|
+
await store.endSession(sessionId);
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## API
|
|
25
|
+
|
|
26
|
+
| Export | Description |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `createStore(config?)` | Create store and run migrations |
|
|
29
|
+
| `HarmonStore` | Store class with full CRUD |
|
|
30
|
+
| `store.createSession(policy)` | Start a new session |
|
|
31
|
+
| `store.endSession(id)` | Mark session completed |
|
|
32
|
+
| `store.logEvent(type, payload, sessionId?)` | Append to event log |
|
|
33
|
+
| `store.addJournalEntry(entry)` | Insert a mood journal entry |
|
|
34
|
+
| `store.getJournalEntriesByMood(mood)` | Query entries by mood tag |
|
|
35
|
+
| `store.getSetting(key)` / `setSetting(key, value)` | Key-value settings |
|
|
36
|
+
| `store.getStats()` | Aggregate counts and mood distribution |
|
|
37
|
+
|
|
38
|
+
## Architecture
|
|
39
|
+
|
|
40
|
+
harmon-store sits beneath the daemon, providing durable storage via libSQL/SQLite. It manages automatic migrations, journal entries with mood tags, session lifecycle, and a typed event log. The daemon reads and writes through this layer exclusively.
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
GNU Affero General Public License v3.0 only. See [LICENSE](../../LICENSE).
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: harmon-store
|
|
3
|
+
description: SQLite persistence layer with versioned migrations for sessions, journals, and events
|
|
4
|
+
capabilities:
|
|
5
|
+
- Store and query journal entries with mood tags, energy levels, and embeddings
|
|
6
|
+
- Manage session lifecycle (create, end, cancel) with status tracking
|
|
7
|
+
- Log and retrieve events with session correlation and time-based queries
|
|
8
|
+
tags:
|
|
9
|
+
- database
|
|
10
|
+
- sqlite
|
|
11
|
+
- persistence
|
|
12
|
+
- storage
|
|
13
|
+
provider: harmon
|
|
14
|
+
version: 0.1.0
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Harmon Store
|
|
18
|
+
|
|
19
|
+
## What this does
|
|
20
|
+
harmon-store provides the persistence layer for the entire harmon system. It manages a SQLite database (via libsql) with automatic versioned migrations, storing journal entries, sessions, event logs, and key-value settings. The store enforces WAL mode for concurrent read performance and validates that encryption is enabled in production environments.
|
|
21
|
+
|
|
22
|
+
## When to use
|
|
23
|
+
- Persisting session history, event logs, or journal entries to disk
|
|
24
|
+
- Querying past sessions, mood distributions, or recent play statistics
|
|
25
|
+
- Adding a new migration when the schema needs to evolve
|
|
26
|
+
|
|
27
|
+
## Key exports
|
|
28
|
+
- `HarmonStore` — class with methods for journals, sessions, events, settings, and stats
|
|
29
|
+
- `createStore` — async factory that instantiates HarmonStore and runs pending migrations
|
|
30
|
+
- `JournalEntry` — interface for a parsed journal record (mood, energy, content, embedding)
|
|
31
|
+
- `Session` — interface for a session record (id, policy, status, timestamps)
|
|
32
|
+
|
|
33
|
+
## Example
|
|
34
|
+
```typescript
|
|
35
|
+
import { createStore } from '@sriinnu/harmon-store';
|
|
36
|
+
|
|
37
|
+
const store = await createStore({ dbPath: '.harmon.db' });
|
|
38
|
+
const sessionId = await store.createSession(JSON.stringify(policy));
|
|
39
|
+
await store.logEvent('session.started', { sessionId });
|
|
40
|
+
const stats = await store.getStats();
|
|
41
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harmon Store - SQLite persistence layer with migrations
|
|
3
|
+
*/
|
|
4
|
+
import { type Client } from '@libsql/client';
|
|
5
|
+
export interface Database {
|
|
6
|
+
client: Client;
|
|
7
|
+
}
|
|
8
|
+
export interface JournalEntry {
|
|
9
|
+
id: string;
|
|
10
|
+
filename: string;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
source: string;
|
|
13
|
+
device: string;
|
|
14
|
+
sessionId?: string;
|
|
15
|
+
moodTags: string;
|
|
16
|
+
energyLevel?: string;
|
|
17
|
+
context?: string;
|
|
18
|
+
content: string;
|
|
19
|
+
policy?: string;
|
|
20
|
+
embedding?: number[];
|
|
21
|
+
createdAt: string;
|
|
22
|
+
}
|
|
23
|
+
export interface Session {
|
|
24
|
+
id: string;
|
|
25
|
+
policy: string;
|
|
26
|
+
startedAt: string;
|
|
27
|
+
endedAt?: string;
|
|
28
|
+
status: 'active' | 'completed' | 'cancelled';
|
|
29
|
+
}
|
|
30
|
+
export interface EventLog {
|
|
31
|
+
id: string;
|
|
32
|
+
sessionId?: string;
|
|
33
|
+
type: string;
|
|
34
|
+
payload: string;
|
|
35
|
+
createdAt: string;
|
|
36
|
+
}
|
|
37
|
+
export interface HarmonStoreConfig {
|
|
38
|
+
dbPath?: string;
|
|
39
|
+
memory?: boolean;
|
|
40
|
+
}
|
|
41
|
+
export declare class HarmonStore {
|
|
42
|
+
private client;
|
|
43
|
+
private dbPath;
|
|
44
|
+
private memory;
|
|
45
|
+
constructor(config?: HarmonStoreConfig);
|
|
46
|
+
/**
|
|
47
|
+
* Run migrations
|
|
48
|
+
*/
|
|
49
|
+
migrate(): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Close the database
|
|
52
|
+
*/
|
|
53
|
+
close(): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* I create the SQLite files myself so the daemon never relies on process
|
|
56
|
+
* umask to keep journal data owner-only.
|
|
57
|
+
*/
|
|
58
|
+
private ensureDatabaseFiles;
|
|
59
|
+
/**
|
|
60
|
+
* I keep the main DB and SQLite sidecars private to the current user.
|
|
61
|
+
*/
|
|
62
|
+
private enforceDatabaseFilePermissions;
|
|
63
|
+
addJournalEntry(entry: Omit<JournalEntry, 'id' | 'createdAt'>): Promise<string>;
|
|
64
|
+
getJournalEntry(id: string): Promise<JournalEntry | null>;
|
|
65
|
+
getJournalEntries(limit?: number, offset?: number): Promise<JournalEntry[]>;
|
|
66
|
+
getJournalEntriesByMood(mood: string, limit?: number): Promise<JournalEntry[]>;
|
|
67
|
+
getRecentJournalEntries(days?: number, limit?: number): Promise<JournalEntry[]>;
|
|
68
|
+
private rowToJournalEntry;
|
|
69
|
+
createSession(policy: string): Promise<string>;
|
|
70
|
+
endSession(id: string): Promise<void>;
|
|
71
|
+
cancelSession(id: string): Promise<void>;
|
|
72
|
+
getSession(id: string): Promise<Session | null>;
|
|
73
|
+
getActiveSession(): Promise<Session | null>;
|
|
74
|
+
getRecentSessions(limit?: number): Promise<Session[]>;
|
|
75
|
+
private rowToSession;
|
|
76
|
+
logEvent(type: string, payload: Record<string, unknown>, sessionId?: string): Promise<string>;
|
|
77
|
+
getEvents(sessionId?: string, limit?: number): Promise<EventLog[]>;
|
|
78
|
+
getRecentEvents(limit?: number): Promise<EventLog[]>;
|
|
79
|
+
private rowToEventLog;
|
|
80
|
+
getSetting(key: string): Promise<string | null>;
|
|
81
|
+
setSetting(key: string, value: string): Promise<void>;
|
|
82
|
+
deleteSetting(key: string): Promise<void>;
|
|
83
|
+
getStats(): Promise<{
|
|
84
|
+
totalEntries: number;
|
|
85
|
+
totalSessions: number;
|
|
86
|
+
activeSessions: number;
|
|
87
|
+
eventsLogged: number;
|
|
88
|
+
recentMoodDistribution: Record<string, number>;
|
|
89
|
+
}>;
|
|
90
|
+
/**
|
|
91
|
+
* Get database path
|
|
92
|
+
*/
|
|
93
|
+
getDbPath(): string;
|
|
94
|
+
/**
|
|
95
|
+
* Validate encryption is enabled in production
|
|
96
|
+
* This should be called after the store is initialized
|
|
97
|
+
*/
|
|
98
|
+
static validateEncryptionInProduction(encryptionEnabled: boolean): void;
|
|
99
|
+
/**
|
|
100
|
+
* Check if encryption should be required based on environment
|
|
101
|
+
*/
|
|
102
|
+
static isEncryptionRequired(): boolean;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Create a store with default configuration
|
|
106
|
+
*/
|
|
107
|
+
export declare function createStore(config?: HarmonStoreConfig): Promise<HarmonStore>;
|
|
108
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harmon Store - SQLite persistence layer with migrations
|
|
3
|
+
*/
|
|
4
|
+
import { createClient } from '@libsql/client';
|
|
5
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
const MIGRATIONS = [
|
|
9
|
+
{
|
|
10
|
+
version: 1,
|
|
11
|
+
statements: [
|
|
12
|
+
`
|
|
13
|
+
CREATE TABLE IF NOT EXISTS journal_entries (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
filename TEXT NOT NULL,
|
|
16
|
+
timestamp TEXT NOT NULL,
|
|
17
|
+
source TEXT NOT NULL,
|
|
18
|
+
device TEXT NOT NULL,
|
|
19
|
+
sessionId TEXT,
|
|
20
|
+
moodTags TEXT,
|
|
21
|
+
energyLevel TEXT,
|
|
22
|
+
context TEXT,
|
|
23
|
+
content TEXT NOT NULL,
|
|
24
|
+
policy TEXT,
|
|
25
|
+
embedding BLOB,
|
|
26
|
+
createdAt TEXT DEFAULT (datetime('now'))
|
|
27
|
+
)
|
|
28
|
+
`,
|
|
29
|
+
'CREATE INDEX IF NOT EXISTS idx_journal_timestamp ON journal_entries(timestamp)',
|
|
30
|
+
'CREATE INDEX IF NOT EXISTS idx_journal_moodTags ON journal_entries(moodTags)',
|
|
31
|
+
'CREATE INDEX IF NOT EXISTS idx_journal_sessionId ON journal_entries(sessionId)',
|
|
32
|
+
`
|
|
33
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
34
|
+
id TEXT PRIMARY KEY,
|
|
35
|
+
policy TEXT NOT NULL,
|
|
36
|
+
startedAt TEXT NOT NULL,
|
|
37
|
+
endedAt TEXT,
|
|
38
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
39
|
+
createdAt TEXT DEFAULT (datetime('now'))
|
|
40
|
+
)
|
|
41
|
+
`,
|
|
42
|
+
'CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status)',
|
|
43
|
+
'CREATE INDEX IF NOT EXISTS idx_sessions_startedAt ON sessions(startedAt)',
|
|
44
|
+
`
|
|
45
|
+
CREATE TABLE IF NOT EXISTS event_log (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
sessionId TEXT,
|
|
48
|
+
type TEXT NOT NULL,
|
|
49
|
+
payload TEXT NOT NULL,
|
|
50
|
+
createdAt TEXT DEFAULT (datetime('now'))
|
|
51
|
+
)
|
|
52
|
+
`,
|
|
53
|
+
'CREATE INDEX IF NOT EXISTS idx_event_log_sessionId ON event_log(sessionId)',
|
|
54
|
+
'CREATE INDEX IF NOT EXISTS idx_event_log_type ON event_log(type)',
|
|
55
|
+
'CREATE INDEX IF NOT EXISTS idx_event_log_createdAt ON event_log(createdAt)',
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
version: 2,
|
|
60
|
+
statements: [`
|
|
61
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
62
|
+
key TEXT PRIMARY KEY,
|
|
63
|
+
value TEXT NOT NULL,
|
|
64
|
+
updatedAt TEXT DEFAULT (datetime('now'))
|
|
65
|
+
)
|
|
66
|
+
`],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
version: 3,
|
|
70
|
+
statements: ['PRAGMA journal_mode=WAL'],
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
export class HarmonStore {
|
|
74
|
+
client;
|
|
75
|
+
dbPath;
|
|
76
|
+
memory;
|
|
77
|
+
constructor(config = {}) {
|
|
78
|
+
const dbPath = config.dbPath || '.harmon.db';
|
|
79
|
+
this.dbPath = path.resolve(dbPath);
|
|
80
|
+
this.memory = config.memory === true;
|
|
81
|
+
// Ensure directory exists
|
|
82
|
+
const dir = path.dirname(this.dbPath);
|
|
83
|
+
if (!fs.existsSync(dir)) {
|
|
84
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
85
|
+
}
|
|
86
|
+
if (!this.memory) {
|
|
87
|
+
this.ensureDatabaseFiles();
|
|
88
|
+
}
|
|
89
|
+
const url = this.memory ? 'file::memory:' : `file:${this.dbPath}`;
|
|
90
|
+
this.client = createClient({
|
|
91
|
+
url,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Run migrations
|
|
96
|
+
*/
|
|
97
|
+
async migrate() {
|
|
98
|
+
// Ensure migration tracking table exists
|
|
99
|
+
await this.client.execute(`
|
|
100
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
101
|
+
version INTEGER PRIMARY KEY,
|
|
102
|
+
appliedAt TEXT DEFAULT (datetime('now'))
|
|
103
|
+
)
|
|
104
|
+
`);
|
|
105
|
+
// Get current version
|
|
106
|
+
const result = await this.client.execute('SELECT MAX(version) as v FROM _migrations');
|
|
107
|
+
const currentVersion = result.rows[0]?.v || 0;
|
|
108
|
+
// Run pending migrations (each wrapped in a transaction for atomicity).
|
|
109
|
+
// PRAGMA statements cannot execute inside a transaction, so they are
|
|
110
|
+
// run separately and the version is recorded in its own batch.
|
|
111
|
+
for (const migration of MIGRATIONS) {
|
|
112
|
+
if (migration.version > currentVersion) {
|
|
113
|
+
const pragmas = migration.statements.filter((s) => /^\s*PRAGMA\b/i.test(s));
|
|
114
|
+
const regular = migration.statements.filter((s) => !/^\s*PRAGMA\b/i.test(s));
|
|
115
|
+
// Execute PRAGMA statements outside a transaction
|
|
116
|
+
for (const pragma of pragmas) {
|
|
117
|
+
await this.client.execute(pragma.trim());
|
|
118
|
+
}
|
|
119
|
+
// Batch the remaining DDL/DML with the version bookmark
|
|
120
|
+
await this.client.batch([
|
|
121
|
+
...regular.map((s) => s.trim()),
|
|
122
|
+
{ sql: 'INSERT INTO _migrations (version) VALUES (?)', args: [migration.version] },
|
|
123
|
+
], 'write');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
this.enforceDatabaseFilePermissions();
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Close the database
|
|
130
|
+
*/
|
|
131
|
+
async close() {
|
|
132
|
+
this.client.close();
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* I create the SQLite files myself so the daemon never relies on process
|
|
136
|
+
* umask to keep journal data owner-only.
|
|
137
|
+
*/
|
|
138
|
+
ensureDatabaseFiles() {
|
|
139
|
+
const handle = fs.openSync(this.dbPath, 'a', 0o600);
|
|
140
|
+
fs.closeSync(handle);
|
|
141
|
+
this.enforceDatabaseFilePermissions();
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* I keep the main DB and SQLite sidecars private to the current user.
|
|
145
|
+
*/
|
|
146
|
+
enforceDatabaseFilePermissions() {
|
|
147
|
+
if (this.memory) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
for (const filePath of [this.dbPath, `${this.dbPath}-shm`, `${this.dbPath}-wal`]) {
|
|
151
|
+
if (!fs.existsSync(filePath)) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
fs.chmodSync(filePath, 0o600);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// Journal Entries
|
|
159
|
+
// ============================================================================
|
|
160
|
+
async addJournalEntry(entry) {
|
|
161
|
+
const id = uuidv4();
|
|
162
|
+
const now = new Date().toISOString();
|
|
163
|
+
await this.client.execute({
|
|
164
|
+
sql: `
|
|
165
|
+
INSERT INTO journal_entries
|
|
166
|
+
(id, filename, timestamp, source, device, sessionId, moodTags, energyLevel, context, content, policy, embedding, createdAt)
|
|
167
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
168
|
+
`,
|
|
169
|
+
args: [
|
|
170
|
+
id,
|
|
171
|
+
entry.filename,
|
|
172
|
+
entry.timestamp,
|
|
173
|
+
entry.source,
|
|
174
|
+
entry.device,
|
|
175
|
+
entry.sessionId || null,
|
|
176
|
+
entry.moodTags,
|
|
177
|
+
entry.energyLevel || null,
|
|
178
|
+
entry.context || null,
|
|
179
|
+
entry.content,
|
|
180
|
+
entry.policy || null,
|
|
181
|
+
entry.embedding ? Buffer.from(JSON.stringify(entry.embedding)) : null,
|
|
182
|
+
now,
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
return id;
|
|
186
|
+
}
|
|
187
|
+
async getJournalEntry(id) {
|
|
188
|
+
const result = await this.client.execute({
|
|
189
|
+
sql: 'SELECT * FROM journal_entries WHERE id = ?',
|
|
190
|
+
args: [id],
|
|
191
|
+
});
|
|
192
|
+
if (result.rows.length === 0)
|
|
193
|
+
return null;
|
|
194
|
+
return this.rowToJournalEntry(result.rows[0]);
|
|
195
|
+
}
|
|
196
|
+
async getJournalEntries(limit = 100, offset = 0) {
|
|
197
|
+
const result = await this.client.execute({
|
|
198
|
+
sql: 'SELECT * FROM journal_entries ORDER BY timestamp DESC LIMIT ? OFFSET ?',
|
|
199
|
+
args: [limit.toString(), offset.toString()],
|
|
200
|
+
});
|
|
201
|
+
return result.rows.map((row) => this.rowToJournalEntry(row));
|
|
202
|
+
}
|
|
203
|
+
async getJournalEntriesByMood(mood, limit = 50) {
|
|
204
|
+
const result = await this.client.execute({
|
|
205
|
+
sql: `
|
|
206
|
+
SELECT * FROM journal_entries
|
|
207
|
+
WHERE moodTags LIKE ?
|
|
208
|
+
ORDER BY timestamp DESC
|
|
209
|
+
LIMIT ?
|
|
210
|
+
`,
|
|
211
|
+
args: [`%${mood}%`, limit.toString()],
|
|
212
|
+
});
|
|
213
|
+
return result.rows.map((row) => this.rowToJournalEntry(row));
|
|
214
|
+
}
|
|
215
|
+
async getRecentJournalEntries(days = 7, limit = 100) {
|
|
216
|
+
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
217
|
+
const result = await this.client.execute({
|
|
218
|
+
sql: `
|
|
219
|
+
SELECT * FROM journal_entries
|
|
220
|
+
WHERE timestamp >= ?
|
|
221
|
+
ORDER BY timestamp DESC
|
|
222
|
+
LIMIT ?
|
|
223
|
+
`,
|
|
224
|
+
args: [cutoff, limit.toString()],
|
|
225
|
+
});
|
|
226
|
+
return result.rows.map((row) => this.rowToJournalEntry(row));
|
|
227
|
+
}
|
|
228
|
+
rowToJournalEntry(row) {
|
|
229
|
+
return {
|
|
230
|
+
id: row.id,
|
|
231
|
+
filename: row.filename,
|
|
232
|
+
timestamp: row.timestamp,
|
|
233
|
+
source: row.source,
|
|
234
|
+
device: row.device,
|
|
235
|
+
sessionId: row.sessionId,
|
|
236
|
+
moodTags: row.moodTags,
|
|
237
|
+
energyLevel: row.energyLevel,
|
|
238
|
+
context: row.context,
|
|
239
|
+
content: row.content,
|
|
240
|
+
policy: row.policy,
|
|
241
|
+
embedding: row.embedding ? JSON.parse(row.embedding.toString()) : undefined,
|
|
242
|
+
createdAt: row.createdAt,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
// ============================================================================
|
|
246
|
+
// Sessions
|
|
247
|
+
// ============================================================================
|
|
248
|
+
async createSession(policy) {
|
|
249
|
+
const id = `sess_${uuidv4().slice(0, 8)}`;
|
|
250
|
+
const now = new Date().toISOString();
|
|
251
|
+
await this.client.execute({
|
|
252
|
+
sql: `
|
|
253
|
+
INSERT INTO sessions (id, policy, startedAt, status)
|
|
254
|
+
VALUES (?, ?, ?, 'active')
|
|
255
|
+
`,
|
|
256
|
+
args: [id, policy, now],
|
|
257
|
+
});
|
|
258
|
+
return id;
|
|
259
|
+
}
|
|
260
|
+
async endSession(id) {
|
|
261
|
+
const now = new Date().toISOString();
|
|
262
|
+
const result = await this.client.execute({
|
|
263
|
+
sql: `
|
|
264
|
+
UPDATE sessions
|
|
265
|
+
SET endedAt = ?, status = 'completed'
|
|
266
|
+
WHERE id = ? AND status = 'active'
|
|
267
|
+
`,
|
|
268
|
+
args: [now, id],
|
|
269
|
+
});
|
|
270
|
+
if (result.rowsAffected === 0) {
|
|
271
|
+
throw new Error(`Session ${id} not found or not active`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async cancelSession(id) {
|
|
275
|
+
const now = new Date().toISOString();
|
|
276
|
+
const result = await this.client.execute({
|
|
277
|
+
sql: `
|
|
278
|
+
UPDATE sessions
|
|
279
|
+
SET endedAt = ?, status = 'cancelled'
|
|
280
|
+
WHERE id = ? AND status = 'active'
|
|
281
|
+
`,
|
|
282
|
+
args: [now, id],
|
|
283
|
+
});
|
|
284
|
+
if (result.rowsAffected === 0) {
|
|
285
|
+
throw new Error(`Session ${id} not found or not active`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async getSession(id) {
|
|
289
|
+
const result = await this.client.execute({
|
|
290
|
+
sql: 'SELECT * FROM sessions WHERE id = ?',
|
|
291
|
+
args: [id],
|
|
292
|
+
});
|
|
293
|
+
if (result.rows.length === 0)
|
|
294
|
+
return null;
|
|
295
|
+
return this.rowToSession(result.rows[0]);
|
|
296
|
+
}
|
|
297
|
+
async getActiveSession() {
|
|
298
|
+
const result = await this.client.execute({
|
|
299
|
+
sql: 'SELECT * FROM sessions WHERE status = ? ORDER BY startedAt DESC LIMIT 1',
|
|
300
|
+
args: ['active'],
|
|
301
|
+
});
|
|
302
|
+
if (result.rows.length === 0)
|
|
303
|
+
return null;
|
|
304
|
+
return this.rowToSession(result.rows[0]);
|
|
305
|
+
}
|
|
306
|
+
async getRecentSessions(limit = 20) {
|
|
307
|
+
const result = await this.client.execute({
|
|
308
|
+
sql: 'SELECT * FROM sessions ORDER BY startedAt DESC LIMIT ?',
|
|
309
|
+
args: [limit.toString()],
|
|
310
|
+
});
|
|
311
|
+
return result.rows.map((row) => this.rowToSession(row));
|
|
312
|
+
}
|
|
313
|
+
rowToSession(row) {
|
|
314
|
+
return {
|
|
315
|
+
id: row.id,
|
|
316
|
+
policy: row.policy,
|
|
317
|
+
startedAt: row.startedAt,
|
|
318
|
+
endedAt: row.endedAt,
|
|
319
|
+
status: row.status,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
// ============================================================================
|
|
323
|
+
// Event Log
|
|
324
|
+
// ============================================================================
|
|
325
|
+
async logEvent(type, payload, sessionId) {
|
|
326
|
+
const id = uuidv4();
|
|
327
|
+
const now = new Date().toISOString();
|
|
328
|
+
await this.client.execute({
|
|
329
|
+
sql: `
|
|
330
|
+
INSERT INTO event_log (id, sessionId, type, payload, createdAt)
|
|
331
|
+
VALUES (?, ?, ?, ?, ?)
|
|
332
|
+
`,
|
|
333
|
+
args: [id, sessionId || null, type, JSON.stringify(payload), now],
|
|
334
|
+
});
|
|
335
|
+
return id;
|
|
336
|
+
}
|
|
337
|
+
async getEvents(sessionId, limit = 100) {
|
|
338
|
+
let sql = 'SELECT * FROM event_log';
|
|
339
|
+
const args = [];
|
|
340
|
+
if (sessionId) {
|
|
341
|
+
sql += ' WHERE sessionId = ?';
|
|
342
|
+
args.push(sessionId);
|
|
343
|
+
}
|
|
344
|
+
sql += ' ORDER BY createdAt DESC LIMIT ?';
|
|
345
|
+
args.push(limit.toString());
|
|
346
|
+
const result = await this.client.execute({ sql, args });
|
|
347
|
+
return result.rows.map((row) => this.rowToEventLog(row));
|
|
348
|
+
}
|
|
349
|
+
async getRecentEvents(limit = 50) {
|
|
350
|
+
const result = await this.client.execute({
|
|
351
|
+
sql: 'SELECT * FROM event_log ORDER BY createdAt DESC LIMIT ?',
|
|
352
|
+
args: [limit.toString()],
|
|
353
|
+
});
|
|
354
|
+
return result.rows.map((row) => this.rowToEventLog(row));
|
|
355
|
+
}
|
|
356
|
+
rowToEventLog(row) {
|
|
357
|
+
return {
|
|
358
|
+
id: row.id,
|
|
359
|
+
sessionId: row.sessionId,
|
|
360
|
+
type: row.type,
|
|
361
|
+
payload: row.payload,
|
|
362
|
+
createdAt: row.createdAt,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
// ============================================================================
|
|
366
|
+
// Settings
|
|
367
|
+
// ============================================================================
|
|
368
|
+
async getSetting(key) {
|
|
369
|
+
const result = await this.client.execute({
|
|
370
|
+
sql: 'SELECT value FROM settings WHERE key = ?',
|
|
371
|
+
args: [key],
|
|
372
|
+
});
|
|
373
|
+
if (result.rows.length === 0)
|
|
374
|
+
return null;
|
|
375
|
+
return result.rows[0]?.value;
|
|
376
|
+
}
|
|
377
|
+
async setSetting(key, value) {
|
|
378
|
+
await this.client.execute({
|
|
379
|
+
sql: `
|
|
380
|
+
INSERT INTO settings (key, value, updatedAt)
|
|
381
|
+
VALUES (?, ?, datetime('now'))
|
|
382
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
383
|
+
value = excluded.value,
|
|
384
|
+
updatedAt = datetime('now')
|
|
385
|
+
`,
|
|
386
|
+
args: [key, value],
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
async deleteSetting(key) {
|
|
390
|
+
await this.client.execute({
|
|
391
|
+
sql: 'DELETE FROM settings WHERE key = ?',
|
|
392
|
+
args: [key],
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
// ============================================================================
|
|
396
|
+
// Statistics
|
|
397
|
+
// ============================================================================
|
|
398
|
+
async getStats() {
|
|
399
|
+
const entriesResult = await this.client.execute({
|
|
400
|
+
sql: 'SELECT COUNT(*) as count FROM journal_entries',
|
|
401
|
+
args: [],
|
|
402
|
+
});
|
|
403
|
+
const sessionsResult = await this.client.execute({
|
|
404
|
+
sql: 'SELECT COUNT(*) as count FROM sessions WHERE status = ?',
|
|
405
|
+
args: ['active'],
|
|
406
|
+
});
|
|
407
|
+
const totalSessionsResult = await this.client.execute({
|
|
408
|
+
sql: 'SELECT COUNT(*) as count FROM sessions',
|
|
409
|
+
args: [],
|
|
410
|
+
});
|
|
411
|
+
const eventsResult = await this.client.execute({
|
|
412
|
+
sql: 'SELECT COUNT(*) as count FROM event_log',
|
|
413
|
+
args: [],
|
|
414
|
+
});
|
|
415
|
+
// Get mood distribution from last 7 days
|
|
416
|
+
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
417
|
+
const moodResult = await this.client.execute({
|
|
418
|
+
sql: `
|
|
419
|
+
SELECT moodTags, COUNT(*) as count FROM journal_entries
|
|
420
|
+
WHERE timestamp >= ?
|
|
421
|
+
GROUP BY moodTags
|
|
422
|
+
ORDER BY count DESC
|
|
423
|
+
`,
|
|
424
|
+
args: [cutoff],
|
|
425
|
+
});
|
|
426
|
+
const recentMoodDistribution = {};
|
|
427
|
+
for (const row of moodResult.rows) {
|
|
428
|
+
const tags = (row.moodTags || '').split(',').map((t) => t.trim());
|
|
429
|
+
for (const tag of tags) {
|
|
430
|
+
if (tag) {
|
|
431
|
+
recentMoodDistribution[tag] = (recentMoodDistribution[tag] || 0) + row.count;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
totalEntries: entriesResult.rows[0]?.count || 0,
|
|
437
|
+
totalSessions: totalSessionsResult.rows[0]?.count || 0,
|
|
438
|
+
activeSessions: sessionsResult.rows[0]?.count || 0,
|
|
439
|
+
eventsLogged: eventsResult.rows[0]?.count || 0,
|
|
440
|
+
recentMoodDistribution,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Get database path
|
|
445
|
+
*/
|
|
446
|
+
getDbPath() {
|
|
447
|
+
return this.dbPath;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Validate encryption is enabled in production
|
|
451
|
+
* This should be called after the store is initialized
|
|
452
|
+
*/
|
|
453
|
+
static validateEncryptionInProduction(encryptionEnabled) {
|
|
454
|
+
if (process.env.NODE_ENV === 'production' && !encryptionEnabled) {
|
|
455
|
+
throw new Error('Encryption is required in production. Set HARMON_ENCRYPTION_SECRET environment variable.');
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Check if encryption should be required based on environment
|
|
460
|
+
*/
|
|
461
|
+
static isEncryptionRequired() {
|
|
462
|
+
return process.env.NODE_ENV === 'production';
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Create a store with default configuration
|
|
467
|
+
*/
|
|
468
|
+
export async function createStore(config) {
|
|
469
|
+
const store = new HarmonStore(config);
|
|
470
|
+
await store.migrate();
|
|
471
|
+
return store;
|
|
472
|
+
}
|
|
473
|
+
//# sourceMappingURL=index.js.map
|