@xandreed/plugin-session-sqlite 0.12.0 → 0.13.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/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # @xandreed/plugin-session-sqlite
2
2
 
3
- Efferent composable agent framework, 0.12.0. Bun only (bun:sqlite).
3
+ Efferent composable agent framework, 0.13.0. Bun only.
4
4
 
5
5
  Documentation: https://github.com/xandreeddev/efferent
package/dist/index.d.ts CHANGED
@@ -1,19 +1,9 @@
1
- import { Layer } from "effect";
2
- import { SessionEnvironment, SessionLog, SessionStore, TurnAdmission } from "@xandreed/core";
3
- /** @deprecated The historical store API is a projection over the unified log. */
4
- export declare const SessionStoreLive: (path: string, options?: {
5
- readonly legacyPaths?: ReadonlyArray<string>;
6
- readonly legacyOwner?: string;
7
- }) => Layer.Layer<SessionLog | TurnAdmission | SessionStore, import("@xandreed/core").SessionLogError, never>;
1
+ import { SessionLog, TurnAdmission } from "@xandreed/core";
8
2
  /** Storage and the open admission policy; compose sessionsPlugin explicitly in the host graph. */
9
3
  export declare const sessionSqlitePlugin: import("@xandreed/core").TypedPlugin<{
10
4
  readonly path: string;
11
- readonly legacyPaths: readonly string[];
12
5
  }, {
13
6
  readonly path: string;
14
- readonly legacyPaths: readonly string[];
15
- }, SessionLog | TurnAdmission | SessionStore, import("@xandreed/core").SessionLogError, SessionEnvironment>;
7
+ }, SessionLog | TurnAdmission, import("@xandreed/core").SessionLogError, never>;
16
8
  export default sessionSqlitePlugin;
17
- export { ConversationStoreProjectionLive, SqliteConversationStoreLive } from "./store/sqliteStore.js";
18
- export { SessionStoreProjectionLive } from "./compatibility.adapter.js";
19
9
  export { SessionLogSqliteLive, sessionLogSqlitePlugin } from "./session-log.adapter.js";
package/dist/index.js CHANGED
@@ -1,19 +1,15 @@
1
- import { Effect, Layer, Schema } from "effect";
2
- import { definePlugin, SessionEnvironment, SessionLog, SessionStore, TurnAdmission, TurnAdmissionOpen } from "@xandreed/core";
3
- import { SessionStoreProjectionLive } from "./compatibility.adapter.js";
1
+ import { Layer, Schema } from "effect";
2
+ import { definePlugin, SessionLog, TurnAdmission, TurnAdmissionOpen } from "@xandreed/core";
4
3
  import { SessionLogSqliteLive } from "./session-log.adapter.js";
