@xandreed/plugin-session-sqlite 0.3.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Xand Reed
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @xandreed/plugin-session-sqlite
2
+
3
+ Efferent composable agent framework, 0.3.0. Bun on Linux.
4
+
5
+ Documentation: https://github.com/xandreeddev/efferent
@@ -0,0 +1,6 @@
1
+ import { Layer } from "effect";
2
+ import { HarnessError, SessionStore } from "@xandreed/core";
3
+ export declare const SessionStoreLive: (path: string) => Layer.Layer<SessionStore, HarnessError, never>;
4
+ export declare const sessionSqlitePlugin: import("@xandreed/core").Plugin;
5
+ export default sessionSqlitePlugin;
6
+ export { SqliteConversationStoreLive } from "./store/sqliteStore.js";
package/dist/index.js ADDED
@@ -0,0 +1,60 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { Effect, Layer, Schema } from "effect";
5
+ import { ConversationId, definePlugin, HarnessError, SessionEvent, SessionRecord, SessionStore } from "@xandreed/core";
6
+ const Config = Schema.Struct({ path: Schema.String }), decodeRecord = Schema.decodeUnknownSync(Schema.parseJson(SessionRecord)), decodeEvent = Schema.decodeUnknownSync(Schema.parseJson(SessionEvent));
7
+ export const SessionStoreLive = (path) => Layer.scoped(SessionStore, Effect.gen(function* () {
8
+ yield* Effect.tryPromise({ try: () => mkdir(dirname(path), { recursive: !0 }), catch: (error) => new HarnessError({ code: "store.open", message: String(error) }) });
9
+ const db = yield* Effect.acquireRelease(Effect.try({ try: () => new Database(path, { create: !0, strict: !0 }), catch: (error) => new HarnessError({ code: "store.open", message: String(error) }) }), (database) => Effect.sync(() => database.close())), operation = (work) => Effect.try({ try: work, catch: (error) => new HarnessError({ code: "store.io", message: String(error) }) });
10
+ yield* operation(() => db.exec(`
11
+ PRAGMA journal_mode = WAL;
12
+ PRAGMA busy_timeout = 5000;
13
+ PRAGMA foreign_keys = ON;
14
+ CREATE TABLE IF NOT EXISTS harness_sessions (id TEXT PRIMARY KEY, workspace TEXT NOT NULL, body TEXT NOT NULL);
15
+ CREATE TABLE IF NOT EXISTS harness_events (session_id TEXT NOT NULL REFERENCES harness_sessions(id), seq INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(session_id, seq));
16
+ `));
17
+ const insert = (record) => {
18
+ db.query("INSERT INTO harness_sessions VALUES (?, ?, ?)").run(record.id, record.workspace, JSON.stringify(record));
19
+ return record;
20
+ }, get = (id) => operation(() => db.query("SELECT body FROM harness_sessions WHERE id = ?").get(id)).pipe(Effect.flatMap((row) => row === null ? Effect.fail(new HarnessError({ code: "session.missing", message: `Session ${id} does not exist` })) : operation(() => decodeRecord(row.body)))), read = (id, after) => get(id).pipe(Effect.zipRight(operation(() => db.query("SELECT body FROM harness_events WHERE session_id = ? AND seq > ? ORDER BY seq").all(id, after).map((row) => decodeEvent(row.body)))));
21
+ return SessionStore.of({
22
+ create: (workspace, profile) => operation(() => insert({ id: ConversationId.make(crypto.randomUUID()), workspace, profile, createdAt: Date.now() })),
23
+ get,
24
+ list: (workspace) => operation(() => db.query("SELECT body FROM harness_sessions WHERE workspace = ? ORDER BY rowid DESC").all(workspace).map((row) => decodeRecord(row.body))),
25
+ read,
26
+ append: (id, body) => get(id).pipe(Effect.zipRight(operation(() => db.transaction(() => {
27
+ const row = db.query("SELECT COALESCE(MAX(seq), -1) + 1 AS seq FROM harness_events WHERE session_id = ?").get(id), event = { ...body, version: 1, id: crypto.randomUUID(), sessionId: id, seq: row?.seq ?? 0, at: Date.now() };
28
+ db.query("INSERT INTO harness_events VALUES (?, ?, ?)").run(id, event.seq, JSON.stringify(event));
29
+ return event;
30
+ })()))),
31
+ fork: (id, through) => Effect.gen(function* () {
32
+ const parent = yield* get(id), trail = (yield* read(id, -1)).filter((event) => event.seq <= through);
33
+ if (trail.reduce((runs, event) => {
34
+ if (event.runId === void 0)
35
+ return runs;
36
+ if (event.name === "run.started")
37
+ return [...runs, event.runId];
38
+ if (["run.completed", "run.failed", "run.cancelled"].includes(event.name))
39
+ return runs.filter((run) => run !== event.runId);
40
+ return runs;
41
+ }, []).length > 0)
42
+ return yield* Effect.fail(new HarnessError({ code: "session.fork-boundary", message: "Fork at a settled turn boundary" }));
43
+ return yield* operation(() => db.transaction(() => {
44
+ const record = insert({ ...parent, id: ConversationId.make(crypto.randomUUID()), parent: id, createdAt: Date.now() });
45
+ trail.forEach((event, seq) => db.query("INSERT INTO harness_events VALUES (?, ?, ?)").run(record.id, seq, JSON.stringify({ ...event, sessionId: record.id, id: crypto.randomUUID(), seq })));
46
+ return record;
47
+ })());
48
+ })
49
+ });
50
+ })), sessionSqlitePlugin = definePlugin({
51
+ id: "@xandreed/plugin-session-sqlite",
52
+ version: "0.3.0",
53
+ scope: "runtime",
54
+ config: Config,
55
+ defaults: { path: ".efferent/runtime/sessions.db" },
56
+ provides: [SessionStore],
57
+ layer: ({ path }) => SessionStoreLive(path)
58
+ });
59
+ export default sessionSqlitePlugin;
60
+ export { SqliteConversationStoreLive } from "./store/sqliteStore.js";
@@ -0,0 +1,3 @@
1
+ import { Layer } from "effect";
2
+ import { ConversationStore, StoreError } from "@xandreed/core";
3
+ export declare const SqliteConversationStoreLive: (dbPath: string) => Layer.Layer<ConversationStore, StoreError, never>;
@@ -0,0 +1,175 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { chmodSync, mkdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { Effect, Either, Layer, Option, Schema } from "effect";
5
+ import { AgentMessage, Checkpoint, ConversationId, ConversationStore, ConversationSummary, RunOutcomeRecord, StoredMessage, StoreError } from "@xandreed/core";
6
+ const MIGRATIONS = [
7
+ `
8
+ CREATE TABLE IF NOT EXISTS conversations (
9
+ id TEXT PRIMARY KEY,
10
+ workspace_dir TEXT,
11
+ title TEXT,
12
+ created_at INTEGER NOT NULL
13
+ );
14
+ CREATE TABLE IF NOT EXISTS messages (
15
+ conversation_id TEXT NOT NULL,
16
+ position INTEGER NOT NULL,
17
+ content TEXT NOT NULL,
18
+ created_at INTEGER NOT NULL,
19
+ PRIMARY KEY (conversation_id, position)
20
+ );
21
+ CREATE TABLE IF NOT EXISTS checkpoints (
22
+ conversation_id TEXT NOT NULL,
23
+ message_position INTEGER NOT NULL,
24
+ summary TEXT NOT NULL,
25
+ created_at INTEGER NOT NULL
26
+ );
27
+ `,
28
+ `
29
+ CREATE INDEX IF NOT EXISTS checkpoints_by_conversation
30
+ ON checkpoints (conversation_id, message_position);
31
+ CREATE INDEX IF NOT EXISTS conversations_by_workspace
32
+ ON conversations (workspace_dir);
33
+ `,
34
+ `
35
+ CREATE TABLE IF NOT EXISTS run_outcomes (
36
+ conversation_id TEXT NOT NULL,
37
+ at INTEGER NOT NULL,
38
+ outcome TEXT NOT NULL,
39
+ reason TEXT NOT NULL
40
+ );
41
+ CREATE INDEX IF NOT EXISTS run_outcomes_by_conversation
42
+ ON run_outcomes (conversation_id, at);
43
+ `
44
+ ], migrate = (db) => {
45
+ const version = db.query("PRAGMA user_version").get().user_version;
46
+ MIGRATIONS.slice(version).forEach((step) => db.transaction(() => db.exec(step))());
47
+ db.exec(`PRAGMA user_version = ${MIGRATIONS.length}`);
48
+ }, decodeMessage = Schema.decodeUnknownEither(Schema.parseJson(AgentMessage)), tryDb = (run) => Effect.try({
49
+ try: run,
50
+ catch: (e) => new StoreError({ message: String(e) })
51
+ }), salvageRows = (rows, where) => Effect.forEach(rows, (row) => Either.match(decodeMessage(row.content), {
52
+ onLeft: (issue) => Effect.logWarning(`${where}: skipping undecodable message row: ${String(issue)}`).pipe(Effect.as(Option.none())),
53
+ onRight: (message) => Effect.succeed(Option.some(message))
54
+ })).pipe(Effect.map((decoded) => decoded.filter(Option.isSome).map((some) => some.value))), salvagePositioned = (rows, where) => Effect.forEach(rows, (row) => Either.match(decodeMessage(row.content), {
55
+ onLeft: (issue) => Effect.logWarning(`${where}: skipping undecodable message row at ${row.position}: ${String(issue)}`).pipe(Effect.as(Option.none())),
56
+ onRight: (message) => Effect.succeed(Option.some(new StoredMessage({ position: row.position, message })))
57
+ })).pipe(Effect.map((decoded) => decoded.filter(Option.isSome).map((some) => some.value)));
58
+ export const SqliteConversationStoreLive = (dbPath) => Layer.scoped(ConversationStore, Effect.gen(function* () {
59
+ const db = yield* tryDb(() => {
60
+ mkdirSync(dirname(dbPath), { recursive: !0 });
61
+ const database = new Database(dbPath, { create: !0 });
62
+ chmodSync(dbPath, 384);
63
+ database.exec("PRAGMA busy_timeout = 5000;");
64
+ database.exec("PRAGMA journal_mode = WAL;");
65
+ migrate(database);
66
+ return database;
67
+ });
68
+ yield* Effect.addFinalizer(() => tryDb(() => db.close()).pipe(Effect.catchAll((error) => Effect.logWarning(`conversation database cleanup failed: ${error.message}`))));
69
+ const latestCheckpointRow = (id) => Option.fromNullable(db.query(`SELECT message_position, summary, created_at FROM checkpoints
70
+ WHERE conversation_id = ? ORDER BY message_position DESC LIMIT 1`).get(id));
71
+ return {
72
+ create: (workspaceDir) => tryDb(() => {
73
+ const id = ConversationId.make(crypto.randomUUID());
74
+ db.query("INSERT INTO conversations (id, workspace_dir, title, created_at) VALUES (?, ?, NULL, ?)").run(id, workspaceDir ?? null, Date.now());
75
+ return id;
76
+ }),
77
+ append: (id, message) => tryDb(() => db.query(`INSERT INTO messages (conversation_id, position, content, created_at)
78
+ SELECT ?1, COALESCE(MAX(position) + 1, 0), ?2, ?3
79
+ FROM messages WHERE conversation_id = ?1
80
+ RETURNING position`).get(id, JSON.stringify(message), Date.now()).position),
81
+ appendAll: (id, messages) => tryDb(() => db.transaction(() => messages.map((message) => db.query(`INSERT INTO messages (conversation_id, position, content, created_at)
82
+ SELECT ?1, COALESCE(MAX(position) + 1, 0), ?2, ?3
83
+ FROM messages WHERE conversation_id = ?1
84
+ RETURNING position`).get(id, JSON.stringify(message), Date.now()).position))()),
85
+ list: (id) => tryDb(() => db.query("SELECT content FROM messages WHERE conversation_id = ? ORDER BY position ASC").all(id)).pipe(Effect.flatMap((rows) => salvageRows(rows, `list ${id}`))),
86
+ listActive: (id) => tryDb(() => {
87
+ const fold = latestCheckpointRow(id), after = Option.match(fold, {
88
+ onNone: () => -1,
89
+ onSome: (c) => c.message_position
90
+ });
91
+ return db.query(`SELECT position, content FROM messages
92
+ WHERE conversation_id = ? AND position > ? ORDER BY position ASC`).all(id, after);
93
+ }).pipe(Effect.flatMap((rows) => salvagePositioned(rows, `listActive ${id}`))),
94
+ checkpoint: (id, summary) => tryDb(() => {
95
+ db.query(`INSERT INTO checkpoints (conversation_id, message_position, summary, created_at)
96
+ SELECT ?1, COALESCE(MAX(position), -1), ?2, ?3
97
+ FROM messages WHERE conversation_id = ?1`).run(id, summary, Date.now());
98
+ }),
99
+ checkpointAt: (id, summary, messagePosition) => tryDb(() => {
100
+ db.query(`INSERT INTO checkpoints (conversation_id, message_position, summary, created_at)
101
+ VALUES (?1, ?2, ?3, ?4)`).run(id, messagePosition, summary, Date.now());
102
+ }),
103
+ latestCheckpoint: (id) => tryDb(() => Option.map(latestCheckpointRow(id), (row) => new Checkpoint({
104
+ conversationId: id,
105
+ messagePosition: row.message_position,
106
+ summary: row.summary,
107
+ createdAt: row.created_at
108
+ }))),
109
+ setTitle: (id, title) => tryDb(() => {
110
+ db.query("UPDATE conversations SET title = ? WHERE id = ?").run(title, id);
111
+ }),
112
+ recordOutcome: (id, outcome, reason) => tryDb(() => {
113
+ db.query("INSERT INTO run_outcomes (conversation_id, at, outcome, reason) VALUES (?, ?, ?, ?)").run(id, Date.now(), outcome, reason);
114
+ }),
115
+ latestOutcome: (id) => tryDb(() => Option.fromNullable(db.query(`SELECT at, outcome, reason FROM run_outcomes
116
+ WHERE conversation_id = ? ORDER BY at DESC, rowid DESC LIMIT 1`).get(id))).pipe(Effect.flatMap((row) => Option.match(row, {
117
+ onNone: () => Effect.succeed(Option.none()),
118
+ onSome: (r) => Schema.decodeUnknown(RunOutcomeRecord)({ conversationId: id, ...r }).pipe(Effect.map(Option.some), Effect.mapError((issue) => new StoreError({ message: String(issue) })))
119
+ }))),
120
+ prune: (beforeEpochMs) => tryDb(() => {
121
+ const removed = db.transaction(() => {
122
+ const ids = db.query("SELECT id FROM conversations WHERE created_at < ?").all(beforeEpochMs).map((row) => row.id);
123
+ ids.forEach((id) => {
124
+ db.query("DELETE FROM messages WHERE conversation_id = ?").run(id);
125
+ db.query("DELETE FROM checkpoints WHERE conversation_id = ?").run(id);
126
+ db.query("DELETE FROM conversations WHERE id = ?").run(id);
127
+ });
128
+ return ids.length;
129
+ })();
130
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE);");
131
+ return removed;
132
+ }),
133
+ fork: (id, upToPosition) => tryDb(() => Option.fromNullable(db.query("SELECT workspace_dir, title FROM conversations WHERE id = ?").get(id))).pipe(Effect.flatMap(Option.match({
134
+ onNone: () => Effect.fail(new StoreError({ message: `conversation ${id} not found` })),
135
+ onSome: (source) => tryDb(() => {
136
+ const forkId = ConversationId.make(crypto.randomUUID()), cap = upToPosition ?? Number.MAX_SAFE_INTEGER;
137
+ db.transaction(() => {
138
+ db.query("INSERT INTO conversations (id, workspace_dir, title, created_at) VALUES (?, ?, ?, ?)").run(forkId, source.workspace_dir, source.title === null ? null : `fork: ${source.title}`, Date.now());
139
+ db.query(`INSERT INTO messages (conversation_id, position, content, created_at)
140
+ SELECT ?2, position, content, created_at FROM messages
141
+ WHERE conversation_id = ?1 AND position <= ?3`).run(id, forkId, cap);
142
+ db.query(`INSERT INTO checkpoints (conversation_id, message_position, summary, created_at)
143
+ SELECT ?2, message_position, summary, created_at FROM checkpoints
144
+ WHERE conversation_id = ?1 AND message_position <= ?3
145
+ ORDER BY message_position DESC LIMIT 1`).run(id, forkId, cap);
146
+ })();
147
+ return forkId;
148
+ })
149
+ }))),
150
+ listByWorkspace: (workspaceDir) => tryDb(() => db.query(`SELECT c.id, c.created_at, c.title,
151
+ (SELECT m.content FROM messages m
152
+ WHERE m.conversation_id = c.id ORDER BY m.position ASC LIMIT 1)
153
+ AS first_content,
154
+ (SELECT o.outcome FROM run_outcomes o
155
+ WHERE o.conversation_id = c.id ORDER BY o.at DESC, o.rowid DESC LIMIT 1)
156
+ AS last_outcome,
157
+ (SELECT o.reason FROM run_outcomes o
158
+ WHERE o.conversation_id = c.id ORDER BY o.at DESC, o.rowid DESC LIMIT 1)
159
+ AS last_reason
160
+ FROM conversations c WHERE c.workspace_dir = ?
161
+ ORDER BY c.created_at DESC`).all(workspaceDir).map((row) => {
162
+ const first = Option.fromNullable(row.first_content).pipe(Option.flatMap((content) => Either.getRight(decodeMessage(content))), Option.flatMap((parsed) => parsed.role === "user" ? Option.some(parsed.content.slice(0, 120)) : Option.none())), lastOutcome = Option.flatMap(Option.all([Option.fromNullable(row.last_outcome), Option.fromNullable(row.last_reason)]), ([outcome, reason]) => Either.getRight(Schema.decodeUnknownEither(Schema.Struct({
163
+ outcome: Schema.Literal("ok", "partial"),
164
+ reason: Schema.Literal("completed", "step-cap", "degenerate-loop")
165
+ }))({ outcome, reason })));
166
+ return new ConversationSummary({
167
+ id: ConversationId.make(row.id),
168
+ createdAt: row.created_at,
169
+ firstPrompt: first,
170
+ title: Option.fromNullable(row.title),
171
+ lastOutcome
172
+ });
173
+ }))
174
+ };
175
+ }));
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@xandreed/plugin-session-sqlite",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "@xandreed/core": "0.3.0",
14
+ "effect": "3.21.4",
15
+ "ws": "^8.21.0"
16
+ },
17
+ "private": false,
18
+ "types": "./dist/index.d.ts",
19
+ "files": [
20
+ "dist",
21
+ "skills",
22
+ "profiles",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "engines": {
27
+ "bun": ">=1.3.0"
28
+ },
29
+ "license": "MIT"
30
+ }