@guuey/threads 0.2.2
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 +21 -0
- package/README.md +50 -0
- package/dist/fold-rows.d.ts +60 -0
- package/dist/fold-rows.d.ts.map +1 -0
- package/dist/fold-rows.js +216 -0
- package/dist/in-memory.d.ts +21 -0
- package/dist/in-memory.d.ts.map +1 -0
- package/dist/in-memory.js +49 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/rows.d.ts +95 -0
- package/dist/rows.d.ts.map +1 -0
- package/dist/rows.js +1 -0
- package/dist/store.d.ts +102 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +201 -0
- package/dist/testing/contract-suite.d.ts +11 -0
- package/dist/testing/contract-suite.d.ts.map +1 -0
- package/dist/testing/contract-suite.js +158 -0
- package/package.json +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Loqu, Inc.
|
|
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,50 @@
|
|
|
1
|
+
# @guuey/threads
|
|
2
|
+
|
|
3
|
+
Universal session/thread persistence for AgJSON agents.
|
|
4
|
+
|
|
5
|
+
Hosted guuey agents get thread rehydration ("my agent remembers this
|
|
6
|
+
conversation") from the platform. This package is that same session model as
|
|
7
|
+
a public contract, so ejected and self-hosted agents — and integration
|
|
8
|
+
harnesses — share it:
|
|
9
|
+
|
|
10
|
+
- **`ThreadStore`** — the storage-agnostic logic: thread resolution and
|
|
11
|
+
ownership, atomic gap-free sequencing, `clientMessageId` idempotency,
|
|
12
|
+
turn-level fold persistence (messages + generative-UI card rows + the
|
|
13
|
+
latest-replace snapshot), and the prompt-lane history projection.
|
|
14
|
+
- **`ThreadPersistencePort`** — the narrow surface a binding implements.
|
|
15
|
+
`InMemoryThreadPersistence` ships in the box (dev, tests, CI); guuey's
|
|
16
|
+
hosted runtime binds DynamoDB; implement the port against your own store
|
|
17
|
+
for ejected deployments.
|
|
18
|
+
- **fold ↔ row mapping** — `agMessageToRow`, `reassembleFold`,
|
|
19
|
+
`uiCardArtifactsFromMessages` and friends: byte-identity persistence of an
|
|
20
|
+
`@silverprotocol/core` `AgReduceResult`, including the projection that
|
|
21
|
+
persists MCP-App cards carried on tool-result blocks.
|
|
22
|
+
- **`@guuey/threads/testing`** — the port contract suite. Run it against
|
|
23
|
+
your binding and "works in-memory" and "works on the real thing" mean the
|
|
24
|
+
same set of guarantees.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { InMemoryThreadPersistence, ThreadStore } from "@guuey/threads";
|
|
28
|
+
|
|
29
|
+
const store = new ThreadStore(new InMemoryThreadPersistence());
|
|
30
|
+
const threadId = await store.ensureThread({
|
|
31
|
+
userId: "g_dev",
|
|
32
|
+
appId: "my-agent",
|
|
33
|
+
region: "local",
|
|
34
|
+
});
|
|
35
|
+
await store.appendMessage({
|
|
36
|
+
threadId,
|
|
37
|
+
userId: "g_dev",
|
|
38
|
+
role: "user",
|
|
39
|
+
content: "hello",
|
|
40
|
+
text: "hello",
|
|
41
|
+
clientMessageId: "m1",
|
|
42
|
+
});
|
|
43
|
+
const history = await store.loadHistory(threadId);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// your-binding.test.ts
|
|
48
|
+
import { runThreadPersistenceContractSuite } from "@guuey/threads/testing";
|
|
49
|
+
runThreadPersistenceContractSuite("MyBinding", async () => ({ port: makeMyPort() }));
|
|
50
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure mappers between the AgJSON fold (`AgReduceResult` components) and
|
|
3
|
+
* `ThreadMessage` rows / `ThreadSnapshot`. No AWS, no I/O — the byte-identity
|
|
4
|
+
* round-trip lives here and is unit-tested table-free. See
|
|
5
|
+
* docs/superpowers/specs/2026-06-23-agjson-persistence-fold-design.md §6–§9.
|
|
6
|
+
*/
|
|
7
|
+
import type { AgMessage, AgArtifact, AgTurnRecord, AgReduceResult, AgEvent, AgMemoryRecord, JsonValue } from "@silverprotocol/core";
|
|
8
|
+
import type { ThreadMessageRow, ThreadSnapshotRow } from "./rows.js";
|
|
9
|
+
export interface RowCtx {
|
|
10
|
+
threadId: string;
|
|
11
|
+
userId: string;
|
|
12
|
+
seq: number;
|
|
13
|
+
at: string;
|
|
14
|
+
clientMessageId: string;
|
|
15
|
+
}
|
|
16
|
+
/** Plain-text projection of an AgMessage — concatenated text blocks. */
|
|
17
|
+
export declare function messageText(msg: AgMessage): string;
|
|
18
|
+
export declare function agMessageToRow(msg: AgMessage, ctx: RowCtx & {
|
|
19
|
+
turnRecord?: AgTurnRecord;
|
|
20
|
+
}): ThreadMessageRow;
|
|
21
|
+
export declare function uiCardArtifactsFromMessages(messages: AgMessage[]): AgArtifact[];
|
|
22
|
+
export declare function agArtifactToCardRow(art: AgArtifact, ctx: RowCtx): ThreadMessageRow;
|
|
23
|
+
/**
|
|
24
|
+
* Reconstruct an AgMessage from a row. Agent-fold rows store the verbatim
|
|
25
|
+
* AgMessage in `content`; user/system rows (persisted up-front) store plain
|
|
26
|
+
* `{ kind, text }` — synthesize a single-text-block AgMessage for those so
|
|
27
|
+
* the reassembled transcript is uniform.
|
|
28
|
+
*/
|
|
29
|
+
export declare function rowToAgMessage(row: ThreadMessageRow): AgMessage;
|
|
30
|
+
/** Extract the AgArtifact from a kind='card' row, or undefined if malformed. */
|
|
31
|
+
export declare function cardRowToAgArtifact(row: ThreadMessageRow): AgArtifact | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Reassemble an AgReduceResult from persisted rows + the thread snapshot.
|
|
34
|
+
* Inverse of the per-turn write (design §9). Append components come from
|
|
35
|
+
* rows in seq order; latest-replace components from the snapshot. Byte-
|
|
36
|
+
* identical to reduce() over the agent-folded portion — EXCEPT that
|
|
37
|
+
* `artifacts` additionally carries the UI-card projections
|
|
38
|
+
* ({@link uiCardArtifactsFromMessages}) the live reduce() never produced.
|
|
39
|
+
* Re-persisting a reassembled fold through `appendFold` stays single-write:
|
|
40
|
+
* the projection is deduped by its deterministic `<msgId>#ui#<idx>`
|
|
41
|
+
* artifactIds there.
|
|
42
|
+
*/
|
|
43
|
+
export declare function reassembleFold(rows: ThreadMessageRow[], snapshot: ThreadSnapshotRow | undefined): AgReduceResult;
|
|
44
|
+
/**
|
|
45
|
+
* Synthetic events that seed a reducer with prior latest-replace state before
|
|
46
|
+
* folding a turn (design §8.1). Seeds state + thread-memory ONLY — never
|
|
47
|
+
* messages/artifacts/turns — so result() yields this turn's append-delta while
|
|
48
|
+
* carrying cumulative state/memory. Contiguous seqs 0..K (no internal gap); the
|
|
49
|
+
* live stream that follows uses its own per-message 0-based seqs (backward
|
|
50
|
+
* jumps, which the reducer tolerates). Push these into the reducer but never
|
|
51
|
+
* emit them to the client.
|
|
52
|
+
*
|
|
53
|
+
* Each synthetic `memory.write` carries `turnId` so the reducer's SET handler
|
|
54
|
+
* lands it back onto the AgMemoryRecord — without it, a re-seeded thread-memory
|
|
55
|
+
* record loses its turnId and memory byte-identity breaks from turn 2 on.
|
|
56
|
+
* (`threadId` is NOT carried: `memory.write` has no threadId on its event arm,
|
|
57
|
+
* so it cannot round-trip through a live write — that is expected.)
|
|
58
|
+
*/
|
|
59
|
+
export declare function seedEventsForReducer(priorState: JsonValue | undefined, priorThreadMemory: AgMemoryRecord[]): AgEvent[];
|
|
60
|
+
//# sourceMappingURL=fold-rows.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fold-rows.d.ts","sourceRoot":"","sources":["../src/fold-rows.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EACV,SAAS,EACT,UAAU,EACV,YAAY,EAEZ,cAAc,EACd,OAAO,EACP,cAAc,EACd,SAAS,EACV,MAAM,sBAAsB,CAAC;AAE9B,OAAO,KAAK,EAAE,gBAAgB,EAAwC,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAE3G,MAAM,WAAW,MAAM;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,EAAE,EAAE,MAAM,CAAC;IACX,eAAe,EAAE,MAAM,CAAC;CACzB;AASD,wEAAwE;AACxE,wBAAgB,WAAW,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,CAMlD;AAED,wBAAgB,cAAc,CAC5B,GAAG,EAAE,SAAS,EACd,GAAG,EAAE,MAAM,GAAG;IAAE,UAAU,CAAC,EAAE,YAAY,CAAA;CAAE,GAC1C,gBAAgB,CAelB;AAgDD,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,SAAS,EAAE,GAAG,UAAU,EAAE,CAqB/E;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG,gBAAgB,CAYlF;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,gBAAgB,GAAG,SAAS,CAY/D;AAED,gFAAgF;AAChF,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,gBAAgB,GAAG,UAAU,GAAG,SAAS,CAMjF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,gBAAgB,EAAE,EACxB,QAAQ,EAAE,iBAAiB,GAAG,SAAS,GACtC,cAAc,CA2BhB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,SAAS,GAAG,SAAS,EACjC,iBAAiB,EAAE,cAAc,EAAE,GAClC,OAAO,EAAE,CAmBX"}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { AgMessage as AgMessageSchema } from "@silverprotocol/core";
|
|
2
|
+
/** AgRole → the row's coarse authorRole projection (secondary; role of record lives in content). */
|
|
3
|
+
function roleToAuthor(role) {
|
|
4
|
+
if (role === "user")
|
|
5
|
+
return "user";
|
|
6
|
+
if (role === "system")
|
|
7
|
+
return "system";
|
|
8
|
+
return "agent"; // assistant | tool
|
|
9
|
+
}
|
|
10
|
+
/** Plain-text projection of an AgMessage — concatenated text blocks. */
|
|
11
|
+
export function messageText(msg) {
|
|
12
|
+
let out = "";
|
|
13
|
+
for (const block of msg.content) {
|
|
14
|
+
if (block.type === "text")
|
|
15
|
+
out += block.text;
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
export function agMessageToRow(msg, ctx) {
|
|
20
|
+
const text = messageText(msg);
|
|
21
|
+
const kind = "text";
|
|
22
|
+
return {
|
|
23
|
+
threadId: ctx.threadId,
|
|
24
|
+
seq: ctx.seq,
|
|
25
|
+
userId: ctx.userId,
|
|
26
|
+
clientMessageId: ctx.clientMessageId,
|
|
27
|
+
at: ctx.at,
|
|
28
|
+
kind,
|
|
29
|
+
authorRole: roleToAuthor(msg.role),
|
|
30
|
+
...(text ? { text } : {}),
|
|
31
|
+
content: msg,
|
|
32
|
+
...(ctx.turnRecord ? { aiContext: ctx.turnRecord } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* MCP-App cards ride `tool-result` blocks' UI channels inside AgMessages —
|
|
37
|
+
* the Claude facet emits NO `artifact.*` events, so `fold.artifacts` alone
|
|
38
|
+
* misses every generative-UI card and nothing ever wrote a `kind:'card'`
|
|
39
|
+
* row for them (guuey#86: cards never rehydrated after a reload). Project
|
|
40
|
+
* UI-carrying tool-result blocks into synthetic AgArtifacts so the
|
|
41
|
+
* EXISTING card-row lane persists them; the client's `cardCardMount`
|
|
42
|
+
* mounts `{ parts: [block] }` through its inline arm unchanged. Facets
|
|
43
|
+
* that DO emit artifact events don't stamp `uiData` on tool-results, so
|
|
44
|
+
* the two sources don't double-write for one card.
|
|
45
|
+
*
|
|
46
|
+
* The narrowing MIRRORS `@guuey/agent-client`'s `toolResultUiResource`
|
|
47
|
+
* (@guuey/agent-client's block-ui.ts — keep the two in sync):
|
|
48
|
+
* 1. `uiData` carrying an MCP resource payload (`{uri, text|blob}`,
|
|
49
|
+
* inlined directly or wrapped as `{ resource: {...} }`) — the explicit
|
|
50
|
+
* surface channel, no `ui://` gate on purpose;
|
|
51
|
+
* 2. a `ui://` resource degraded into a `provider-raw` content part —
|
|
52
|
+
* gated on the scheme, because provider-raw is a lossy catch-all.
|
|
53
|
+
*/
|
|
54
|
+
function isJsonObject(v) {
|
|
55
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
56
|
+
}
|
|
57
|
+
function isResourcePayload(v) {
|
|
58
|
+
if (!isJsonObject(v))
|
|
59
|
+
return false;
|
|
60
|
+
if (typeof v.uri !== "string")
|
|
61
|
+
return false;
|
|
62
|
+
return typeof v.text === "string" || typeof v.blob === "string";
|
|
63
|
+
}
|
|
64
|
+
function resourceUri(v) {
|
|
65
|
+
if (!isJsonObject(v))
|
|
66
|
+
return undefined;
|
|
67
|
+
return typeof v.uri === "string" ? v.uri : undefined;
|
|
68
|
+
}
|
|
69
|
+
function uiDataCarriesResource(uiData) {
|
|
70
|
+
if (!isJsonObject(uiData))
|
|
71
|
+
return false;
|
|
72
|
+
return isResourcePayload(uiData) || isResourcePayload(uiData.resource);
|
|
73
|
+
}
|
|
74
|
+
function providerRawCarriesUiResource(raw) {
|
|
75
|
+
if (!isJsonObject(raw))
|
|
76
|
+
return false;
|
|
77
|
+
const candidate = raw.resource !== undefined ? raw.resource : raw;
|
|
78
|
+
if (!isResourcePayload(candidate))
|
|
79
|
+
return false;
|
|
80
|
+
return resourceUri(candidate)?.startsWith("ui://") === true;
|
|
81
|
+
}
|
|
82
|
+
export function uiCardArtifactsFromMessages(messages) {
|
|
83
|
+
const artifacts = [];
|
|
84
|
+
for (const msg of messages) {
|
|
85
|
+
for (let i = 0; i < msg.content.length; i++) {
|
|
86
|
+
const block = msg.content[i];
|
|
87
|
+
if (block.type !== "tool-result")
|
|
88
|
+
continue;
|
|
89
|
+
const carriesUi = uiDataCarriesResource(block.uiData) ||
|
|
90
|
+
block.content.some((part) => part.type === "provider-raw" && providerRawCarriesUiResource(part.raw));
|
|
91
|
+
if (!carriesUi)
|
|
92
|
+
continue;
|
|
93
|
+
artifacts.push({
|
|
94
|
+
artifactId: `${msg.id}#ui#${i}`,
|
|
95
|
+
turnId: msg.turnId ?? "",
|
|
96
|
+
threadId: msg.threadId ?? "",
|
|
97
|
+
parts: [block],
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return artifacts;
|
|
102
|
+
}
|
|
103
|
+
export function agArtifactToCardRow(art, ctx) {
|
|
104
|
+
return {
|
|
105
|
+
threadId: ctx.threadId,
|
|
106
|
+
seq: ctx.seq,
|
|
107
|
+
userId: ctx.userId,
|
|
108
|
+
clientMessageId: ctx.clientMessageId,
|
|
109
|
+
at: ctx.at,
|
|
110
|
+
kind: "card",
|
|
111
|
+
authorRole: "agent",
|
|
112
|
+
content: { producedInTurnId: art.turnId },
|
|
113
|
+
cardSnapshot: art,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Reconstruct an AgMessage from a row. Agent-fold rows store the verbatim
|
|
118
|
+
* AgMessage in `content`; user/system rows (persisted up-front) store plain
|
|
119
|
+
* `{ kind, text }` — synthesize a single-text-block AgMessage for those so
|
|
120
|
+
* the reassembled transcript is uniform.
|
|
121
|
+
*/
|
|
122
|
+
export function rowToAgMessage(row) {
|
|
123
|
+
const parsed = AgMessageSchema.safeParse(row.content);
|
|
124
|
+
if (parsed.success)
|
|
125
|
+
return parsed.data;
|
|
126
|
+
const role = row.authorRole === "user" ? "user" : row.authorRole === "system" ? "system" : "assistant";
|
|
127
|
+
const text = row.text ?? "";
|
|
128
|
+
return {
|
|
129
|
+
id: `${row.threadId}#${row.seq}`,
|
|
130
|
+
role,
|
|
131
|
+
content: text ? [{ type: "text", text }] : [],
|
|
132
|
+
threadId: row.threadId,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** Extract the AgArtifact from a kind='card' row, or undefined if malformed. */
|
|
136
|
+
export function cardRowToAgArtifact(row) {
|
|
137
|
+
const snap = row.cardSnapshot;
|
|
138
|
+
if (snap && typeof snap === "object" && "artifactId" in snap) {
|
|
139
|
+
return snap;
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Reassemble an AgReduceResult from persisted rows + the thread snapshot.
|
|
145
|
+
* Inverse of the per-turn write (design §9). Append components come from
|
|
146
|
+
* rows in seq order; latest-replace components from the snapshot. Byte-
|
|
147
|
+
* identical to reduce() over the agent-folded portion — EXCEPT that
|
|
148
|
+
* `artifacts` additionally carries the UI-card projections
|
|
149
|
+
* ({@link uiCardArtifactsFromMessages}) the live reduce() never produced.
|
|
150
|
+
* Re-persisting a reassembled fold through `appendFold` stays single-write:
|
|
151
|
+
* the projection is deduped by its deterministic `<msgId>#ui#<idx>`
|
|
152
|
+
* artifactIds there.
|
|
153
|
+
*/
|
|
154
|
+
export function reassembleFold(rows, snapshot) {
|
|
155
|
+
const ordered = rows.slice().sort((a, b) => a.seq - b.seq);
|
|
156
|
+
const messages = [];
|
|
157
|
+
const artifacts = [];
|
|
158
|
+
const turnsById = new Map();
|
|
159
|
+
for (const row of ordered) {
|
|
160
|
+
if (row.kind === "card") {
|
|
161
|
+
const art = cardRowToAgArtifact(row);
|
|
162
|
+
if (art)
|
|
163
|
+
artifacts.push(art);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
messages.push(rowToAgMessage(row));
|
|
167
|
+
const tr = row.aiContext;
|
|
168
|
+
if (tr && typeof tr === "object" && "turnId" in tr) {
|
|
169
|
+
const rec = tr;
|
|
170
|
+
if (!turnsById.has(rec.turnId))
|
|
171
|
+
turnsById.set(rec.turnId, rec);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
messages,
|
|
176
|
+
artifacts,
|
|
177
|
+
memory: snapshot?.threadMemory ?? [],
|
|
178
|
+
turns: [...turnsById.values()],
|
|
179
|
+
...(snapshot?.workingState !== undefined ? { state: snapshot.workingState } : {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Synthetic events that seed a reducer with prior latest-replace state before
|
|
184
|
+
* folding a turn (design §8.1). Seeds state + thread-memory ONLY — never
|
|
185
|
+
* messages/artifacts/turns — so result() yields this turn's append-delta while
|
|
186
|
+
* carrying cumulative state/memory. Contiguous seqs 0..K (no internal gap); the
|
|
187
|
+
* live stream that follows uses its own per-message 0-based seqs (backward
|
|
188
|
+
* jumps, which the reducer tolerates). Push these into the reducer but never
|
|
189
|
+
* emit them to the client.
|
|
190
|
+
*
|
|
191
|
+
* Each synthetic `memory.write` carries `turnId` so the reducer's SET handler
|
|
192
|
+
* lands it back onto the AgMemoryRecord — without it, a re-seeded thread-memory
|
|
193
|
+
* record loses its turnId and memory byte-identity breaks from turn 2 on.
|
|
194
|
+
* (`threadId` is NOT carried: `memory.write` has no threadId on its event arm,
|
|
195
|
+
* so it cannot round-trip through a live write — that is expected.)
|
|
196
|
+
*/
|
|
197
|
+
export function seedEventsForReducer(priorState, priorThreadMemory) {
|
|
198
|
+
const events = [];
|
|
199
|
+
let seq = 0;
|
|
200
|
+
if (priorState !== undefined) {
|
|
201
|
+
events.push({ seq: seq++, type: "state.snapshot", snapshot: priorState });
|
|
202
|
+
}
|
|
203
|
+
for (const rec of priorThreadMemory) {
|
|
204
|
+
events.push({
|
|
205
|
+
seq: seq++,
|
|
206
|
+
type: "memory.write",
|
|
207
|
+
scope: "thread",
|
|
208
|
+
...(rec.key !== undefined ? { key: rec.key } : {}),
|
|
209
|
+
value: rec.value,
|
|
210
|
+
...(rec.reason !== undefined ? { reason: rec.reason } : {}),
|
|
211
|
+
...(rec.durable !== undefined ? { durable: rec.durable } : {}),
|
|
212
|
+
...(rec.turnId !== undefined ? { turnId: rec.turnId } : {}),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return events;
|
|
216
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InMemoryThreadPersistence — the dev/test binding of
|
|
3
|
+
* {@link ThreadPersistencePort}. Same contract as any real binding (run
|
|
4
|
+
* the suite in `@guuey/threads/testing` to prove yours), including the
|
|
5
|
+
* two conditional-write guards and the null-preview no-touch semantics.
|
|
6
|
+
*/
|
|
7
|
+
import type { ThreadMessageRow, ThreadPersistencePort, ThreadRow, ThreadSnapshotRow } from "./rows.js";
|
|
8
|
+
export declare class InMemoryThreadPersistence implements ThreadPersistencePort {
|
|
9
|
+
readonly threads: Map<string, ThreadRow>;
|
|
10
|
+
readonly messages: ThreadMessageRow[];
|
|
11
|
+
readonly snapshots: Map<string, ThreadSnapshotRow>;
|
|
12
|
+
getThread(threadId: string): Promise<ThreadRow | undefined>;
|
|
13
|
+
createThread(row: ThreadRow): Promise<void>;
|
|
14
|
+
incrementSeq(threadId: string, preview: string | null, atIso: string): Promise<number>;
|
|
15
|
+
putMessage(row: ThreadMessageRow): Promise<void>;
|
|
16
|
+
listRecentMessages(threadId: string, limit: number): Promise<ThreadMessageRow[]>;
|
|
17
|
+
findByClientMessageId(threadId: string, clientMessageId: string): Promise<ThreadMessageRow | undefined>;
|
|
18
|
+
getSnapshot(threadId: string): Promise<ThreadSnapshotRow | undefined>;
|
|
19
|
+
putSnapshot(row: ThreadSnapshotRow): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=in-memory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"in-memory.d.ts","sourceRoot":"","sources":["../src/in-memory.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EACV,gBAAgB,EAChB,qBAAqB,EACrB,SAAS,EACT,iBAAiB,EAClB,MAAM,WAAW,CAAC;AAEnB,qBAAa,yBAA0B,YAAW,qBAAqB;IACrE,QAAQ,CAAC,OAAO,yBAAgC;IAChD,QAAQ,CAAC,QAAQ,EAAE,gBAAgB,EAAE,CAAM;IAC3C,QAAQ,CAAC,SAAS,iCAAwC;IAEpD,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAI3D,YAAY,CAAC,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAO3C,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAYtF,UAAU,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IAOhD,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAQhF,qBAAqB,CACzB,QAAQ,EAAE,MAAM,EAChB,eAAe,EAAE,MAAM,GACtB,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC;IAMlC,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC;IAIrE,WAAW,CAAC,GAAG,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;CAGzD"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export class InMemoryThreadPersistence {
|
|
2
|
+
threads = new Map();
|
|
3
|
+
messages = [];
|
|
4
|
+
snapshots = new Map();
|
|
5
|
+
async getThread(threadId) {
|
|
6
|
+
return this.threads.get(threadId);
|
|
7
|
+
}
|
|
8
|
+
async createThread(row) {
|
|
9
|
+
if (this.threads.has(row.id)) {
|
|
10
|
+
throw new Error(`createThread: thread ${row.id} already exists`);
|
|
11
|
+
}
|
|
12
|
+
this.threads.set(row.id, { ...row });
|
|
13
|
+
}
|
|
14
|
+
async incrementSeq(threadId, preview, atIso) {
|
|
15
|
+
const t = this.threads.get(threadId);
|
|
16
|
+
if (!t)
|
|
17
|
+
throw new Error(`incrementSeq: thread ${threadId} does not exist`);
|
|
18
|
+
t.lastSeq += 1;
|
|
19
|
+
t.lastMessageAt = atIso;
|
|
20
|
+
t.updatedAt = atIso;
|
|
21
|
+
// null = allocate the seq WITHOUT touching the preview (card rows,
|
|
22
|
+
// text-less agent turns) — mirror of the hosted binding's branch.
|
|
23
|
+
if (preview !== null)
|
|
24
|
+
t.lastMessagePreview = preview;
|
|
25
|
+
return t.lastSeq;
|
|
26
|
+
}
|
|
27
|
+
async putMessage(row) {
|
|
28
|
+
if (this.messages.some((m) => m.threadId === row.threadId && m.seq === row.seq)) {
|
|
29
|
+
throw new Error(`putMessage: seq ${row.seq} already exists on ${row.threadId}`);
|
|
30
|
+
}
|
|
31
|
+
this.messages.push({ ...row });
|
|
32
|
+
}
|
|
33
|
+
async listRecentMessages(threadId, limit) {
|
|
34
|
+
return this.messages
|
|
35
|
+
.filter((m) => m.threadId === threadId)
|
|
36
|
+
.sort((a, b) => b.seq - a.seq)
|
|
37
|
+
.slice(0, limit)
|
|
38
|
+
.reverse();
|
|
39
|
+
}
|
|
40
|
+
async findByClientMessageId(threadId, clientMessageId) {
|
|
41
|
+
return this.messages.find((m) => m.threadId === threadId && m.clientMessageId === clientMessageId);
|
|
42
|
+
}
|
|
43
|
+
async getSnapshot(threadId) {
|
|
44
|
+
return this.snapshots.get(threadId);
|
|
45
|
+
}
|
|
46
|
+
async putSnapshot(row) {
|
|
47
|
+
this.snapshots.set(row.threadId, { ...row });
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @guuey/threads — universal session/thread persistence for AgJSON agents
|
|
3
|
+
* (guuey#107).
|
|
4
|
+
*
|
|
5
|
+
* The pieces:
|
|
6
|
+
* - {@link ThreadStore} — the storage-agnostic logic: thread resolution,
|
|
7
|
+
* atomic sequencing, idempotency, turn-level fold persistence
|
|
8
|
+
* (messages + card rows + snapshot), prompt-lane history.
|
|
9
|
+
* - {@link ThreadPersistencePort} — the narrow surface a binding
|
|
10
|
+
* implements. `InMemoryThreadPersistence` ships here; guuey's hosted
|
|
11
|
+
* runtime binds DynamoDB; bring your own store for ejected agents.
|
|
12
|
+
* - fold↔row mapping — `agMessageToRow`/`reassembleFold`/friends, the
|
|
13
|
+
* byte-identity persistence of an `AgReduceResult`, including the
|
|
14
|
+
* UI-card projection (`uiCardArtifactsFromMessages`, guuey#86).
|
|
15
|
+
* - `@guuey/threads/testing` — the port contract suite: run it against
|
|
16
|
+
* your binding for the same guarantees the hosted one carries.
|
|
17
|
+
*/
|
|
18
|
+
export type { StoredHistoryMessage, ThreadMessageKind, ThreadMessageRole, ThreadMessageRow, ThreadPersistencePort, ThreadRow, ThreadSnapshotRow, } from "./rows.js";
|
|
19
|
+
export { ThreadStore, type AppendFoldInput, type AppendFoldResult, type AppendMessageInput, type AppendMessageResult, type EnsureThreadInput, } from "./store.js";
|
|
20
|
+
export { InMemoryThreadPersistence } from "./in-memory.js";
|
|
21
|
+
export { agArtifactToCardRow, agMessageToRow, cardRowToAgArtifact, messageText, reassembleFold, rowToAgMessage, seedEventsForReducer, uiCardArtifactsFromMessages, type RowCtx, } from "./fold-rows.js";
|
|
22
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,YAAY,EACV,oBAAoB,EACpB,iBAAiB,EACjB,iBAAiB,EACjB,gBAAgB,EAChB,qBAAqB,EACrB,SAAS,EACT,iBAAiB,GAClB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,WAAW,EACX,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,GACvB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,yBAAyB,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EACL,mBAAmB,EACnB,cAAc,EACd,mBAAmB,EACnB,WAAW,EACX,cAAc,EACd,cAAc,EACd,oBAAoB,EACpB,2BAA2B,EAC3B,KAAK,MAAM,GACZ,MAAM,gBAAgB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { ThreadStore, } from "./store.js";
|
|
2
|
+
export { InMemoryThreadPersistence } from "./in-memory.js";
|
|
3
|
+
export { agArtifactToCardRow, agMessageToRow, cardRowToAgArtifact, messageText, reassembleFold, rowToAgMessage, seedEventsForReducer, uiCardArtifactsFromMessages, } from "./fold-rows.js";
|
package/dist/rows.d.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row shapes + the persistence port — the storage-agnostic half of the
|
|
3
|
+
* thread contract (guuey#107, extracted from the hosted runtime's
|
|
4
|
+
* thread-store so ejected/self-hosted agents share ONE session model).
|
|
5
|
+
*
|
|
6
|
+
* A binding implements {@link ThreadPersistencePort} against its store
|
|
7
|
+
* (guuey's hosted runtime binds DynamoDB; `InMemoryThreadPersistence`
|
|
8
|
+
* ships here for dev/tests) and runs the exported contract suite
|
|
9
|
+
* (`@guuey/threads/testing`) so "works in-memory" and "works on the real
|
|
10
|
+
* thing" are the same mechanical guarantee — the `@guuey/state` pattern.
|
|
11
|
+
*/
|
|
12
|
+
import type { AgMemoryRecord, JsonValue } from "@silverprotocol/core";
|
|
13
|
+
export type ThreadMessageRole = "user" | "agent" | "system";
|
|
14
|
+
export type ThreadMessageKind = "text" | "card" | "event";
|
|
15
|
+
export interface ThreadRow {
|
|
16
|
+
id: string;
|
|
17
|
+
userId: string;
|
|
18
|
+
appId: string;
|
|
19
|
+
servingRegion: string;
|
|
20
|
+
title: string;
|
|
21
|
+
status: string;
|
|
22
|
+
pinned: boolean;
|
|
23
|
+
/** Monotonic seq counter — bumped atomically on each append. */
|
|
24
|
+
lastSeq: number;
|
|
25
|
+
lastMessageAt: string;
|
|
26
|
+
lastMessagePreview: string;
|
|
27
|
+
threadMode: string;
|
|
28
|
+
createdAt: string;
|
|
29
|
+
updatedAt: string;
|
|
30
|
+
}
|
|
31
|
+
export interface ThreadMessageRow {
|
|
32
|
+
threadId: string;
|
|
33
|
+
seq: number;
|
|
34
|
+
userId: string;
|
|
35
|
+
clientMessageId: string;
|
|
36
|
+
at: string;
|
|
37
|
+
kind: ThreadMessageKind;
|
|
38
|
+
authorRole: ThreadMessageRole;
|
|
39
|
+
text?: string;
|
|
40
|
+
content?: unknown;
|
|
41
|
+
/** Verbatim `AgArtifact` stored on kind='card' rows. */
|
|
42
|
+
cardSnapshot?: unknown;
|
|
43
|
+
/** `AgTurnRecord` stored on agent-fold rows for context recovery. */
|
|
44
|
+
aiContext?: unknown;
|
|
45
|
+
}
|
|
46
|
+
/** Latest-replace fold snapshot for a thread. */
|
|
47
|
+
export interface ThreadSnapshotRow {
|
|
48
|
+
threadId: string;
|
|
49
|
+
userId: string;
|
|
50
|
+
/** AgReduceResult.state — opaque working blob. */
|
|
51
|
+
workingState?: JsonValue;
|
|
52
|
+
/** AgReduceResult.memory filtered to scope='thread'. */
|
|
53
|
+
threadMemory: AgMemoryRecord[];
|
|
54
|
+
lastTurnId?: string;
|
|
55
|
+
updatedAt: string;
|
|
56
|
+
}
|
|
57
|
+
/** A prior message loaded for context injection — narrow projection. */
|
|
58
|
+
export interface StoredHistoryMessage {
|
|
59
|
+
seq: number;
|
|
60
|
+
authorRole: ThreadMessageRole;
|
|
61
|
+
kind: ThreadMessageKind;
|
|
62
|
+
text: string | null;
|
|
63
|
+
content: unknown;
|
|
64
|
+
at: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The only surface {@link ThreadStore} needs from a backing store. Keep
|
|
68
|
+
* implementations honest with the contract suite in
|
|
69
|
+
* `@guuey/threads/testing`.
|
|
70
|
+
*/
|
|
71
|
+
export interface ThreadPersistencePort {
|
|
72
|
+
/** Point-read a Thread by id; `undefined` when the row does not exist. */
|
|
73
|
+
getThread(threadId: string): Promise<ThreadRow | undefined>;
|
|
74
|
+
/** Put a new Thread row (conditional create — must reject an existing id). */
|
|
75
|
+
createThread(row: ThreadRow): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Atomically bump `lastSeq` (+ set `lastMessageAt`/`updatedAt`) on an
|
|
78
|
+
* EXISTING Thread and return the NEW `lastSeq`. `preview: null`
|
|
79
|
+
* allocates the seq WITHOUT touching `lastMessagePreview` (card rows and
|
|
80
|
+
* text-less agent turns must not clobber the last real preview);
|
|
81
|
+
* a string preview replaces it.
|
|
82
|
+
*/
|
|
83
|
+
incrementSeq(threadId: string, preview: string | null, atIso: string): Promise<number>;
|
|
84
|
+
/** Put a ThreadMessage row (conditional — must reject a duplicate seq). */
|
|
85
|
+
putMessage(row: ThreadMessageRow): Promise<void>;
|
|
86
|
+
/** Up to `limit` most-recent messages for a thread, returned seq-ASCending. */
|
|
87
|
+
listRecentMessages(threadId: string, limit: number): Promise<ThreadMessageRow[]>;
|
|
88
|
+
/** Existing message by (clientMessageId, threadId) for idempotency dedup. */
|
|
89
|
+
findByClientMessageId(threadId: string, clientMessageId: string): Promise<ThreadMessageRow | undefined>;
|
|
90
|
+
/** Point-read the thread's snapshot; undefined when none yet. */
|
|
91
|
+
getSnapshot(threadId: string): Promise<ThreadSnapshotRow | undefined>;
|
|
92
|
+
/** Upsert (full replace) the thread's snapshot row. */
|
|
93
|
+
putSnapshot(row: ThreadSnapshotRow): Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=rows.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rows.d.ts","sourceRoot":"","sources":["../src/rows.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEtE,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;AAC5D,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAE1D,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,OAAO,CAAC;IAChB,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,iBAAiB,CAAC;IACxB,UAAU,EAAE,iBAAiB,CAAC;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,wDAAwD;IACxD,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,qEAAqE;IACrE,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,iDAAiD;AACjD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,kDAAkD;IAClD,YAAY,CAAC,EAAE,SAAS,CAAC;IACzB,wDAAwD;IACxD,YAAY,EAAE,cAAc,EAAE,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wEAAwE;AACxE,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,iBAAiB,CAAC;IAC9B,IAAI,EAAE,iBAAiB,CAAC;IACxB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;;;;GAIG;AACH,MAAM,WAAW,qBAAqB;IACpC,0EAA0E;IAC1E,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAAC;IAC5D,8EAA8E;IAC9E,YAAY,CAAC,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C;;;;;;OAMG;IACH,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACvF,2EAA2E;IAC3E,UAAU,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjD,+EAA+E;IAC/E,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAC;IACjF,6EAA6E;IAC7E,qBAAqB,CACnB,QAAQ,EAAE,MAAM,EAChB,eAAe,EAAE,MAAM,GACtB,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC,CAAC;IACzC,iEAAiE;IACjE,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAAC;IACtE,uDAAuD;IACvD,WAAW,CAAC,GAAG,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpD"}
|
package/dist/rows.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { AgReduceResult } from "@silverprotocol/core";
|
|
2
|
+
import type { StoredHistoryMessage, ThreadMessageKind, ThreadMessageRole, ThreadPersistencePort, ThreadSnapshotRow } from "./rows.js";
|
|
3
|
+
export interface EnsureThreadInput {
|
|
4
|
+
/** Client-supplied thread id (localStorage). Absent on first contact. */
|
|
5
|
+
threadId?: string;
|
|
6
|
+
/** Resolved end-user id (`g_<hash>`). */
|
|
7
|
+
userId: string;
|
|
8
|
+
appId: string;
|
|
9
|
+
/** Region pin for a freshly-created Thread (the pod's `AWS_REGION`). */
|
|
10
|
+
region: string;
|
|
11
|
+
}
|
|
12
|
+
export interface AppendMessageInput {
|
|
13
|
+
threadId: string;
|
|
14
|
+
userId: string;
|
|
15
|
+
role: ThreadMessageRole;
|
|
16
|
+
/** Arbitrary JSON persisted on the row (string for plain text turns). */
|
|
17
|
+
content: unknown;
|
|
18
|
+
/** Plain-text projection for the preview + transcript render. */
|
|
19
|
+
text?: string;
|
|
20
|
+
/** Idempotency key — a retried invoke with the same key won't double-write. */
|
|
21
|
+
clientMessageId: string;
|
|
22
|
+
kind?: ThreadMessageKind;
|
|
23
|
+
}
|
|
24
|
+
export interface AppendMessageResult {
|
|
25
|
+
seq: number;
|
|
26
|
+
/** True when an existing row matched `clientMessageId` (no new write). */
|
|
27
|
+
deduped: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface AppendFoldInput {
|
|
30
|
+
threadId: string;
|
|
31
|
+
userId: string;
|
|
32
|
+
fold: AgReduceResult;
|
|
33
|
+
/** Base for the turn-level idempotency sentinel + per-row derived keys. */
|
|
34
|
+
clientMessageIdBase: string;
|
|
35
|
+
/**
|
|
36
|
+
* Suppress the `putSnapshot` upsert (rows still write). Set by the caller
|
|
37
|
+
* when the prior snapshot read failed OR the reducer parked
|
|
38
|
+
* (`needsResync`), so a degraded/partial fold never clobbers a good
|
|
39
|
+
* snapshot. Default: write the snapshot.
|
|
40
|
+
*/
|
|
41
|
+
skipSnapshot?: boolean;
|
|
42
|
+
}
|
|
43
|
+
export interface AppendFoldResult {
|
|
44
|
+
messageSeqs: number[];
|
|
45
|
+
artifactSeqs: number[];
|
|
46
|
+
/** Count of non-thread (durable) memory records dropped in v1. */
|
|
47
|
+
droppedDurableMemory: number;
|
|
48
|
+
/**
|
|
49
|
+
* True when the turn-level sentinel matched a committed prior turn and the
|
|
50
|
+
* WHOLE fold-persist was short-circuited (no rows written this call).
|
|
51
|
+
*/
|
|
52
|
+
deduped: boolean;
|
|
53
|
+
}
|
|
54
|
+
export declare class ThreadStore {
|
|
55
|
+
private readonly db;
|
|
56
|
+
constructor(db: ThreadPersistencePort);
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the thread for this invoke:
|
|
59
|
+
* - `threadId` found AND owned by `userId` → return it (the happy path);
|
|
60
|
+
* - otherwise (no `threadId`, stale/unknown id, OR owned by a different
|
|
61
|
+
* identity) → mint a fresh Thread with a server-assigned id and return it.
|
|
62
|
+
*
|
|
63
|
+
* Minting-fresh on an owner mismatch (rather than erroring) is deliberate:
|
|
64
|
+
* the caller's anon `guuey_guest` cookie can rotate (cleared, or a cross-site
|
|
65
|
+
* request where it isn't sent), which would otherwise orphan a stored
|
|
66
|
+
* `threadId` behind a permanent 403. Minting fresh is both resilient (chat
|
|
67
|
+
* continues on a new conversation) AND safe — the caller never sees another
|
|
68
|
+
* user's thread, and there's no existence oracle. The pod never honours a
|
|
69
|
+
* client-chosen id, so squatting is impossible.
|
|
70
|
+
*/
|
|
71
|
+
ensureThread(input: EnsureThreadInput): Promise<string>;
|
|
72
|
+
/** Prior messages for the thread (seq-ASC, capped to the most recent N). */
|
|
73
|
+
loadHistory(threadId: string, limit?: number): Promise<StoredHistoryMessage[]>;
|
|
74
|
+
/**
|
|
75
|
+
* Append one message with an atomic, gap-free seq. Idempotent on
|
|
76
|
+
* `clientMessageId`: a prior row with the same key returns its seq without
|
|
77
|
+
* a second write. Mirrors `ops/append-message.ts` steps 2–4.
|
|
78
|
+
*/
|
|
79
|
+
appendMessage(input: AppendMessageInput): Promise<AppendMessageResult>;
|
|
80
|
+
/** The thread's latest fold snapshot, or undefined when none persisted yet. */
|
|
81
|
+
getSnapshot(threadId: string): Promise<ThreadSnapshotRow | undefined>;
|
|
82
|
+
/**
|
|
83
|
+
* Persist a turn's fold delta: one row per message, one card row per
|
|
84
|
+
* artifact (each via an atomic gap-free seq), turn records inlined on the
|
|
85
|
+
* message rows, and an upserted snapshot for working state + thread-memory.
|
|
86
|
+
*
|
|
87
|
+
* Idempotency is TURN-LEVEL, not per-row. A retried invoke re-runs the LLM
|
|
88
|
+
* and can yield a *different* fold (different message count / content);
|
|
89
|
+
* per-index dedup would keep some prior rows and append new ones for the
|
|
90
|
+
* rest → a Frankenstein/orphan thread + clobbered snapshot. Instead, the
|
|
91
|
+
* FIRST row written (the first message, or — if there are no messages — the
|
|
92
|
+
* first card) claims a single `${base}#agentTurn` sentinel key. On any later
|
|
93
|
+
* call with the same base, the sentinel matches and the WHOLE persist is
|
|
94
|
+
* short-circuited (a committed agent turn is never re-merged).
|
|
95
|
+
*
|
|
96
|
+
* Durable (non-thread) memory is logged-and-dropped in v1. `skipSnapshot`
|
|
97
|
+
* suppresses the snapshot upsert so a degraded/parked fold never overwrites
|
|
98
|
+
* a good snapshot.
|
|
99
|
+
*/
|
|
100
|
+
appendFold(input: AppendFoldInput): Promise<AppendFoldResult>;
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,OAAO,KAAK,EACV,oBAAoB,EACpB,iBAAiB,EACjB,iBAAiB,EAEjB,qBAAqB,EAErB,iBAAiB,EAClB,MAAM,WAAW,CAAC;AAOnB,MAAM,WAAW,iBAAiB;IAChC,yEAAyE;IACzE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yCAAyC;IACzC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,iBAAiB,CAAC;IACxB,yEAAyE;IACzE,OAAO,EAAE,OAAO,CAAC;IACjB,iEAAiE;IACjE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,eAAe,EAAE,MAAM,CAAC;IACxB,IAAI,CAAC,EAAE,iBAAiB,CAAC;CAC1B;AAED,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,MAAM,CAAC;IACZ,0EAA0E;IAC1E,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,cAAc,CAAC;IACrB,2EAA2E;IAC3E,mBAAmB,EAAE,MAAM,CAAC;IAC5B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,kEAAkE;IAClE,oBAAoB,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,OAAO,EAAE,OAAO,CAAC;CAClB;AAMD,qBAAa,WAAW;IACV,OAAO,CAAC,QAAQ,CAAC,EAAE;gBAAF,EAAE,EAAE,qBAAqB;IAEtD;;;;;;;;;;;;;OAaG;IACG,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC;IA4B7D,4EAA4E;IACtE,WAAW,CACf,QAAQ,EAAE,MAAM,EAChB,KAAK,GAAE,MAA8B,GACpC,OAAO,CAAC,oBAAoB,EAAE,CAAC;IAqBlC;;;;OAIG;IACG,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,mBAAmB,CAAC;IA4B5E,+EAA+E;IACzE,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC;IAI3E;;;;;;;;;;;;;;;;;OAiBG;IACG,UAAU,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC;CA8EpE"}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThreadStore — the storage-agnostic session logic: thread resolution +
|
|
3
|
+
* ownership, atomic gap-free sequencing, clientMessageId idempotency,
|
|
4
|
+
* turn-level fold persistence (messages + card rows + snapshot), and the
|
|
5
|
+
* prompt-lane history projection. Depends only on
|
|
6
|
+
* {@link ThreadPersistencePort}; bindings supply the storage
|
|
7
|
+
* (`InMemoryThreadPersistence` here; guuey's hosted runtime binds
|
|
8
|
+
* DynamoDB).
|
|
9
|
+
*/
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { agMessageToRow, agArtifactToCardRow, uiCardArtifactsFromMessages } from "./fold-rows.js";
|
|
12
|
+
/** Max history messages fed back as context — bounds latency on long threads. */
|
|
13
|
+
const DEFAULT_HISTORY_LIMIT = 40;
|
|
14
|
+
const PREVIEW_MAX_LEN = 240;
|
|
15
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
16
|
+
// ThreadStore — the logic.
|
|
17
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
18
|
+
export class ThreadStore {
|
|
19
|
+
db;
|
|
20
|
+
constructor(db) {
|
|
21
|
+
this.db = db;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the thread for this invoke:
|
|
25
|
+
* - `threadId` found AND owned by `userId` → return it (the happy path);
|
|
26
|
+
* - otherwise (no `threadId`, stale/unknown id, OR owned by a different
|
|
27
|
+
* identity) → mint a fresh Thread with a server-assigned id and return it.
|
|
28
|
+
*
|
|
29
|
+
* Minting-fresh on an owner mismatch (rather than erroring) is deliberate:
|
|
30
|
+
* the caller's anon `guuey_guest` cookie can rotate (cleared, or a cross-site
|
|
31
|
+
* request where it isn't sent), which would otherwise orphan a stored
|
|
32
|
+
* `threadId` behind a permanent 403. Minting fresh is both resilient (chat
|
|
33
|
+
* continues on a new conversation) AND safe — the caller never sees another
|
|
34
|
+
* user's thread, and there's no existence oracle. The pod never honours a
|
|
35
|
+
* client-chosen id, so squatting is impossible.
|
|
36
|
+
*/
|
|
37
|
+
async ensureThread(input) {
|
|
38
|
+
if (input.threadId) {
|
|
39
|
+
const existing = await this.db.getThread(input.threadId);
|
|
40
|
+
if (existing && existing.userId === input.userId) {
|
|
41
|
+
return existing.id;
|
|
42
|
+
}
|
|
43
|
+
// Not found OR owned by a different identity → fall through, mint fresh.
|
|
44
|
+
}
|
|
45
|
+
const now = new Date().toISOString();
|
|
46
|
+
const row = {
|
|
47
|
+
id: randomUUID(),
|
|
48
|
+
userId: input.userId,
|
|
49
|
+
appId: input.appId,
|
|
50
|
+
servingRegion: input.region,
|
|
51
|
+
title: 'New thread',
|
|
52
|
+
status: 'active',
|
|
53
|
+
pinned: false,
|
|
54
|
+
lastSeq: 0,
|
|
55
|
+
lastMessageAt: now,
|
|
56
|
+
lastMessagePreview: '',
|
|
57
|
+
threadMode: 'single',
|
|
58
|
+
createdAt: now,
|
|
59
|
+
updatedAt: now,
|
|
60
|
+
};
|
|
61
|
+
await this.db.createThread(row);
|
|
62
|
+
return row.id;
|
|
63
|
+
}
|
|
64
|
+
/** Prior messages for the thread (seq-ASC, capped to the most recent N). */
|
|
65
|
+
async loadHistory(threadId, limit = DEFAULT_HISTORY_LIMIT) {
|
|
66
|
+
const rows = await this.db.listRecentMessages(threadId, limit);
|
|
67
|
+
// This lane feeds the LLM prompt (sse-server → priorMessages →
|
|
68
|
+
// worker <conversation_history>, which serializes {role, text} ONLY —
|
|
69
|
+
// row `content` never reaches the model). Card rows are UI
|
|
70
|
+
// persistence, not conversation: mapped naively they render as empty
|
|
71
|
+
// "Agent:" lines and evict real messages from the history window, so
|
|
72
|
+
// they are dropped here and the model deliberately sees no card HTML.
|
|
73
|
+
// Known trade-off: cards still consume the DynamoDB Limit before this
|
|
74
|
+
// filter, so card-heavy threads under-fill the window (bounded, most-
|
|
75
|
+
// recent-first; revisit with an over-fetch if it bites).
|
|
76
|
+
return rows.filter((r) => r.kind !== 'card').map((r) => ({
|
|
77
|
+
seq: r.seq,
|
|
78
|
+
authorRole: r.authorRole,
|
|
79
|
+
kind: r.kind,
|
|
80
|
+
text: r.text ?? null,
|
|
81
|
+
content: r.content ?? null,
|
|
82
|
+
at: r.at,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Append one message with an atomic, gap-free seq. Idempotent on
|
|
87
|
+
* `clientMessageId`: a prior row with the same key returns its seq without
|
|
88
|
+
* a second write. Mirrors `ops/append-message.ts` steps 2–4.
|
|
89
|
+
*/
|
|
90
|
+
async appendMessage(input) {
|
|
91
|
+
const prior = await this.db.findByClientMessageId(input.threadId, input.clientMessageId);
|
|
92
|
+
if (prior) {
|
|
93
|
+
return { seq: prior.seq, deduped: true };
|
|
94
|
+
}
|
|
95
|
+
const now = new Date().toISOString();
|
|
96
|
+
const preview = input.text ? input.text.slice(0, PREVIEW_MAX_LEN) : '';
|
|
97
|
+
const seq = await this.db.incrementSeq(input.threadId, preview, now);
|
|
98
|
+
const row = {
|
|
99
|
+
threadId: input.threadId,
|
|
100
|
+
seq,
|
|
101
|
+
userId: input.userId,
|
|
102
|
+
clientMessageId: input.clientMessageId,
|
|
103
|
+
at: now,
|
|
104
|
+
kind: input.kind ?? 'text',
|
|
105
|
+
authorRole: input.role,
|
|
106
|
+
...(input.text !== undefined ? { text: input.text } : {}),
|
|
107
|
+
content: input.content,
|
|
108
|
+
};
|
|
109
|
+
await this.db.putMessage(row);
|
|
110
|
+
return { seq, deduped: false };
|
|
111
|
+
}
|
|
112
|
+
/** The thread's latest fold snapshot, or undefined when none persisted yet. */
|
|
113
|
+
async getSnapshot(threadId) {
|
|
114
|
+
return this.db.getSnapshot(threadId);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Persist a turn's fold delta: one row per message, one card row per
|
|
118
|
+
* artifact (each via an atomic gap-free seq), turn records inlined on the
|
|
119
|
+
* message rows, and an upserted snapshot for working state + thread-memory.
|
|
120
|
+
*
|
|
121
|
+
* Idempotency is TURN-LEVEL, not per-row. A retried invoke re-runs the LLM
|
|
122
|
+
* and can yield a *different* fold (different message count / content);
|
|
123
|
+
* per-index dedup would keep some prior rows and append new ones for the
|
|
124
|
+
* rest → a Frankenstein/orphan thread + clobbered snapshot. Instead, the
|
|
125
|
+
* FIRST row written (the first message, or — if there are no messages — the
|
|
126
|
+
* first card) claims a single `${base}#agentTurn` sentinel key. On any later
|
|
127
|
+
* call with the same base, the sentinel matches and the WHOLE persist is
|
|
128
|
+
* short-circuited (a committed agent turn is never re-merged).
|
|
129
|
+
*
|
|
130
|
+
* Durable (non-thread) memory is logged-and-dropped in v1. `skipSnapshot`
|
|
131
|
+
* suppresses the snapshot upsert so a degraded/parked fold never overwrites
|
|
132
|
+
* a good snapshot.
|
|
133
|
+
*/
|
|
134
|
+
async appendFold(input) {
|
|
135
|
+
const { threadId, userId, fold, clientMessageIdBase, skipSnapshot } = input;
|
|
136
|
+
// Turn-level idempotency gate: a committed agent turn is never re-merged.
|
|
137
|
+
const sentinelKey = `${clientMessageIdBase}#agentTurn`;
|
|
138
|
+
const existing = await this.db.findByClientMessageId(threadId, sentinelKey);
|
|
139
|
+
if (existing) {
|
|
140
|
+
return { messageSeqs: [existing.seq], artifactSeqs: [], droppedDurableMemory: 0, deduped: true };
|
|
141
|
+
}
|
|
142
|
+
const messageSeqs = [];
|
|
143
|
+
const artifactSeqs = [];
|
|
144
|
+
// Whichever row is written first claims the sentinel key.
|
|
145
|
+
let sentinelClaimed = false;
|
|
146
|
+
for (let i = 0; i < fold.messages.length; i++) {
|
|
147
|
+
const msg = fold.messages[i];
|
|
148
|
+
const clientMessageId = sentinelClaimed ? `${clientMessageIdBase}#agent#${i}` : sentinelKey;
|
|
149
|
+
sentinelClaimed = true;
|
|
150
|
+
const text = msg.content.reduce((acc, b) => (b.type === 'text' ? acc + b.text : acc), '');
|
|
151
|
+
const now = new Date().toISOString();
|
|
152
|
+
// Text-less agent messages (tool-call + tool-result only — the canonical
|
|
153
|
+
// card-producing turn) must not blank the preview: '' takes the SET
|
|
154
|
+
// branch, null leaves the prior preview standing.
|
|
155
|
+
const seq = await this.db.incrementSeq(threadId, text ? text.slice(0, PREVIEW_MAX_LEN) : null, now);
|
|
156
|
+
const turnRecord = fold.turns.find((t) => t.turnId === msg.turnId);
|
|
157
|
+
await this.db.putMessage(agMessageToRow(msg, {
|
|
158
|
+
threadId,
|
|
159
|
+
userId,
|
|
160
|
+
seq,
|
|
161
|
+
at: now,
|
|
162
|
+
clientMessageId,
|
|
163
|
+
...(turnRecord ? { turnRecord } : {}),
|
|
164
|
+
}));
|
|
165
|
+
messageSeqs.push(seq);
|
|
166
|
+
}
|
|
167
|
+
// Cards can arrive two ways: first-class artifact events (fold.artifacts)
|
|
168
|
+
// or UI-carrying tool-result blocks inside messages (the Claude facet's
|
|
169
|
+
// only channel — see uiCardArtifactsFromMessages). Persist both through
|
|
170
|
+
// the same card-row lane so history rehydrates them (guuey#86). The
|
|
171
|
+
// artifactId dedupe makes re-persisting a reassembled fold (whose
|
|
172
|
+
// artifacts already CONTAIN prior projections, deterministic
|
|
173
|
+
// `<msgId>#ui#<idx>` ids) a no-op instead of a double-write.
|
|
174
|
+
const knownArtifactIds = new Set(fold.artifacts.map((a) => a.artifactId));
|
|
175
|
+
const projected = uiCardArtifactsFromMessages(fold.messages).filter((a) => !knownArtifactIds.has(a.artifactId));
|
|
176
|
+
const cardArtifacts = [...fold.artifacts, ...projected];
|
|
177
|
+
for (let i = 0; i < cardArtifacts.length; i++) {
|
|
178
|
+
const art = cardArtifacts[i];
|
|
179
|
+
const clientMessageId = sentinelClaimed ? `${clientMessageIdBase}#card#${i}` : sentinelKey;
|
|
180
|
+
sentinelClaimed = true;
|
|
181
|
+
const now = new Date().toISOString();
|
|
182
|
+
const seq = await this.db.incrementSeq(threadId, art.name ?? null, now);
|
|
183
|
+
await this.db.putMessage(agArtifactToCardRow(art, { threadId, userId, seq, at: now, clientMessageId }));
|
|
184
|
+
artifactSeqs.push(seq);
|
|
185
|
+
}
|
|
186
|
+
const threadMemory = fold.memory.filter((m) => m.scope === 'thread');
|
|
187
|
+
const droppedDurableMemory = fold.memory.length - threadMemory.length;
|
|
188
|
+
if (skipSnapshot !== true) {
|
|
189
|
+
const lastTurnId = fold.turns.length ? fold.turns[fold.turns.length - 1].turnId : undefined;
|
|
190
|
+
await this.db.putSnapshot({
|
|
191
|
+
threadId,
|
|
192
|
+
userId,
|
|
193
|
+
threadMemory,
|
|
194
|
+
updatedAt: new Date().toISOString(),
|
|
195
|
+
...(fold.state !== undefined ? { workingState: fold.state } : {}),
|
|
196
|
+
...(lastTurnId ? { lastTurnId } : {}),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return { messageSeqs, artifactSeqs, droppedDurableMemory, deduped: false };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ThreadPersistencePort } from "../rows.js";
|
|
2
|
+
export interface ThreadPersistenceHarness {
|
|
3
|
+
port: ThreadPersistencePort;
|
|
4
|
+
cleanup?: () => void | Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Run the port contract against a binding. `make` must yield a FRESH,
|
|
8
|
+
* empty store per call.
|
|
9
|
+
*/
|
|
10
|
+
export declare function runThreadPersistenceContractSuite(name: string, make: () => Promise<ThreadPersistenceHarness>): void;
|
|
11
|
+
//# sourceMappingURL=contract-suite.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contract-suite.d.ts","sourceRoot":"","sources":["../../src/testing/contract-suite.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,qBAAqB,EAAa,MAAM,YAAY,CAAC;AAEnE,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,qBAAqB,CAAC;IAC5B,OAAO,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACtC;AAiCD;;;GAGG;AACH,wBAAgB,iCAAiC,CAC/C,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,OAAO,CAAC,wBAAwB,CAAC,GAC5C,IAAI,CA8HN"}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binding-agnostic behavioral contract for {@link ThreadPersistencePort}.
|
|
3
|
+
* Every binding (in-memory here, guuey's hosted DynamoDB binding, your
|
|
4
|
+
* own store) runs this SAME suite so "works in-memory" and "works on the
|
|
5
|
+
* real thing" mean the same set of guarantees — the `@guuey/state`
|
|
6
|
+
* contract-suite pattern (guuey#107).
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
function threadRow(id) {
|
|
10
|
+
const now = "2026-08-07T00:00:00.000Z";
|
|
11
|
+
return {
|
|
12
|
+
id,
|
|
13
|
+
userId: "g_contract",
|
|
14
|
+
appId: "app_contract",
|
|
15
|
+
servingRegion: "test-region",
|
|
16
|
+
title: "New thread",
|
|
17
|
+
status: "active",
|
|
18
|
+
pinned: false,
|
|
19
|
+
lastSeq: 0,
|
|
20
|
+
lastMessageAt: now,
|
|
21
|
+
lastMessagePreview: "",
|
|
22
|
+
threadMode: "single",
|
|
23
|
+
createdAt: now,
|
|
24
|
+
updatedAt: now,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
async function withHarness(make, fn) {
|
|
28
|
+
const h = await make();
|
|
29
|
+
try {
|
|
30
|
+
await fn(h.port);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
await h.cleanup?.();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Run the port contract against a binding. `make` must yield a FRESH,
|
|
38
|
+
* empty store per call.
|
|
39
|
+
*/
|
|
40
|
+
export function runThreadPersistenceContractSuite(name, make) {
|
|
41
|
+
describe(`ThreadPersistencePort contract — ${name}`, () => {
|
|
42
|
+
it("creates and point-reads a thread; unknown ids read undefined", async () => {
|
|
43
|
+
await withHarness(make, async (port) => {
|
|
44
|
+
await port.createThread(threadRow("t1"));
|
|
45
|
+
expect((await port.getThread("t1"))?.id).toBe("t1");
|
|
46
|
+
expect(await port.getThread("t-missing")).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
it("rejects creating a thread id twice (conditional create)", async () => {
|
|
50
|
+
await withHarness(make, async (port) => {
|
|
51
|
+
await port.createThread(threadRow("t1"));
|
|
52
|
+
await expect(port.createThread(threadRow("t1"))).rejects.toThrow();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
it("incrementSeq is monotonic from 1 and echoes the new value", async () => {
|
|
56
|
+
await withHarness(make, async (port) => {
|
|
57
|
+
await port.createThread(threadRow("t1"));
|
|
58
|
+
expect(await port.incrementSeq("t1", "one", "2026-08-07T00:00:01.000Z")).toBe(1);
|
|
59
|
+
expect(await port.incrementSeq("t1", "two", "2026-08-07T00:00:02.000Z")).toBe(2);
|
|
60
|
+
const t = await port.getThread("t1");
|
|
61
|
+
expect(t?.lastSeq).toBe(2);
|
|
62
|
+
expect(t?.lastMessagePreview).toBe("two");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
it("incrementSeq(null) allocates the seq WITHOUT touching the preview", async () => {
|
|
66
|
+
await withHarness(make, async (port) => {
|
|
67
|
+
await port.createThread(threadRow("t1"));
|
|
68
|
+
await port.incrementSeq("t1", "kept preview", "2026-08-07T00:00:01.000Z");
|
|
69
|
+
expect(await port.incrementSeq("t1", null, "2026-08-07T00:00:02.000Z")).toBe(2);
|
|
70
|
+
const t = await port.getThread("t1");
|
|
71
|
+
expect(t?.lastMessagePreview).toBe("kept preview");
|
|
72
|
+
expect(t?.lastMessageAt).toBe("2026-08-07T00:00:02.000Z");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
it("incrementSeq on a missing thread rejects (conditional update)", async () => {
|
|
76
|
+
await withHarness(make, async (port) => {
|
|
77
|
+
await expect(port.incrementSeq("t-missing", "p", "2026-08-07T00:00:01.000Z")).rejects.toThrow();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
it("putMessage rejects a duplicate (threadId, seq)", async () => {
|
|
81
|
+
await withHarness(make, async (port) => {
|
|
82
|
+
await port.createThread(threadRow("t1"));
|
|
83
|
+
const row = {
|
|
84
|
+
threadId: "t1",
|
|
85
|
+
seq: 1,
|
|
86
|
+
userId: "g_contract",
|
|
87
|
+
clientMessageId: "c1",
|
|
88
|
+
at: "2026-08-07T00:00:01.000Z",
|
|
89
|
+
kind: "text",
|
|
90
|
+
authorRole: "user",
|
|
91
|
+
text: "hello",
|
|
92
|
+
};
|
|
93
|
+
await port.putMessage(row);
|
|
94
|
+
await expect(port.putMessage({ ...row, clientMessageId: "c2" })).rejects.toThrow();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
it("listRecentMessages returns the most-recent `limit` rows in seq-ASC order", async () => {
|
|
98
|
+
await withHarness(make, async (port) => {
|
|
99
|
+
await port.createThread(threadRow("t1"));
|
|
100
|
+
for (let seq = 1; seq <= 5; seq++) {
|
|
101
|
+
await port.putMessage({
|
|
102
|
+
threadId: "t1",
|
|
103
|
+
seq,
|
|
104
|
+
userId: "g_contract",
|
|
105
|
+
clientMessageId: `c${seq}`,
|
|
106
|
+
at: `2026-08-07T00:00:0${seq}.000Z`,
|
|
107
|
+
kind: "text",
|
|
108
|
+
authorRole: seq % 2 ? "user" : "agent",
|
|
109
|
+
text: `m${seq}`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const rows = await port.listRecentMessages("t1", 3);
|
|
113
|
+
expect(rows.map((r) => r.seq)).toEqual([3, 4, 5]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
it("findByClientMessageId resolves the idempotency key within its thread only", async () => {
|
|
117
|
+
await withHarness(make, async (port) => {
|
|
118
|
+
await port.createThread(threadRow("t1"));
|
|
119
|
+
await port.createThread(threadRow("t2"));
|
|
120
|
+
await port.putMessage({
|
|
121
|
+
threadId: "t1",
|
|
122
|
+
seq: 1,
|
|
123
|
+
userId: "g_contract",
|
|
124
|
+
clientMessageId: "shared-key",
|
|
125
|
+
at: "2026-08-07T00:00:01.000Z",
|
|
126
|
+
kind: "text",
|
|
127
|
+
authorRole: "user",
|
|
128
|
+
text: "hello",
|
|
129
|
+
});
|
|
130
|
+
expect((await port.findByClientMessageId("t1", "shared-key"))?.seq).toBe(1);
|
|
131
|
+
expect(await port.findByClientMessageId("t2", "shared-key")).toBeUndefined();
|
|
132
|
+
expect(await port.findByClientMessageId("t1", "unknown")).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
it("snapshot upsert is full-replace and point-readable", async () => {
|
|
136
|
+
await withHarness(make, async (port) => {
|
|
137
|
+
await port.createThread(threadRow("t1"));
|
|
138
|
+
expect(await port.getSnapshot("t1")).toBeUndefined();
|
|
139
|
+
await port.putSnapshot({
|
|
140
|
+
threadId: "t1",
|
|
141
|
+
userId: "g_contract",
|
|
142
|
+
threadMemory: [{ scope: "thread", key: "k", value: "v1" }],
|
|
143
|
+
workingState: { step: 1 },
|
|
144
|
+
updatedAt: "2026-08-07T00:00:01.000Z",
|
|
145
|
+
});
|
|
146
|
+
await port.putSnapshot({
|
|
147
|
+
threadId: "t1",
|
|
148
|
+
userId: "g_contract",
|
|
149
|
+
threadMemory: [],
|
|
150
|
+
updatedAt: "2026-08-07T00:00:02.000Z",
|
|
151
|
+
});
|
|
152
|
+
const snap = await port.getSnapshot("t1");
|
|
153
|
+
expect(snap?.threadMemory).toEqual([]);
|
|
154
|
+
expect(snap?.workingState).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@guuey/threads",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Universal session/thread persistence for AgJSON agents — the ThreadStore contract (append-fold, history, snapshots), the fold↔row mapping, and an in-memory binding. Guuey's hosted runtime is one binding; implement the port against your own store and run the exported contract suite for the same guarantees.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./testing": {
|
|
20
|
+
"types": "./dist/testing/contract-suite.d.ts",
|
|
21
|
+
"import": "./dist/testing/contract-suite.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@silverprotocol/core": "0.4.1"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^24.0.0",
|
|
29
|
+
"typescript": "^5.0.0",
|
|
30
|
+
"vitest": "^3.0.0"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"guuey",
|
|
37
|
+
"threads",
|
|
38
|
+
"sessions",
|
|
39
|
+
"persistence",
|
|
40
|
+
"rehydration",
|
|
41
|
+
"agjson",
|
|
42
|
+
"agent"
|
|
43
|
+
],
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/withguuey/guuey-sdks.git",
|
|
47
|
+
"directory": "packages/threads"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://guuey.com",
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/loqu-co/guuey/issues"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"vitest": "^3.0.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"vitest": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "tsc -p tsconfig.build.json",
|
|
63
|
+
"dev": "tsc --watch",
|
|
64
|
+
"typecheck": "tsc --noEmit",
|
|
65
|
+
"test": "vitest run",
|
|
66
|
+
"test:watch": "vitest"
|
|
67
|
+
}
|
|
68
|
+
}
|