5
- const Config = Schema.Struct({ path: Schema.String, legacyPaths: Schema.Array(Schema.String) });
6
- export const SessionStoreLive = (path, options = {}) => Layer.mergeAll(SessionStoreProjectionLive.pipe(Layer.provideMerge(SessionLogSqliteLive(path, options))), TurnAdmissionOpen), sessionSqlitePlugin = definePlugin({
4
+ const Config = Schema.Struct({ path: Schema.String });
5
+ export const sessionSqlitePlugin = definePlugin({
7
6
  id: "@xandreed/plugin-session-sqlite",
8
- version: "0.12.0",
7
+ version: "0.13.0",
9
8
  scope: "runtime",
10
9
  config: Config,
11
- defaults: { path: ".efferent/runtime/sessions.db", legacyPaths: [] },
12
- requires: [SessionEnvironment],
13
- provides: [SessionLog, SessionStore, TurnAdmission],
14
- layer: ({ path, legacyPaths }) => Layer.unwrap(SessionEnvironment.pipe(Effect.map(({ workspace }) => SessionStoreLive(path, { legacyPaths, legacyOwner: workspace }))))
10
+ defaults: { path: ".efferent/runtime/sessions.db" },
11
+ provides: [SessionLog, TurnAdmission],
12
+ layer: ({ path }) => Layer.merge(SessionLogSqliteLive(path), TurnAdmissionOpen)
15
13
  });
16
14
  export default sessionSqlitePlugin;
17
- export { ConversationStoreProjectionLive, SqliteConversationStoreLive } from "./store/sqliteStore.js";
18
- export { SessionStoreProjectionLive } from "./compatibility.adapter.js";
19
15
  export { SessionLogSqliteLive, sessionLogSqlitePlugin } from "./session-log.adapter.js";
@@ -1,47 +1,15 @@
1
- import { Effect, Layer, Option } from "effect";
1
+ import { Effect, Layer } from "effect";
2
2
  import type { Scope } from "effect";
3
- import { ConversationId, SessionLog, SessionLogError } from "@xandreed/core";
4
- import type { JsonObject } from "@xandreed/core";
5
- /** One conversation as the positional listing shows it: its first message and its latest title and outcome, as stored. */
6
- export interface ConversationOverview {
7
- readonly id: ConversationId;
8
- readonly createdAt: number;
9
- readonly first: Option.Option<JsonObject>;
10
- readonly title: Option.Option<JsonObject>;
11
- readonly outcome: Option.Option<JsonObject>;
12
- }
13
- /**
14
- * What this package's compatibility projections use of a SQLite log beyond
15
- * the SessionLog contract: set-based reads, retention in one transaction,
16
- * and importing a host's own older file.
17
- */
18
- export interface SqliteLogInternals {
19
- /** The owner's conversation sessions (origin `conversation`), newest first, in one query. */
20
- readonly conversations: (owner: string) => Effect.Effect<ReadonlyArray<ConversationOverview>, SessionLogError>;
21
- /** Remove the conversation sessions created before `before` in one transaction, then reclaim the space; returns how many. */
22
- readonly prune: (before: number) => Effect.Effect<number, SessionLogError>;
23
- /** Import older files through read-only connections (absent ones are skipped); orphan rows without a session default to `owner`. */
24
- readonly importLegacy: (paths: ReadonlyArray<string>, owner: Option.Option<string>) => Effect.Effect<void, SessionLogError>;
25
- }
26
- /** The SQLite internals of a log `SessionLogSqliteLive` built; none for any other SessionLog. */
27
- export declare const sqliteLogInternals: (log: SessionLog["Service"]) => Option.Option<SqliteLogInternals>;
3
+ import { SessionLog, SessionLogError } from "@xandreed/core";
28
4
  /**
29
5
  * The authoritative session journal in one SQLite file: `session_heads`
30
- * and `session_log_events`. Legacy tables remain preserved, read only after
31
- * their import. Removal cascades to events and children and leaves a
32
- * tombstone, so no import brings a removed session back. A commit is one
6
+ * and `session_log_events`. Removal cascades to events and children. A commit is one
33
7
  * IMMEDIATE transaction that checks the revision and the clock before it
34
8
  * writes; the clock is the process's.
35
9
  */
36
- export declare const makeSessionLogSqlite: (path: string, options?: {
37
- readonly legacyPaths?: ReadonlyArray<string>;
38
- readonly legacyOwner?: string;
39
- }) => Effect.Effect<SessionLog["Service"], SessionLogError, Scope.Scope>;
10
+ export declare const makeSessionLogSqlite: (path: string) => Effect.Effect<SessionLog["Service"], SessionLogError, Scope.Scope>;
40
11
  /** The session log over one SQLite file (see `makeSessionLogSqlite`). */
41
- export declare const SessionLogSqliteLive: (path: string, options?: {
42
- readonly legacyPaths?: ReadonlyArray<string>;
43
- readonly legacyOwner?: string;
44
- }) => Layer.Layer<SessionLog, SessionLogError>;
12
+ export declare const SessionLogSqliteLive: (path: string) => Layer.Layer<SessionLog, SessionLogError>;
45
13
  /** The session log in a SQLite file, as a plugin: provides SessionLog. */
46
14
  export declare const sessionLogSqlitePlugin: import("@xandreed/core").TypedPlugin<{
47
15
  readonly path: string;
@@ -1,6 +1,6 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { mkdir } from "node:fs/promises";
3
- import { chmodSync, existsSync, realpathSync } from "node:fs";
3
+ import { chmodSync } from "node:fs";
4
4
  import { dirname } from "node:path";
5
5
  import { Clock, Effect, Layer, Option, Schema } from "effect";
6
6
  import {
@@ -13,7 +13,6 @@ import {
13
13
  SessionLogError,
14
14
  SessionMissing
15
15
  } from "@xandreed/core";
16
- import { importLegacySessions, LEGACY_BOOKKEEPING } from "./legacy-session.adapter.js";
17
16
  const failure = (code) => (error) => new SessionLogError({ code, message: String(error) }), json = (text) => JSON.parse(text), headOf = (row, now) => ({
18
17
  header: {
19
18
  id: ConversationId.make(row.id),
@@ -39,8 +38,8 @@ const failure = (code) => (error) => new SessionLogError({ code, message: String
39
38
  kind: row.kind,
40
39
  at: row.at,
41
40
  data: json(row.data)
42
- }), internals = new WeakMap;
43
- export const sqliteLogInternals = (log) => Option.fromNullishOr(internals.get(log)), makeSessionLogSqlite = (path, options = {}) => Effect.gen(function* () {
41
+ });
42
+ export const makeSessionLogSqlite = (path) => Effect.gen(function* () {
44
43
  yield* Effect.tryPromise({ try: () => mkdir(dirname(path), { recursive: !0 }), catch: failure("store.open") });
45
44
  const db = yield* Effect.acquireRelease(Effect.try({ try: () => new Database(path, { create: !0, strict: !0 }), catch: failure("store.open") }), (database) => Effect.sync(() => database.close())), operation = (work) => Effect.try({ try: work, catch: failure("store.io") });
46
45
  yield* operation(() => chmodSync(path, 384));
@@ -58,26 +57,9 @@ export const sqliteLogInternals = (log) => Option.fromNullishOr(internals.get(lo
58
57
  session_id TEXT NOT NULL REFERENCES session_heads(id) ON DELETE CASCADE, seq INTEGER NOT NULL, turn INTEGER,
59
58
  kind TEXT NOT NULL, at INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(session_id, seq));
60
59
  CREATE INDEX IF NOT EXISTS session_log_events_kind ON session_log_events(session_id, kind, seq);
61
- ${LEGACY_BOOKKEEPING}
62
60
  `));
63
- const imported = (source, sourceId, owner) => operation(() => importLegacySessions(db, source, sourceId, owner)).pipe(Effect.tap((result) => Effect.forEach(result.skipped, (row) => Effect.logWarning(`legacy import of ${sourceId}: skipped an undecodable row: ${row}`), { discard: !0 })), Effect.flatMap((result) => Option.match(result.refused, {
64
- onNone: () => Effect.void,
65
- onSome: (message) => Effect.fail(new SessionLogError({ code: "store.legacy-conflict", message }))
66
- }))), importLegacy = (paths, owner) => Effect.gen(function* () {
67
- const destination = yield* operation(() => realpathSync(path)), sources = yield* operation(() => [...new Set(paths.filter(existsSync).map((source) => realpathSync(source)))].filter((source) => source !== destination));
68
- yield* Effect.forEach(sources, (source) => Effect.acquireUseRelease(operation(() => {
69
- const legacy = new Database(source, { readonly: !0, strict: !0 });
70
- legacy.exec("PRAGMA busy_timeout = 5000");
71
- return legacy;
72
- }), (legacy) => imported(legacy, source, owner), (legacy) => Effect.sync(() => legacy.close())), { discard: !0 });
73
- }), owner = Option.fromNullishOr(options.legacyOwner);
74
- yield* imported(db, "local", owner);
75
- yield* importLegacy(options.legacyPaths ?? [], owner);
76
- const headRow = (id) => db.query("SELECT * FROM session_heads WHERE id = ?").get(id), removeTrees = (ids, now) => ids.forEach((id) => {
77
- db.query(`WITH RECURSIVE tree(id) AS (SELECT id FROM session_heads WHERE id = ? UNION SELECT child.id FROM session_heads child JOIN tree ON child.parent_id = tree.id)
78
- INSERT OR IGNORE INTO session_log_tombstones (session_id, removed_at) SELECT id, ? FROM tree`).run(id, now);
79
- db.query("DELETE FROM session_heads WHERE id = ?").run(id);
80
- }), log = SessionLog.of({
61
+ const headRow = (id) => db.query("SELECT * FROM session_heads WHERE id = ?").get(id), removeTree = (id) => db.query("DELETE FROM session_heads WHERE id = ?").run(id);
62
+ return SessionLog.of({
81
63
  create: (header) => Effect.gen(function* () {
82
64
  const now = yield* Clock.currentTimeMillis, outcome = yield* operation(() => db.transaction(() => {
83
65
  if (headRow(header.id) !== null)
@@ -141,38 +123,15 @@ export const sqliteLogInternals = (log) => Option.fromNullishOr(internals.get(lo
141
123
  return Effect.fail(new RevisionConflict({ session: id, expected: commit.expect, actual: outcome.actual }));
142
124
  return Effect.fail(new LeaseExpired({ session: id, notAfter: Option.getOrElse(commit.notAfter, () => at), now: at }));
143
125
  })))),
144
- remove: (id) => Clock.currentTimeMillis.pipe(Effect.flatMap((now) => operation(() => db.transaction(() => removeTrees([id], now)).immediate())))
145
- });
146
- internals.set(log, {
147
- conversations: (owner) => operation(() => db.query(`
148
- SELECT head.id AS id, head.created_at AS created_at,
149
- (SELECT event.data FROM session_log_events event WHERE event.session_id = head.id AND event.kind = 'conversation.message' ORDER BY event.seq LIMIT 1) AS first,
150
- (SELECT event.data FROM session_log_events event WHERE event.session_id = head.id AND event.kind = 'conversation.title' ORDER BY event.seq DESC LIMIT 1) AS title,
151
- (SELECT event.data FROM session_log_events event WHERE event.session_id = head.id AND event.kind = 'conversation.outcome' ORDER BY event.seq DESC LIMIT 1) AS outcome
152
- FROM session_heads head WHERE head.owner = ? AND head.origin = 'conversation' ORDER BY head.created_at DESC, head.id DESC`).all(owner).map((row) => ({
153
- id: ConversationId.make(row.id),
154
- createdAt: row.created_at,
155
- first: Option.map(Option.fromNullishOr(row.first), json),
156
- title: Option.map(Option.fromNullishOr(row.title), json),
157
- outcome: Option.map(Option.fromNullishOr(row.outcome), json)
158
- }))),
159
- prune: (before) => Clock.currentTimeMillis.pipe(Effect.flatMap((now) => operation(() => {
160
- const removed = db.transaction(() => {
161
- const ids = db.query("SELECT id FROM session_heads WHERE origin = 'conversation' AND created_at < ?").all(before).map((row) => row.id);
162
- removeTrees(ids, now);
163
- return ids.length;
164
- }).immediate();
165
- db.exec("PRAGMA wal_checkpoint(TRUNCATE);");
166
- return removed;
167
- }))),
168
- importLegacy
126
+ remove: (id) => operation(() => db.transaction(() => {
127
+ removeTree(id);
128
+ }).immediate())
169
129
  });
170
- return log;
171
- }), SessionLogSqliteLive = (path, options = {}) => Layer.effect(SessionLog, makeSessionLogSqlite(path, options));
130
+ }), SessionLogSqliteLive = (path) => Layer.effect(SessionLog, makeSessionLogSqlite(path));
172
131
  const LogConfig = Schema.Struct({ path: Schema.String });
173
132
  export const sessionLogSqlitePlugin = definePlugin({
174
133
  id: "@xandreed/plugin-session-sqlite/log",
175
- version: "0.12.0",
134
+ version: "0.13.0",
176
135
  scope: "runtime",
177
136
  config: LogConfig,
178
137
  defaults: { path: ".efferent/runtime/sessions.db" },
package/package.json CHANGED
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "name": "@xandreed/plugin-session-sqlite",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
+ "engines": {
5
+ "bun": ">=1.3.0"
6
+ },
4
7
  "type": "module",
5
8
  "main": "./dist/index.js",
6
9
  "exports": {
@@ -10,8 +13,8 @@
10
13
  }
11
14
  },
12
15
  "dependencies": {
13
- "@xandreed/core": "0.12.0",
14
- "effect": "4.0.0-rc.118",
16
+ "@xandreed/core": "0.13.0",
17
+ "effect": "4.0.7",
15
18
  "ws": "^8.21.0"
16
19
  },
17
20
  "private": false,
@@ -23,8 +26,5 @@
23
26
  "README.md",
24
27
  "LICENSE"
25
28
  ],
26
- "engines": {
27
- "bun": ">=1.3.0"
28
- },
29
29
  "license": "MIT"
30
30
  }
@@ -1,35 +0,0 @@
1
- import { Effect, Layer, Option } from "effect";
2
- import { ConversationId, HarnessError, SessionEvent, SessionLog, SessionStore } from "@xandreed/core";
3
- import type { EventBody, SessionCommitted, SessionDraft, SessionHead, SessionLogEvent } from "@xandreed/core";
4
- type Log = SessionLog["Service"];
5
- /** Read inherited records at their immutable fork boundary, then the child's own records. A fork at `through <= 0` inherits nothing. */
6
- export declare const compatibilityHistory: (log: Log, id: ConversationId, kinds: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<SessionLogEvent>, HarnessError>;
7
- /**
8
- * The last event of `kinds` at or before `upTo` (the head when omitted), or
9
- * the inherited history's when the session has none of its own: a binary
10
- * search over the cursor, so an append never reads the whole history.
11
- */
12
- export declare const compatibilityLast: (log: Log, id: ConversationId, kinds: ReadonlyArray<string>, upTo?: number) => Effect.Effect<Option.Option<SessionLogEvent>, HarnessError>;
13
- /**
14
- * Plan against the head's revision; a conflict re-reads and re-plans. The
15
- * first retries are immediate, later ones wait a little (jittered), so an
16
- * append outside a turn outlasts a busy turn writer instead of failing.
17
- */
18
- export declare const compatibilityCommit: <A>(log: Log, id: ConversationId, plan: (head: SessionHead) => Effect.Effect<{
19
- readonly drafts: ReadonlyArray<SessionDraft>;
20
- readonly result: A;
21
- }, HarnessError>) => Effect.Effect<{
22
- readonly committed: SessionCommitted;
23
- readonly result: A;
24
- }, HarnessError>;
25
- /** Includes branches, preserving the historical list contract. */
26
- export declare const compatibilityListing: (log: Log, owner: string, parent?: Option.Option<string & import("effect/Brand").Brand<"ConversationId">>) => Effect.Effect<ReadonlyArray<SessionHead>, HarnessError>;
27
- export declare const harnessHistory: (log: Log, id: ConversationId, after?: number) => Effect.Effect<ReadonlyArray<SessionEvent>, HarnessError>;
28
- export declare const harnessEvent: (id: ConversationId, body: EventBody, seq: number, at: number) => SessionEvent;
29
- /**
30
- * @deprecated Historical harness vocabulary projected over SessionLog.
31
- * It owns no tables and can be removed once custom AgentLoop hosts use the
32
- * composable Agent/Turn API. Harness itself admits and ends through Sessions.
33
- */
34
- export declare const SessionStoreProjectionLive: Layer.Layer<SessionStore, never, SessionLog>;
35
- export {};
@@ -1,91 +0,0 @@
1
- import { Clock, Effect, Layer, Option, Random, Schema } from "effect";
2
- import { ConversationId, HarnessError, SessionEvent, SessionLog, SessionRecord, SessionStore } from "@xandreed/core";
3
- const storageFailure = (error) => new HarnessError({ code: error._tag === "SessionMissing" ? "session.missing" : "session.log", message: error.message ?? error._tag }), eventOf = Schema.decodeUnknownEffect(SessionEvent), decodeFailure = (error) => new HarnessError({ code: "session.decode", message: String(error) }), HARNESS = "harness.event", SETTLED = ["run.completed", "run.failed", "run.cancelled"], harnessSeqOf = (event) => {
4
- const inner = event.data.event;
5
- return typeof inner === "object" && inner !== null && "seq" in inner && typeof inner.seq === "number" ? inner.seq : Number.POSITIVE_INFINITY;
6
- };
7
- export const compatibilityHistory = (log, id, kinds) => Effect.gen(function* () {
8
- const head = yield* log.head(id).pipe(Effect.mapError(storageFailure)), inherited = yield* Option.match(head.header.parent, {
9
- onNone: () => Effect.succeed([]),
10
- onSome: (parent) => parent.through <= 0 ? Effect.succeed([]) : compatibilityHistory(log, parent.id, kinds).pipe(Effect.map((events) => events.filter((event) => event.session !== parent.id || event.seq <= parent.through)))
11
- }), own = yield* log.read(id, { after: 0, limit: Option.none(), kinds }).pipe(Effect.mapError(storageFailure));
12
- return [...inherited, ...own];
13
- });
14
- const nextOf = (log, id, kinds, after, upTo) => log.read(id, { after, limit: Option.some(1), kinds }).pipe(Effect.map((events) => Option.filter(Option.fromNullishOr(events[0]), (event) => event.seq <= upTo)), Effect.mapError(storageFailure)), firstHolding = (low, high, holds) => low >= high ? Effect.succeed(high) : Effect.gen(function* () {
15
- const middle = Math.floor((low + high) / 2);
16
- return (yield* holds(middle)) ? yield* firstHolding(low, middle, holds) : yield* firstHolding(middle + 1, high, holds);
17
- });
18
- export const compatibilityLast = (log, id, kinds, upTo = Number.MAX_SAFE_INTEGER) => Effect.gen(function* () {
19
- const head = yield* log.head(id).pipe(Effect.mapError(storageFailure)), bound = Math.min(head.seq, upTo), cursor = yield* firstHolding(0, bound, (at) => nextOf(log, id, kinds, at, bound).pipe(Effect.map(Option.isNone)));
20
- if (cursor > 0)
21
- return yield* nextOf(log, id, kinds, cursor - 1, bound);
22
- return yield* Option.match(head.header.parent, {
23
- onNone: () => Effect.succeed(Option.none()),
24
- onSome: (parent) => parent.through <= 0 ? Effect.succeed(Option.none()) : compatibilityLast(log, parent.id, kinds, parent.through)
25
- });
26
- });
27
- const harnessAfter = (log, id, after, upTo = Number.MAX_SAFE_INTEGER) => Effect.gen(function* () {
28
- const head = yield* log.head(id).pipe(Effect.mapError(storageFailure)), bound = Math.min(head.seq, upTo), beyond = (cursor) => nextOf(log, id, [HARNESS], cursor, bound).pipe(Effect.map(Option.match({ onNone: () => !0, onSome: (event) => harnessSeqOf(event) > after }))), cursor = after < 0 || (yield* beyond(0)) ? 0 : yield* firstHolding(1, bound, beyond), own = (yield* log.read(id, { after: cursor, limit: Option.none(), kinds: [HARNESS] }).pipe(Effect.mapError(storageFailure))).filter((event) => event.seq <= bound && harnessSeqOf(event) > after);
29
- return [...cursor > 0 ? [] : yield* Option.match(head.header.parent, {
30
- onNone: () => Effect.succeed([]),
31
- onSome: (parent) => parent.through <= 0 ? Effect.succeed([]) : harnessAfter(log, parent.id, after, parent.through)
32
- }), ...own];
33
- });
34
- export const compatibilityCommit = (log, id, plan) => {
35
- const attempt = (tries) => Effect.gen(function* () {
36
- const head = yield* log.head(id).pipe(Effect.mapError(storageFailure)), planned = yield* plan(head);
37
- return yield* log.commit(id, { expect: head.revision, notAfter: Option.none(), state: Option.none(), events: planned.drafts }).pipe(Effect.map((committed) => ({ committed, result: planned.result })), Effect.catchTag("RevisionConflict", (conflict) => tries >= 32 ? Effect.fail(storageFailure(conflict)) : tries < 4 ? attempt(tries + 1) : Random.nextIntBetween(1, Math.min(2 ** (tries - 3), 50)).pipe(Effect.flatMap((wait) => Effect.sleep(`${wait} millis`)), Effect.andThen(attempt(tries + 1)))), Effect.mapError((error) => error instanceof HarnessError ? error : storageFailure(error)));
38
- });
39
- return attempt(0);
40
- }, compatibilityListing = (log, owner, parent = Option.none()) => Effect.gen(function* () {
41
- const heads = yield* log.list({ owner, limit: Number.MAX_SAFE_INTEGER, before: Option.none(), parent }).pipe(Effect.mapError(storageFailure)), children = yield* Effect.forEach(heads, (head) => compatibilityListing(log, owner, Option.some(head.header.id)));
42
- return [...heads, ...children.flat()].sort((a, b) => b.header.createdAt - a.header.createdAt);
43
- });
44
- const recordOf = (head) => Schema.decodeUnknownEffect(SessionRecord)({
45
- id: head.header.id,
46
- workspace: head.header.owner,
47
- profile: head.header.meta.profile ?? "smith",
48
- createdAt: head.header.createdAt,
49
- ...Option.match(head.header.parent, { onNone: () => typeof head.header.meta.legacyParent === "string" ? { parent: head.header.meta.legacyParent } : {}, onSome: (parent) => ({ parent: parent.id }) })
50
- }).pipe(Effect.mapError((error) => new HarnessError({ code: "session.decode", message: String(error) }))), decodedHarness = (id, events) => Effect.forEach(events, (event) => eventOf(event.data.event).pipe(Effect.map((decoded) => ({ ...decoded, sessionId: id })), Effect.mapError(decodeFailure)));
51
- export const harnessHistory = (log, id, after = -1) => harnessAfter(log, id, after).pipe(Effect.flatMap((events) => decodedHarness(id, events))), harnessEvent = (id, body, seq, at) => ({ ...body, version: 1, id: crypto.randomUUID(), sessionId: id, seq, at });
52
- const isHarness = (event) => event.kind === HARNESS;
53
- export const SessionStoreProjectionLive = Layer.effect(SessionStore, Effect.gen(function* () {
54
- const log = yield* SessionLog;
55
- return SessionStore.of({
56
- create: (workspace, profile) => Clock.currentTimeMillis.pipe(Effect.flatMap((createdAt) => log.create({
57
- id: ConversationId.make(crypto.randomUUID()),
58
- owner: workspace,
59
- origin: "harness",
60
- createdAt,
61
- meta: { workspace, profile, projection: "harness" },
62
- parent: Option.none()
63
- })), Effect.flatMap(recordOf), Effect.mapError((error) => error instanceof HarnessError ? error : storageFailure(error))),
64
- get: (id) => log.head(id).pipe(Effect.mapError(storageFailure), Effect.flatMap(recordOf)),
65
- list: (workspace) => compatibilityListing(log, workspace).pipe(Effect.flatMap((heads) => Effect.forEach(heads.filter((head) => head.header.origin !== "task"), recordOf))),
66
- read: (id, after) => harnessHistory(log, id, after),
67
- append: (id, body) => compatibilityCommit(log, id, (head) => Effect.gen(function* () {
68
- const last = yield* compatibilityLast(log, id, [HARNESS], head.seq), previous = yield* Option.match(last, {
69
- onNone: () => Effect.succeed(-1),
70
- onSome: (event) => eventOf(event.data.event).pipe(Effect.map((decoded) => decoded.seq), Effect.mapError(decodeFailure))
71
- }), event = harnessEvent(id, body, previous + 1, yield* Clock.currentTimeMillis);
72
- return { drafts: [{ kind: HARNESS, turn: Option.none(), data: { event } }], result: event };
73
- })).pipe(Effect.map((done) => done.result)),
74
- fork: (id, through) => Effect.gen(function* () {
75
- const parent = yield* log.head(id).pipe(Effect.mapError(storageFailure));
76
- if ((yield* harnessHistory(log, id)).filter((event) => event.seq <= through).reduce((runs, event) => event.runId === void 0 ? runs : event.name === "run.started" ? [...runs, event.runId] : SETTLED.includes(event.name) ? runs.filter((run) => run !== event.runId) : runs, []).length > 0)
77
- return yield* Effect.fail(new HarnessError({ code: "session.fork-boundary", message: "Fork at a settled turn boundary" }));
78
- const journal = yield* compatibilityHistory(log, id, []), lastIndex = journal.findLastIndex((event) => isHarness(event) && harnessSeqOf(event) <= through), endIndex = Option.flatMap(Option.fromNullishOr(journal[lastIndex]), (last) => Option.flatMap(Option.fromNullishOr(journal.slice(lastIndex + 1).find((event) => isHarness(event) || event.kind === "turn.ended" && event.session === last.session && Option.isSome(last.turn) && Option.contains(event.turn, last.turn.value))), (next) => isHarness(next) ? Option.none() : Option.some(journal.indexOf(next)))), boundaryIndex = Option.getOrElse(endIndex, () => lastIndex), inherited = journal.slice(0, boundaryIndex + 1), boundary = Option.fromNullishOr(journal[boundaryIndex]), inheritedCut = Option.exists(boundary, (event) => event.session !== id), copied = inheritedCut ? inherited.filter((event) => isHarness(event) || event.kind.startsWith("conversation.")) : [], turnAtFork = inherited.reduce((turns, event) => Option.match(event.turn, { onNone: () => turns, onSome: (turn) => Math.max(turns, turn) }), 0), created = yield* log.create({
79
- id: ConversationId.make(crypto.randomUUID()),
80
- owner: parent.header.owner,
81
- origin: parent.header.origin,
82
- createdAt: yield* Clock.currentTimeMillis,
83
- meta: { ...parent.header.meta, legacyParent: id },
84
- parent: Option.some({ id, through: inheritedCut ? 0 : Option.match(boundary, { onNone: () => 0, onSome: (event) => event.seq }), turnAtFork })
85
- }).pipe(Effect.mapError(storageFailure));
86
- if (copied.length > 0)
87
- yield* compatibilityCommit(log, created.header.id, () => Effect.succeed({ drafts: copied.map((event) => ({ kind: event.kind, turn: Option.none(), data: event.data })), result: void 0 })).pipe(Effect.onError(() => log.remove(created.header.id).pipe(Effect.ignore)));
88
- return yield* recordOf(created);
89
- }).pipe(Effect.uninterruptible)
90
- });
91
- }));
@@ -1 +0,0 @@
1
- export {};
@@ -1,24 +0,0 @@
1
- import { Database } from "bun:sqlite";
2
- import { Option } from "effect";
3
- /** What importing one source did: refused (nothing written), or imported, with the rows it could not decode and skipped. */
4
- export interface LegacyImport {
5
- readonly refused: Option.Option<string>;
6
- readonly skipped: ReadonlyArray<string>;
7
- }
8
- /**
9
- * The import bookkeeping: the sources already imported, and the sessions
10
- * removed since (a removed session is never imported again, whatever path
11
- * its source is found at).
12
- */
13
- export declare const LEGACY_BOOKKEEPING = "\n CREATE TABLE IF NOT EXISTS session_log_imports (source TEXT PRIMARY KEY, imported_at INTEGER NOT NULL);\n CREATE TABLE IF NOT EXISTS session_log_tombstones (session_id TEXT PRIMARY KEY, removed_at INTEGER NOT NULL);\n";
14
- /**
15
- * Import frozen pre-log tables once, in the destination's IMMEDIATE
16
- * transaction. Sources stay read only. A session is matched by id whatever
17
- * path its source is found at: events it already holds are not imported
18
- * again, a removed session is not imported again, and an orphan message row
19
- * (written under a harness id with no conversation row) joins the session
20
- * stored under its id, with that session's owner. A conflicting identity or
21
- * position refuses the entire source before any row is changed; a row that
22
- * does not decode is skipped and reported.
23
- */
24
- export declare const importLegacySessions: (database: Database, source: Database, sourceId: string, owner?: Option.Option<string>) => LegacyImport;
@@ -1,80 +0,0 @@
1
- import { Database } from "bun:sqlite";
2
- import { Option, Result, Schema } from "effect";
3
- import { canonicalJson, SessionEvent, SessionRecord } from "@xandreed/core";
4
- const decodeRecord = Schema.decodeUnknownResult(Schema.fromJsonString(SessionRecord)), decodeEvent = Schema.decodeUnknownResult(Schema.fromJsonString(SessionEvent)), hasTable = (database, name) => database.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) !== null;
5
- export const LEGACY_BOOKKEEPING = `
6
- CREATE TABLE IF NOT EXISTS session_log_imports (source TEXT PRIMARY KEY, imported_at INTEGER NOT NULL);
7
- CREATE TABLE IF NOT EXISTS session_log_tombstones (session_id TEXT PRIMARY KEY, removed_at INTEGER NOT NULL);
8
- `;
9
- const HARNESS_KINDS = ["harness.event"], CONVERSATION_KINDS = ["conversation.message", "conversation.checkpoint", "conversation.title", "conversation.outcome"], placeOf = (event) => {
10
- const inner = event.data.event;
11
- if (event.kind === "conversation.message" && typeof event.data.position === "number")
12
- return Option.some(`${event.kind}:${event.data.position}`);
13
- return event.kind === "harness.event" && typeof inner === "object" && inner !== null && "seq" in inner && typeof inner.seq === "number" ? Option.some(`${event.kind}:${inner.seq}`) : Option.none();
14
- };
15
- export const importLegacySessions = (database, source, sourceId, owner = Option.none()) => database.transaction(() => {
16
- database.exec(LEGACY_BOOKKEEPING);
17
- if (database.query("SELECT source FROM session_log_imports WHERE source = ?").get(sourceId) !== null)
18
- return { refused: Option.none(), skipped: [] };
19
- const removed = (id) => database.query("SELECT session_id FROM session_log_tombstones WHERE session_id = ?").get(id) !== null, stored = (id) => Option.fromNullishOr(database.query("SELECT owner, origin, created_at FROM session_heads WHERE id = ?").get(id)), records = hasTable(source, "harness_sessions") ? source.query("SELECT id, body FROM harness_sessions ORDER BY rowid").all().map((row) => ({ row, decoded: decodeRecord(row.body) })) : [], harness = records.flatMap(({ decoded }) => Result.match(decoded, { onFailure: () => [], onSuccess: (record) => [record] })).map((record) => {
20
- const rows = source.query("SELECT seq, body FROM harness_events WHERE session_id = ? ORDER BY seq").all(record.id).map((row) => ({ row, decoded: decodeEvent(row.body) }));
21
- return { plan: {
22
- id: record.id,
23
- owner: record.workspace,
24
- origin: "harness",
25
- createdAt: record.createdAt,
26
- kinds: HARNESS_KINDS,
27
- meta: { workspace: record.workspace, profile: record.profile, projection: "harness", ...Option.match(Option.fromNullishOr(record.parent), { onNone: () => ({}), onSome: (parent) => ({ legacyParent: parent }) }) },
28
- events: rows.flatMap(({ decoded }) => Result.match(decoded, { onFailure: () => [], onSuccess: (event) => [{ kind: "harness.event", at: event.at, data: { event } }] }))
29
- }, skipped: rows.flatMap(({ row, decoded }) => Result.match(decoded, { onFailure: (issue) => [`harness event ${record.id}#${row.seq}: ${String(issue)}`], onSuccess: () => [] })) };
30
- }), messages = hasTable(source, "messages"), conversations = hasTable(source, "conversations"), conversationRows = conversations ? source.query("SELECT id, workspace_dir, title, created_at FROM conversations ORDER BY rowid").all() : [], orphanRows = messages ? source.query(`SELECT conversation_id AS id, MIN(created_at) AS created_at FROM messages ${conversations ? "WHERE conversation_id NOT IN (SELECT id FROM conversations)" : ""} GROUP BY conversation_id`).all().map((row) => {
31
- const target = stored(row.id), planned = Option.fromNullishOr(harness.find((entry) => entry.plan.id === row.id)?.plan);
32
- return {
33
- id: row.id,
34
- title: null,
35
- workspace_dir: Option.getOrElse(Option.orElse(Option.map(target, (head) => head.owner), () => Option.map(planned, (plan) => plan.owner)), () => Option.getOrElse(owner, () => "conversation-store")),
36
- created_at: Option.getOrElse(Option.orElse(Option.map(target, (head) => head.created_at), () => Option.map(planned, (plan) => plan.createdAt)), () => row.created_at)
37
- };
38
- }) : [], checkpoints = hasTable(source, "checkpoints"), outcomes = hasTable(source, "run_outcomes"), conversationPlans = [...conversationRows, ...orphanRows].map((row) => ({
39
- id: row.id,
40
- owner: row.workspace_dir ?? "conversation-store",
41
- origin: "conversation",
42
- createdAt: row.created_at,
43
- kinds: CONVERSATION_KINDS,
44
- meta: { workspace: row.workspace_dir ?? "conversation-store", projection: "conversation" },
45
- events: [
46
- ...messages ? source.query("SELECT position, content, created_at FROM messages WHERE conversation_id = ? ORDER BY position").all(row.id).map((message) => ({ kind: "conversation.message", at: message.created_at, data: { position: message.position, content: message.content } })) : [],
47
- ...checkpoints ? source.query("SELECT message_position, summary, created_at FROM checkpoints WHERE conversation_id = ? ORDER BY rowid").all(row.id).map((checkpoint) => ({ kind: "conversation.checkpoint", at: checkpoint.created_at, data: { messagePosition: checkpoint.message_position, summary: checkpoint.summary, createdAt: checkpoint.created_at } })) : [],
48
- ...row.title === null ? [] : [{ kind: "conversation.title", at: row.created_at, data: { title: row.title } }],
49
- ...outcomes ? source.query("SELECT at, outcome, reason FROM run_outcomes WHERE conversation_id = ? ORDER BY at, rowid").all(row.id).map((outcome) => ({ kind: "conversation.outcome", at: outcome.at, data: { outcome: outcome.outcome, reason: outcome.reason, at: outcome.at } })) : []
50
- ]
51
- })), skipped = [
52
- ...records.flatMap(({ row, decoded }) => Result.match(decoded, { onFailure: (issue) => [`harness session ${row.id}: ${String(issue)}`], onSuccess: () => [] })),
53
- ...harness.flatMap((entry) => entry.skipped)
54
- ], plans = [...harness.map((entry) => entry.plan), ...conversationPlans].filter((plan) => !removed(plan.id)), identityConflict = plans.find((plan) => plans.some((other) => other.id === plan.id && other.owner !== plan.owner) || Option.exists(stored(plan.id), (head) => head.owner !== plan.owner || head.origin === plan.origin && head.created_at !== plan.createdAt));
55
- if (identityConflict !== void 0)
56
- return { refused: Option.some(`session ${identityConflict.id} has a conflicting legacy identity`), skipped };
57
- const indexed = plans.map((plan) => {
58
- const previous = database.query(`SELECT kind, data FROM session_log_events WHERE session_id = ? AND kind IN (${plan.kinds.map(() => "?").join(", ")}) ORDER BY seq`).all(plan.id, ...plan.kinds).map((row) => ({ kind: row.kind, data: JSON.parse(row.data) })).map((event) => ({ place: placeOf(event), content: `${event.kind}
59
- ${canonicalJson(event.data)}` })), places = new Map(previous.flatMap((event) => Option.toArray(Option.map(event.place, (place) => [place, event.content])))), present = new Set(previous.map((event) => event.content)), incoming = plan.events.map((event) => ({ event, place: placeOf(event), content: `${event.kind}
60
- ${canonicalJson(event.data)}` }));
61
- return {
62
- plan,
63
- conflict: incoming.some((entry) => Option.exists(entry.place, (place) => Option.exists(Option.fromNullishOr(places.get(place)), (content) => content !== entry.content))),
64
- fresh: incoming.filter((entry) => !present.has(entry.content)).map((entry) => entry.event)
65
- };
66
- }), positionConflict = indexed.find((entry) => entry.conflict);
67
- if (positionConflict !== void 0)
68
- return { refused: Option.some(`session ${positionConflict.plan.id} has a conflicting legacy position`), skipped };
69
- const header = database.query("INSERT OR IGNORE INTO session_heads (id, owner, origin, created_at, meta, updated_at) VALUES (?, ?, ?, ?, ?, ?)"), insert = database.query("INSERT INTO session_log_events (session_id, seq, turn, kind, at, data) VALUES (?, ?, NULL, ?, ?, ?)");
70
- indexed.forEach(({ plan, fresh }) => {
71
- header.run(plan.id, plan.owner, plan.origin, plan.createdAt, JSON.stringify(plan.meta), plan.createdAt);
72
- if (fresh.length === 0)
73
- return;
74
- const base = database.query("SELECT seq FROM session_heads WHERE id = ?").get(plan.id)?.seq ?? 0;
75
- fresh.forEach((event, index) => insert.run(plan.id, base + index + 1, event.kind, event.at, JSON.stringify(event.data)));
76
- database.query("UPDATE session_heads SET seq = ?, revision = revision + 1, updated_at = MAX(updated_at, ?) WHERE id = ?").run(base + fresh.length, fresh.reduce((latest, event) => Math.max(latest, event.at), 0), plan.id);
77
- });
78
- database.query("INSERT INTO session_log_imports VALUES (?, ?)").run(sourceId, Date.now());
79
- return { refused: Option.none(), skipped };
80
- }).immediate();
@@ -1 +0,0 @@
1
- export {};
@@ -1,20 +0,0 @@
1
- import { Effect, Layer } from "effect";
2
- import { ConversationStore, SessionLog, StoreError } from "@xandreed/core";
3
- /**
4
- * @deprecated Positional conversation API projected over the host's unified SessionLog.
5
- *
6
- * Over this package's SQLite log it lists a workspace in one query and prunes
7
- * in one transaction, and `legacy` imports the host's own older message file
8
- * into it first (orphan rows without a session default to `owner`). Over any
9
- * other log the listing reads each conversation's first message and latest
10
- * title and outcome, pruning needs the host's `prune`, and `legacy` fails.
11
- */
12
- export declare const ConversationStoreProjectionLive: (options?: {
13
- readonly prune?: (beforeEpochMs: number) => Effect.Effect<number, StoreError>;
14
- readonly legacy?: {
15
- readonly paths: ReadonlyArray<string>;
16
- readonly owner: string;
17
- };
18
- }) => Layer.Layer<ConversationStore, StoreError, SessionLog>;
19
- /** @deprecated Convenience host composition: the projection over one SQLite session log, opened once. */
20
- export declare const SqliteConversationStoreLive: (dbPath: string) => Layer.Layer<ConversationStore, StoreError>;
@@ -1,100 +0,0 @@
1
- import { Clock, Effect, Layer, Option, Result, Schema } from "effect";
2
- import { AgentMessage, Checkpoint, ConversationId, ConversationStore, ConversationSummary, HarnessError, RunOutcomeRecord, SessionLog, StoredMessage, StoreError } from "@xandreed/core";
3
- import { compatibilityCommit, compatibilityHistory, compatibilityLast } from "../compatibility.adapter.js";
4
- import { makeSessionLogSqlite, sqliteLogInternals } from "../session-log.adapter.js";
5
- const MESSAGE = "conversation.message", CHECKPOINT = "conversation.checkpoint", TITLE = "conversation.title", OUTCOME = "conversation.outcome", failed = (error) => new StoreError({ message: error instanceof Error ? error.message : String(error) }), decodeMessage = Schema.decodeUnknownResult(Schema.fromJsonString(AgentMessage)), decodeListedOutcome = Schema.decodeUnknownResult(Schema.Struct({
6
- outcome: Schema.Literals(["ok", "partial"]),
7
- reason: Schema.Literals(["completed", "step-cap", "degenerate-loop"])
8
- })), draft = (kind, data) => ({ kind, data, turn: Option.none() }), positioned = (events) => Effect.forEach(events.filter((event) => event.kind === MESSAGE), (event) => {
9
- const decoded = decodeMessage(typeof event.data.content === "string" ? event.data.content : "");
10
- return Result.match(decoded, {
11
- onFailure: (issue) => Effect.logWarning(`conversation ${event.session}: skipping undecodable message at ${event.data.position}: ${String(issue)}`).pipe(Effect.as(Option.none())),
12
- onSuccess: (message) => typeof event.data.position === "number" ? Effect.succeed(Option.some(new StoredMessage({ position: event.data.position, message }))) : Effect.succeed(Option.none())
13
- });
14
- }).pipe(Effect.map((rows) => rows.flatMap(Option.toArray))), latestFold = (events, upTo = Number.MAX_SAFE_INTEGER) => events.filter((event) => event.kind === CHECKPOINT && Number(event.data.messagePosition) <= upTo).reduce((best, event) => Option.match(best, {
15
- onNone: () => Option.some(event),
16
- onSome: (current) => Number(event.data.messagePosition) >= Number(current.data.messagePosition) ? Option.some(event) : best
17
- }), Option.none()), summaryOf = (row) => {
18
- const first = Option.flatMap(row.first, (data) => typeof data.content === "string" ? Result.getSuccess(decodeMessage(data.content)) : Option.none());
19
- return new ConversationSummary({
20
- id: row.id,
21
- createdAt: row.createdAt,
22
- title: Option.flatMap(row.title, (data) => typeof data.title === "string" ? Option.some(data.title) : Option.none()),
23
- firstPrompt: Option.flatMap(first, (message) => message.role === "user" ? Option.some(message.content.slice(0, 120)) : Option.none()),
24
- lastOutcome: Option.flatMap(row.outcome, (data) => Result.getSuccess(decodeListedOutcome({ outcome: data.outcome, reason: data.reason })))
25
- });
26
- };
27
- export const ConversationStoreProjectionLive = (options = {}) => Layer.effect(ConversationStore, Effect.gen(function* () {
28
- const log = yield* SessionLog, sqlite = sqliteLogInternals(log), legacy = Option.fromNullishOr(options.legacy);
29
- if (Option.isSome(legacy))
30
- yield* Option.match(sqlite, {
31
- onNone: () => Effect.fail(new StoreError({ message: "importing an older conversation file requires the SQLite session log" })),
32
- onSome: (internals) => internals.importLegacy(legacy.value.paths, Option.some(legacy.value.owner)).pipe(Effect.mapError(failed))
33
- });
34
- const orEmpty = (read, empty) => read.pipe(Effect.catchTag("HarnessError", (error) => error.code === "session.missing" ? Effect.succeed(empty) : Effect.fail(error)), Effect.mapError(failed)), read = (id) => orEmpty(compatibilityHistory(log, id, [MESSAGE, CHECKPOINT, TITLE, OUTCOME]), []), last = (id, kind, upTo) => orEmpty(compatibilityLast(log, id, [kind], upTo), Option.none()), checkpointOf = (id, events) => Option.match(latestFold(events), {
35
- onNone: () => Effect.succeed(Option.none()),
36
- onSome: (latest) => Schema.decodeUnknownEffect(Checkpoint)({ conversationId: id, ...latest.data }).pipe(Effect.map(Option.some), Effect.mapError(failed))
37
- }), ensure = (id) => log.head(id).pipe(Effect.catchTag("SessionMissing", () => Clock.currentTimeMillis.pipe(Effect.flatMap((createdAt) => log.create({
38
- id,
39
- owner: "conversation-store",
40
- origin: "conversation",
41
- createdAt,
42
- meta: { projection: "conversation", workspace: "conversation-store" },
43
- parent: Option.none()
44
- }).pipe(Effect.catchTag("SessionExists", () => log.head(id)))))), Effect.mapError(failed), Effect.asVoid), commit = (id, plan) => ensure(id).pipe(Effect.andThen(compatibilityCommit(log, id, (head) => plan(head).pipe(Effect.mapError((error) => new HarnessError({ code: "conversation.store", message: error.message }))))), Effect.map((done) => done.result), Effect.mapError(failed)), lastPosition = (id, head) => last(id, MESSAGE, head.seq).pipe(Effect.map(Option.match({ onNone: () => -1, onSome: (event) => Number(event.data.position) }))), appendAll = (id, messages) => messages.length === 0 ? Effect.succeed([]) : commit(id, (head) => lastPosition(id, head).pipe(Effect.map((previous) => {
45
- const positions = messages.map((_, index) => previous + index + 1);
46
- return { drafts: messages.map((message, index) => draft(MESSAGE, { position: positions[index], content: JSON.stringify(message) })), result: positions };
47
- }))), checkpointAt = (id, summary, messagePosition) => commit(id, () => Clock.currentTimeMillis.pipe(Effect.map((createdAt) => ({
48
- drafts: [draft(CHECKPOINT, { summary, messagePosition, createdAt })],
49
- result: void 0
50
- })))), latestOutcome = (id) => last(id, OUTCOME).pipe(Effect.flatMap(Option.match({
51
- onNone: () => Effect.succeed(Option.none()),
52
- onSome: (latest) => Schema.decodeUnknownEffect(RunOutcomeRecord)({ conversationId: id, ...latest.data }).pipe(Effect.map(Option.some), Effect.mapError(failed))
53
- }))), overviews = (owner) => log.list({ owner, limit: Number.MAX_SAFE_INTEGER, before: Option.none(), parent: Option.none() }).pipe(Effect.mapError(failed), Effect.map((heads) => heads.filter((head) => head.header.origin === "conversation").sort((a, b) => b.header.createdAt - a.header.createdAt || (a.header.id < b.header.id ? 1 : -1))), Effect.flatMap((heads) => Effect.forEach(heads, (head) => Effect.gen(function* () {
54
- const first = yield* log.read(head.header.id, { after: 0, limit: Option.some(1), kinds: [MESSAGE] }).pipe(Effect.mapError(failed)), title = yield* last(head.header.id, TITLE), outcome = yield* last(head.header.id, OUTCOME);
55
- return { id: head.header.id, createdAt: head.header.createdAt, first: Option.map(Option.fromNullishOr(first[0]), (event) => event.data), title: Option.map(title, (event) => event.data), outcome: Option.map(outcome, (event) => event.data) };
56
- }))));
57
- return ConversationStore.of({
58
- create: (workspace = "conversation-store") => Clock.currentTimeMillis.pipe(Effect.flatMap((createdAt) => log.create({
59
- id: ConversationId.make(crypto.randomUUID()),
60
- owner: workspace,
61
- origin: "conversation",
62
- createdAt,
63
- meta: { workspace, projection: "conversation" },
64
- parent: Option.none()
65
- })), Effect.map((head) => head.header.id), Effect.mapError(failed)),
66
- append: (id, message) => appendAll(id, [message]).pipe(Effect.map((positions) => positions[0] ?? 0)),
67
- appendAll,
68
- list: (id) => read(id).pipe(Effect.flatMap(positioned), Effect.map((rows) => rows.map((row) => row.message))),
69
- listActive: (id) => read(id).pipe(Effect.flatMap((events) => Effect.all([positioned(events), checkpointOf(id, events)])), Effect.map(([rows, checkpoint]) => rows.filter((row) => row.position > Option.match(checkpoint, { onNone: () => -1, onSome: (fold) => fold.messagePosition })))),
70
- checkpoint: (id, summary) => commit(id, (head) => Effect.gen(function* () {
71
- const messagePosition = yield* lastPosition(id, head);
72
- return { drafts: [draft(CHECKPOINT, { summary, messagePosition, createdAt: yield* Clock.currentTimeMillis })], result: void 0 };
73
- })),
74
- checkpointAt,
75
- latestCheckpoint: (id) => read(id).pipe(Effect.flatMap((events) => checkpointOf(id, events))),
76
- setTitle: (id, title) => commit(id, () => Effect.succeed({ drafts: [draft(TITLE, { title })], result: void 0 })),
77
- recordOutcome: (id, outcome, reason) => commit(id, () => Clock.currentTimeMillis.pipe(Effect.map((at) => ({ drafts: [draft(OUTCOME, { at, outcome, reason })], result: void 0 })))),
78
- latestOutcome,
79
- listByWorkspace: (workspace) => Option.match(sqlite, {
80
- onNone: () => overviews(workspace),
81
- onSome: (internals) => internals.conversations(workspace).pipe(Effect.mapError(failed))
82
- }).pipe(Effect.map((rows) => rows.map(summaryOf))),
83
- fork: (id, upToPosition = Number.MAX_SAFE_INTEGER) => Effect.gen(function* () {
84
- const source = yield* log.head(id).pipe(Effect.mapError((error) => new StoreError({ message: `conversation ${id} not found: ${String(error)}` }))), records = yield* read(id), messages = records.filter((event) => event.kind === MESSAGE && Number(event.data.position) <= upToPosition), checkpoint = latestFold(records, upToPosition), title = records.filter((event) => event.kind === TITLE).at(-1)?.data.title, created = yield* log.create({
85
- id: ConversationId.make(crypto.randomUUID()),
86
- owner: source.header.owner,
87
- origin: "conversation",
88
- createdAt: yield* Clock.currentTimeMillis,
89
- meta: { ...source.header.meta, forkedFrom: id, projection: "conversation" },
90
- parent: Option.none()
91
- }).pipe(Effect.mapError(failed));
92
- yield* commit(created.header.id, () => Effect.succeed({ drafts: [...messages.map((event) => draft(event.kind, event.data)), ...Option.toArray(Option.map(checkpoint, (fold) => draft(fold.kind, fold.data))), ...typeof title === "string" ? [draft(TITLE, { title: `fork: ${title}` })] : []], result: void 0 })).pipe(Effect.onError(() => log.remove(created.header.id).pipe(Effect.ignore)));
93
- return created.header.id;
94
- }).pipe(Effect.uninterruptible),
95
- prune: options.prune ?? ((before) => Option.match(sqlite, {
96
- onNone: () => Effect.fail(new StoreError({ message: "pruning requires the storage host's explicit retention adapter" })),
97
- onSome: (internals) => internals.prune(before).pipe(Effect.mapError(failed))
98
- }))
99
- });
100
- })), SqliteConversationStoreLive = (dbPath) => Layer.unwrap(makeSessionLogSqlite(dbPath).pipe(Effect.mapError(failed), Effect.map((log) => ConversationStoreProjectionLive().pipe(Layer.provide(Layer.succeed(SessionLog, log))))));
@@ -1 +0,0 @@
1
- export {};