logisheets-logician 1.11.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 +89 -0
- package/dist/agent/loop.d.ts +101 -0
- package/dist/agent/loop.js +260 -0
- package/dist/conversation.d.ts +105 -0
- package/dist/conversation.js +10 -0
- package/dist/craft-interactions-api.d.ts +81 -0
- package/dist/craft-interactions-api.js +25 -0
- package/dist/craft-interactions-core.d.ts +14 -0
- package/dist/craft-interactions-core.js +33 -0
- package/dist/crafts/manifest.d.ts +39 -0
- package/dist/crafts/manifest.js +10 -0
- package/dist/crafts/skill-tools.d.ts +33 -0
- package/dist/crafts/skill-tools.js +146 -0
- package/dist/crafts/store.d.ts +47 -0
- package/dist/crafts/store.js +69 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +22 -0
- package/dist/index.node.js +5065 -0
- package/dist/projection.d.ts +129 -0
- package/dist/projection.js +249 -0
- package/dist/storage.d.ts +99 -0
- package/dist/storage.js +223 -0
- package/dist/tool.d.ts +137 -0
- package/dist/tool.js +57 -0
- package/dist/tools/block-ops.d.ts +30 -0
- package/dist/tools/block-ops.js +97 -0
- package/dist/tools/builder.d.ts +206 -0
- package/dist/tools/builder.js +1502 -0
- package/dist/tools/cells.d.ts +58 -0
- package/dist/tools/cells.js +263 -0
- package/dist/tools/comments.d.ts +54 -0
- package/dist/tools/comments.js +234 -0
- package/dist/tools/craft-interactions.d.ts +22 -0
- package/dist/tools/craft-interactions.js +454 -0
- package/dist/tools/edit.d.ts +55 -0
- package/dist/tools/edit.js +356 -0
- package/dist/tools/format.d.ts +58 -0
- package/dist/tools/format.js +208 -0
- package/dist/tools/history.d.ts +13 -0
- package/dist/tools/history.js +39 -0
- package/dist/tools/inspect.d.ts +93 -0
- package/dist/tools/inspect.js +488 -0
- package/dist/tools/links.d.ts +30 -0
- package/dist/tools/links.js +122 -0
- package/dist/tools/structure.d.ts +62 -0
- package/dist/tools/structure.js +174 -0
- package/dist/tools/taxonomy.d.ts +32 -0
- package/dist/tools/taxonomy.js +93 -0
- package/package.json +53 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure projections from the event stream to:
|
|
3
|
+
* - Agent IR messages (Anthropic-compatible shape, used as canonical IR
|
|
4
|
+
* by all LlmClient implementations — Anthropic adapters consume it
|
|
5
|
+
* directly, other providers translate into their wire format)
|
|
6
|
+
* → toLlmMessages
|
|
7
|
+
* - UI-friendly chat bubbles → toUiBubbles
|
|
8
|
+
*
|
|
9
|
+
* Both are referentially transparent: same events → same output, no I/O.
|
|
10
|
+
* The agent loop calls toLlmMessages before each request; the UI calls
|
|
11
|
+
* toUiBubbles whenever events change. Cache-control breakpoints for
|
|
12
|
+
* prompt caching are NOT placed here — they're a property of the
|
|
13
|
+
* request, not the message content.
|
|
14
|
+
*/
|
|
15
|
+
import type { ConversationEvent, SystemNoteEvent, ToolCallEvent } from './conversation.js';
|
|
16
|
+
export type AgentRole = 'user' | 'assistant';
|
|
17
|
+
export type AgentContentBlock = {
|
|
18
|
+
type: 'text';
|
|
19
|
+
text: string;
|
|
20
|
+
} | {
|
|
21
|
+
type: 'tool_use';
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
input: unknown;
|
|
25
|
+
} | {
|
|
26
|
+
type: 'tool_result';
|
|
27
|
+
tool_use_id: string;
|
|
28
|
+
content: string | AgentContentBlock[];
|
|
29
|
+
is_error?: boolean;
|
|
30
|
+
};
|
|
31
|
+
export interface AgentMessage {
|
|
32
|
+
role: AgentRole;
|
|
33
|
+
content: string | AgentContentBlock[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Hook for materializing blob refs into real payloads. The store-aware
|
|
37
|
+
* caller (agent loop) passes a resolver; pure callers can omit it and
|
|
38
|
+
* blob refs render as a short placeholder.
|
|
39
|
+
*/
|
|
40
|
+
export type BlobResolver = (ref: string) => string | null;
|
|
41
|
+
export interface ToLlmMessagesOpts {
|
|
42
|
+
/** Resolve `{kind:'blob_ref', ref}` payloads into strings. */
|
|
43
|
+
resolveBlob?: BlobResolver;
|
|
44
|
+
/**
|
|
45
|
+
* Cap on a single tool_result's serialized size, in chars. Larger
|
|
46
|
+
* payloads are truncated with a trailing "(... truncated, N more
|
|
47
|
+
* chars)" marker. Default 8000.
|
|
48
|
+
*/
|
|
49
|
+
max_tool_result_chars?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Drop tool_results older than this index (0-based, counting only
|
|
52
|
+
* tool_result events). Use to keep ancient results out of context
|
|
53
|
+
* once they're no longer load-bearing. -1 disables (default).
|
|
54
|
+
*/
|
|
55
|
+
truncate_tool_results_before?: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Project events into the agent IR (Anthropic-compatible).
|
|
59
|
+
*
|
|
60
|
+
* Rules:
|
|
61
|
+
* - user_message → {role: 'user', content: text}
|
|
62
|
+
* - assistant_text +
|
|
63
|
+
* tool_call → merged into one {role: 'assistant', content: [...]}
|
|
64
|
+
* per turn_id
|
|
65
|
+
* - tool_result → {role: 'user', content: [{type:'tool_result', ...}]}
|
|
66
|
+
* - user_confirm → dropped (internal signal)
|
|
67
|
+
* - system_note:debug → dropped
|
|
68
|
+
* - system_note:ui_only→ dropped
|
|
69
|
+
* - system_note:summary→ {role: 'user', content: text} with a
|
|
70
|
+
* "[summary]" prefix so the model recognizes it
|
|
71
|
+
*/
|
|
72
|
+
export declare function toLlmMessages(events: readonly ConversationEvent[], opts?: ToLlmMessagesOpts): AgentMessage[];
|
|
73
|
+
/**
|
|
74
|
+
* Each bubble is one row in the chat UI. tool_call + tool_result are
|
|
75
|
+
* fused into a single ToolBubble so the UI renders one collapsible card
|
|
76
|
+
* per tool invocation rather than two stacked rows.
|
|
77
|
+
*/
|
|
78
|
+
export type ChatBubble = UserBubble | AssistantTextBubble | ToolBubble | NoteBubble;
|
|
79
|
+
export interface UserBubble {
|
|
80
|
+
kind: 'user';
|
|
81
|
+
id: string;
|
|
82
|
+
ts: number;
|
|
83
|
+
text: string;
|
|
84
|
+
}
|
|
85
|
+
export interface AssistantTextBubble {
|
|
86
|
+
kind: 'assistant_text';
|
|
87
|
+
id: string;
|
|
88
|
+
ts: number;
|
|
89
|
+
text: string;
|
|
90
|
+
turn_id: string;
|
|
91
|
+
/** Set on the last assistant_text of a turn for tail spacing. */
|
|
92
|
+
last_in_turn: boolean;
|
|
93
|
+
}
|
|
94
|
+
export interface ToolBubble {
|
|
95
|
+
kind: 'tool';
|
|
96
|
+
id: string;
|
|
97
|
+
ts: number;
|
|
98
|
+
turn_id: string;
|
|
99
|
+
tool_use_id: string;
|
|
100
|
+
name: string;
|
|
101
|
+
input: unknown;
|
|
102
|
+
/** Populated once the matching tool_result arrives. */
|
|
103
|
+
output?: unknown;
|
|
104
|
+
error?: string;
|
|
105
|
+
duration_ms?: number;
|
|
106
|
+
/** True from request → response window. */
|
|
107
|
+
pending: boolean;
|
|
108
|
+
/** Captures the user's decision on a confirmation prompt. */
|
|
109
|
+
user_confirm?: {
|
|
110
|
+
approved: boolean;
|
|
111
|
+
reason?: string;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export interface NoteBubble {
|
|
115
|
+
kind: 'note';
|
|
116
|
+
id: string;
|
|
117
|
+
ts: number;
|
|
118
|
+
text: string;
|
|
119
|
+
/** 'summary' notes are also worth showing; 'debug' is filtered out. */
|
|
120
|
+
severity: 'info' | 'summary';
|
|
121
|
+
}
|
|
122
|
+
export interface ToUiBubblesOpts {
|
|
123
|
+
/** Include debug-level system_notes (for a "show internals" toggle). */
|
|
124
|
+
include_debug?: boolean;
|
|
125
|
+
}
|
|
126
|
+
export declare function toUiBubbles(events: readonly ConversationEvent[], opts?: ToUiBubblesOpts): ChatBubble[];
|
|
127
|
+
/** Type guard, exposed for downstream tooling. */
|
|
128
|
+
export declare function isToolCall(e: ConversationEvent): e is ToolCallEvent;
|
|
129
|
+
export declare function isSystemNote(e: ConversationEvent): e is SystemNoteEvent;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure projections from the event stream to:
|
|
3
|
+
* - Agent IR messages (Anthropic-compatible shape, used as canonical IR
|
|
4
|
+
* by all LlmClient implementations — Anthropic adapters consume it
|
|
5
|
+
* directly, other providers translate into their wire format)
|
|
6
|
+
* → toLlmMessages
|
|
7
|
+
* - UI-friendly chat bubbles → toUiBubbles
|
|
8
|
+
*
|
|
9
|
+
* Both are referentially transparent: same events → same output, no I/O.
|
|
10
|
+
* The agent loop calls toLlmMessages before each request; the UI calls
|
|
11
|
+
* toUiBubbles whenever events change. Cache-control breakpoints for
|
|
12
|
+
* prompt caching are NOT placed here — they're a property of the
|
|
13
|
+
* request, not the message content.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Project events into the agent IR (Anthropic-compatible).
|
|
17
|
+
*
|
|
18
|
+
* Rules:
|
|
19
|
+
* - user_message → {role: 'user', content: text}
|
|
20
|
+
* - assistant_text +
|
|
21
|
+
* tool_call → merged into one {role: 'assistant', content: [...]}
|
|
22
|
+
* per turn_id
|
|
23
|
+
* - tool_result → {role: 'user', content: [{type:'tool_result', ...}]}
|
|
24
|
+
* - user_confirm → dropped (internal signal)
|
|
25
|
+
* - system_note:debug → dropped
|
|
26
|
+
* - system_note:ui_only→ dropped
|
|
27
|
+
* - system_note:summary→ {role: 'user', content: text} with a
|
|
28
|
+
* "[summary]" prefix so the model recognizes it
|
|
29
|
+
*/
|
|
30
|
+
export function toLlmMessages(events, opts = {}) {
|
|
31
|
+
var _a, _b, _c;
|
|
32
|
+
const max = (_a = opts.max_tool_result_chars) !== null && _a !== void 0 ? _a : 8000;
|
|
33
|
+
const dropBefore = (_b = opts.truncate_tool_results_before) !== null && _b !== void 0 ? _b : -1;
|
|
34
|
+
const messages = [];
|
|
35
|
+
let toolResultIdx = -1;
|
|
36
|
+
// Buffer for in-progress assistant turn. Same turn_id collapses
|
|
37
|
+
// multiple assistant_text + tool_call events into one message.
|
|
38
|
+
let pendingTurnId = null;
|
|
39
|
+
let pendingBlocks = [];
|
|
40
|
+
const flushAssistant = () => {
|
|
41
|
+
if (pendingTurnId === null)
|
|
42
|
+
return;
|
|
43
|
+
if (pendingBlocks.length > 0) {
|
|
44
|
+
messages.push({ role: 'assistant', content: pendingBlocks });
|
|
45
|
+
}
|
|
46
|
+
pendingTurnId = null;
|
|
47
|
+
pendingBlocks = [];
|
|
48
|
+
};
|
|
49
|
+
for (const e of events) {
|
|
50
|
+
switch (e.kind) {
|
|
51
|
+
case 'user_confirm':
|
|
52
|
+
// internal — never crosses to LLM
|
|
53
|
+
continue;
|
|
54
|
+
case 'system_note':
|
|
55
|
+
if (e.visibility !== 'summary')
|
|
56
|
+
continue;
|
|
57
|
+
flushAssistant();
|
|
58
|
+
messages.push({
|
|
59
|
+
role: 'user',
|
|
60
|
+
content: `[summary] ${e.text}`,
|
|
61
|
+
});
|
|
62
|
+
continue;
|
|
63
|
+
case 'user_message':
|
|
64
|
+
flushAssistant();
|
|
65
|
+
messages.push({ role: 'user', content: e.text });
|
|
66
|
+
continue;
|
|
67
|
+
case 'assistant_text':
|
|
68
|
+
if (pendingTurnId !== e.turn_id) {
|
|
69
|
+
flushAssistant();
|
|
70
|
+
pendingTurnId = e.turn_id;
|
|
71
|
+
}
|
|
72
|
+
pendingBlocks.push({ type: 'text', text: e.text });
|
|
73
|
+
continue;
|
|
74
|
+
case 'tool_call':
|
|
75
|
+
if (pendingTurnId !== e.turn_id) {
|
|
76
|
+
flushAssistant();
|
|
77
|
+
pendingTurnId = e.turn_id;
|
|
78
|
+
}
|
|
79
|
+
pendingBlocks.push({
|
|
80
|
+
type: 'tool_use',
|
|
81
|
+
id: e.tool_use_id,
|
|
82
|
+
name: e.name,
|
|
83
|
+
input: (_c = e.input) !== null && _c !== void 0 ? _c : {},
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
case 'tool_result': {
|
|
87
|
+
flushAssistant();
|
|
88
|
+
toolResultIdx++;
|
|
89
|
+
if (toolResultIdx < dropBefore)
|
|
90
|
+
continue;
|
|
91
|
+
const serialized = serializeToolOutput(e, opts.resolveBlob, max);
|
|
92
|
+
messages.push({
|
|
93
|
+
role: 'user',
|
|
94
|
+
content: [
|
|
95
|
+
{
|
|
96
|
+
type: 'tool_result',
|
|
97
|
+
tool_use_id: e.tool_use_id,
|
|
98
|
+
content: serialized.content,
|
|
99
|
+
is_error: serialized.is_error,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
flushAssistant();
|
|
108
|
+
return messages;
|
|
109
|
+
}
|
|
110
|
+
function serializeToolOutput(e, resolveBlob, max) {
|
|
111
|
+
if (e.error) {
|
|
112
|
+
return { content: e.error.slice(0, max), is_error: true };
|
|
113
|
+
}
|
|
114
|
+
let raw = stringifyOutput(e.output, resolveBlob);
|
|
115
|
+
if (raw.length > max) {
|
|
116
|
+
const dropped = raw.length - max;
|
|
117
|
+
raw = raw.slice(0, max) + `\n(… truncated, ${dropped} more chars)`;
|
|
118
|
+
}
|
|
119
|
+
return { content: raw };
|
|
120
|
+
}
|
|
121
|
+
function stringifyOutput(output, resolveBlob) {
|
|
122
|
+
if (typeof output === 'string')
|
|
123
|
+
return output;
|
|
124
|
+
if (output && typeof output === 'object') {
|
|
125
|
+
const ref = output.kind === 'blob_ref' &&
|
|
126
|
+
typeof output.ref === 'string'
|
|
127
|
+
? output.ref
|
|
128
|
+
: null;
|
|
129
|
+
if (ref) {
|
|
130
|
+
const resolved = resolveBlob ? resolveBlob(ref) : null;
|
|
131
|
+
return resolved !== null && resolved !== void 0 ? resolved : `(blob ${ref} — not resolved)`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
return JSON.stringify(output);
|
|
136
|
+
}
|
|
137
|
+
catch (_a) {
|
|
138
|
+
return String(output);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export function toUiBubbles(events, opts = {}) {
|
|
142
|
+
const bubbles = [];
|
|
143
|
+
/** tool_use_id → index in bubbles array, for back-patching results. */
|
|
144
|
+
const toolBubbleByUseId = new Map();
|
|
145
|
+
for (const e of events) {
|
|
146
|
+
switch (e.kind) {
|
|
147
|
+
case 'user_message':
|
|
148
|
+
bubbles.push({
|
|
149
|
+
kind: 'user',
|
|
150
|
+
id: e.id,
|
|
151
|
+
ts: e.ts,
|
|
152
|
+
text: e.text,
|
|
153
|
+
});
|
|
154
|
+
continue;
|
|
155
|
+
case 'assistant_text':
|
|
156
|
+
bubbles.push({
|
|
157
|
+
kind: 'assistant_text',
|
|
158
|
+
id: e.id,
|
|
159
|
+
ts: e.ts,
|
|
160
|
+
text: e.text,
|
|
161
|
+
turn_id: e.turn_id,
|
|
162
|
+
last_in_turn: false,
|
|
163
|
+
});
|
|
164
|
+
continue;
|
|
165
|
+
case 'tool_call': {
|
|
166
|
+
const idx = bubbles.push({
|
|
167
|
+
kind: 'tool',
|
|
168
|
+
id: e.id,
|
|
169
|
+
ts: e.ts,
|
|
170
|
+
turn_id: e.turn_id,
|
|
171
|
+
tool_use_id: e.tool_use_id,
|
|
172
|
+
name: e.name,
|
|
173
|
+
input: e.input,
|
|
174
|
+
pending: true,
|
|
175
|
+
}) - 1;
|
|
176
|
+
toolBubbleByUseId.set(e.tool_use_id, idx);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
case 'tool_result': {
|
|
180
|
+
const idx = toolBubbleByUseId.get(e.tool_use_id);
|
|
181
|
+
if (idx === undefined) {
|
|
182
|
+
// Orphan tool_result — surface as a note for debugging.
|
|
183
|
+
bubbles.push({
|
|
184
|
+
kind: 'note',
|
|
185
|
+
id: e.id,
|
|
186
|
+
ts: e.ts,
|
|
187
|
+
text: `orphan tool_result for ${e.tool_use_id}`,
|
|
188
|
+
severity: 'info',
|
|
189
|
+
});
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const tb = bubbles[idx];
|
|
193
|
+
tb.output = e.output;
|
|
194
|
+
tb.error = e.error;
|
|
195
|
+
tb.duration_ms = e.duration_ms;
|
|
196
|
+
tb.pending = false;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
case 'user_confirm': {
|
|
200
|
+
const idx = toolBubbleByUseId.get(e.tool_use_id);
|
|
201
|
+
if (idx !== undefined) {
|
|
202
|
+
const tb = bubbles[idx];
|
|
203
|
+
tb.user_confirm = {
|
|
204
|
+
approved: e.approved,
|
|
205
|
+
reason: e.reason,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
case 'system_note': {
|
|
211
|
+
if (e.visibility === 'debug' && !opts.include_debug)
|
|
212
|
+
continue;
|
|
213
|
+
if (e.visibility === 'ui_only' || e.visibility === 'summary') {
|
|
214
|
+
bubbles.push({
|
|
215
|
+
kind: 'note',
|
|
216
|
+
id: e.id,
|
|
217
|
+
ts: e.ts,
|
|
218
|
+
text: e.text,
|
|
219
|
+
severity: e.visibility === 'summary' ? 'summary' : 'info',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Tag the last assistant_text of each turn so the UI can add trailing
|
|
227
|
+
// spacing / show a "done" marker without inspecting siblings at render.
|
|
228
|
+
const lastAssistantIdxByTurn = new Map();
|
|
229
|
+
for (let i = 0; i < bubbles.length; i++) {
|
|
230
|
+
const b = bubbles[i];
|
|
231
|
+
if (b.kind === 'assistant_text')
|
|
232
|
+
lastAssistantIdxByTurn.set(b.turn_id, i);
|
|
233
|
+
}
|
|
234
|
+
for (const i of lastAssistantIdxByTurn.values()) {
|
|
235
|
+
;
|
|
236
|
+
bubbles[i].last_in_turn = true;
|
|
237
|
+
}
|
|
238
|
+
return bubbles;
|
|
239
|
+
}
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Helpers re-exported for tests / external composition
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
/** Type guard, exposed for downstream tooling. */
|
|
244
|
+
export function isToolCall(e) {
|
|
245
|
+
return e.kind === 'tool_call';
|
|
246
|
+
}
|
|
247
|
+
export function isSystemNote(e) {
|
|
248
|
+
return e.kind === 'system_note';
|
|
249
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage abstraction for conversations + events + blobs.
|
|
3
|
+
*
|
|
4
|
+
* The logician package never imports a concrete storage backend.
|
|
5
|
+
* Hosts plug one in:
|
|
6
|
+
* - Watson (browser craft) → IdbConversationStore via Dexie
|
|
7
|
+
* - Node CLI / MCP server → FileConversationStore (json-on-disk)
|
|
8
|
+
* - Tests / smoke runs / REPLs → MemoryConversationStore (this file)
|
|
9
|
+
*
|
|
10
|
+
* Same pattern as `WorkbookClient` in tool.ts — declare the surface
|
|
11
|
+
* here, let the platform provide the implementation.
|
|
12
|
+
*/
|
|
13
|
+
import type { Conversation, ConversationEvent, ConversationExport, ConversationSummary } from './conversation.js';
|
|
14
|
+
export interface ConversationStoreOpts {
|
|
15
|
+
/** Cap on listEvents() output for safety; 0 means no cap. */
|
|
16
|
+
max_events_per_query?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Persistence surface used by the agent loop. All methods are async so
|
|
20
|
+
* IDB / fs / network backends fit without changing call sites.
|
|
21
|
+
*/
|
|
22
|
+
export interface ConversationStore {
|
|
23
|
+
createConversation(meta: Omit<Conversation, 'id' | 'created_at' | 'updated_at'> & {
|
|
24
|
+
id?: string;
|
|
25
|
+
}): Promise<Conversation>;
|
|
26
|
+
getConversation(id: string): Promise<Conversation | null>;
|
|
27
|
+
listConversations(filter?: {
|
|
28
|
+
workbook_id?: string;
|
|
29
|
+
limit?: number;
|
|
30
|
+
}): Promise<ConversationSummary[]>;
|
|
31
|
+
updateConversation(id: string, patch: Partial<Pick<Conversation, 'title' | 'extra'>>): Promise<void>;
|
|
32
|
+
deleteConversation(id: string): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Append a single event. Implementations should `updated_at` the
|
|
35
|
+
* parent conversation atomically with the insert.
|
|
36
|
+
*/
|
|
37
|
+
appendEvent(event: ConversationEvent): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Batched append. Useful when replaying a remote sync or restoring
|
|
40
|
+
* a workbook-archived conversation. Atomic on backends that support
|
|
41
|
+
* transactions; otherwise best-effort sequential.
|
|
42
|
+
*/
|
|
43
|
+
appendEvents(events: ConversationEvent[]): Promise<void>;
|
|
44
|
+
/** Read events in ts-ascending order. */
|
|
45
|
+
listEvents(conversation_id: string, opts?: {
|
|
46
|
+
since_ts?: number;
|
|
47
|
+
limit?: number;
|
|
48
|
+
}): Promise<ConversationEvent[]>;
|
|
49
|
+
/** Returns a stable ref string the caller stores in the event. */
|
|
50
|
+
putBlob(value: string | Uint8Array): Promise<string>;
|
|
51
|
+
getBlob(ref: string): Promise<string | Uint8Array | null>;
|
|
52
|
+
deleteBlob(ref: string): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Reactive subscription. Browser stores (IDB/Dexie) implement this
|
|
55
|
+
* to drive UI updates; headless backends may omit it.
|
|
56
|
+
* Returns an unsubscribe function.
|
|
57
|
+
*/
|
|
58
|
+
subscribeEvents?(conversation_id: string, cb: (events: ConversationEvent[]) => void): () => void;
|
|
59
|
+
exportConversation?(id: string): Promise<ConversationExport>;
|
|
60
|
+
importConversation?(bundle: ConversationExport): Promise<string>;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Volatile store. Survives the lifetime of the process, nothing more.
|
|
64
|
+
* Useful for:
|
|
65
|
+
* - tests
|
|
66
|
+
* - node CLI runs that don't care about persistence across invocations
|
|
67
|
+
* - Watson MVP before IdbConversationStore lands
|
|
68
|
+
*/
|
|
69
|
+
export declare class MemoryConversationStore implements ConversationStore {
|
|
70
|
+
private conversations;
|
|
71
|
+
/** Events grouped by conversation_id, kept sorted by ts. */
|
|
72
|
+
private events;
|
|
73
|
+
private blobs;
|
|
74
|
+
private subscribers;
|
|
75
|
+
private blobCounter;
|
|
76
|
+
createConversation(meta: Omit<Conversation, 'id' | 'created_at' | 'updated_at'> & {
|
|
77
|
+
id?: string;
|
|
78
|
+
}): Promise<Conversation>;
|
|
79
|
+
getConversation(id: string): Promise<Conversation | null>;
|
|
80
|
+
listConversations(filter?: {
|
|
81
|
+
workbook_id?: string;
|
|
82
|
+
limit?: number;
|
|
83
|
+
}): Promise<ConversationSummary[]>;
|
|
84
|
+
updateConversation(id: string, patch: Partial<Pick<Conversation, 'title' | 'extra'>>): Promise<void>;
|
|
85
|
+
deleteConversation(id: string): Promise<void>;
|
|
86
|
+
appendEvent(event: ConversationEvent): Promise<void>;
|
|
87
|
+
appendEvents(events: ConversationEvent[]): Promise<void>;
|
|
88
|
+
listEvents(conversation_id: string, opts?: {
|
|
89
|
+
since_ts?: number;
|
|
90
|
+
limit?: number;
|
|
91
|
+
}): Promise<ConversationEvent[]>;
|
|
92
|
+
putBlob(value: string | Uint8Array): Promise<string>;
|
|
93
|
+
getBlob(ref: string): Promise<string | Uint8Array | null>;
|
|
94
|
+
deleteBlob(ref: string): Promise<void>;
|
|
95
|
+
subscribeEvents(conversation_id: string, cb: (events: ConversationEvent[]) => void): () => void;
|
|
96
|
+
exportConversation(id: string): Promise<ConversationExport>;
|
|
97
|
+
importConversation(bundle: ConversationExport): Promise<string>;
|
|
98
|
+
private notify;
|
|
99
|
+
}
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage abstraction for conversations + events + blobs.
|
|
3
|
+
*
|
|
4
|
+
* The logician package never imports a concrete storage backend.
|
|
5
|
+
* Hosts plug one in:
|
|
6
|
+
* - Watson (browser craft) → IdbConversationStore via Dexie
|
|
7
|
+
* - Node CLI / MCP server → FileConversationStore (json-on-disk)
|
|
8
|
+
* - Tests / smoke runs / REPLs → MemoryConversationStore (this file)
|
|
9
|
+
*
|
|
10
|
+
* Same pattern as `WorkbookClient` in tool.ts — declare the surface
|
|
11
|
+
* here, let the platform provide the implementation.
|
|
12
|
+
*/
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// In-memory implementation
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
/**
|
|
17
|
+
* Volatile store. Survives the lifetime of the process, nothing more.
|
|
18
|
+
* Useful for:
|
|
19
|
+
* - tests
|
|
20
|
+
* - node CLI runs that don't care about persistence across invocations
|
|
21
|
+
* - Watson MVP before IdbConversationStore lands
|
|
22
|
+
*/
|
|
23
|
+
export class MemoryConversationStore {
|
|
24
|
+
constructor() {
|
|
25
|
+
this.conversations = new Map();
|
|
26
|
+
/** Events grouped by conversation_id, kept sorted by ts. */
|
|
27
|
+
this.events = new Map();
|
|
28
|
+
this.blobs = new Map();
|
|
29
|
+
this.subscribers = new Map();
|
|
30
|
+
this.blobCounter = 0;
|
|
31
|
+
}
|
|
32
|
+
async createConversation(meta) {
|
|
33
|
+
var _a;
|
|
34
|
+
const id = (_a = meta.id) !== null && _a !== void 0 ? _a : randomId('conv');
|
|
35
|
+
const now = nowMs();
|
|
36
|
+
const conv = {
|
|
37
|
+
...meta,
|
|
38
|
+
id,
|
|
39
|
+
created_at: now,
|
|
40
|
+
updated_at: now,
|
|
41
|
+
};
|
|
42
|
+
this.conversations.set(id, conv);
|
|
43
|
+
this.events.set(id, []);
|
|
44
|
+
return conv;
|
|
45
|
+
}
|
|
46
|
+
async getConversation(id) {
|
|
47
|
+
var _a;
|
|
48
|
+
return (_a = this.conversations.get(id)) !== null && _a !== void 0 ? _a : null;
|
|
49
|
+
}
|
|
50
|
+
async listConversations(filter) {
|
|
51
|
+
let rows = [...this.conversations.values()];
|
|
52
|
+
if ((filter === null || filter === void 0 ? void 0 : filter.workbook_id) !== undefined) {
|
|
53
|
+
rows = rows.filter((c) => c.workbook_id === filter.workbook_id);
|
|
54
|
+
}
|
|
55
|
+
rows.sort((a, b) => b.updated_at - a.updated_at);
|
|
56
|
+
if (filter === null || filter === void 0 ? void 0 : filter.limit)
|
|
57
|
+
rows = rows.slice(0, filter.limit);
|
|
58
|
+
return rows.map((c) => {
|
|
59
|
+
var _a, _b;
|
|
60
|
+
return ({
|
|
61
|
+
id: c.id,
|
|
62
|
+
title: c.title,
|
|
63
|
+
workbook_id: c.workbook_id,
|
|
64
|
+
updated_at: c.updated_at,
|
|
65
|
+
event_count: (_b = (_a = this.events.get(c.id)) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
async updateConversation(id, patch) {
|
|
70
|
+
const c = this.conversations.get(id);
|
|
71
|
+
if (!c)
|
|
72
|
+
return;
|
|
73
|
+
if (patch.title !== undefined)
|
|
74
|
+
c.title = patch.title;
|
|
75
|
+
if (patch.extra !== undefined)
|
|
76
|
+
c.extra = patch.extra;
|
|
77
|
+
c.updated_at = nowMs();
|
|
78
|
+
}
|
|
79
|
+
async deleteConversation(id) {
|
|
80
|
+
this.conversations.delete(id);
|
|
81
|
+
this.events.delete(id);
|
|
82
|
+
this.subscribers.delete(id);
|
|
83
|
+
}
|
|
84
|
+
async appendEvent(event) {
|
|
85
|
+
const bucket = this.events.get(event.conversation_id);
|
|
86
|
+
if (!bucket) {
|
|
87
|
+
throw new Error(`appendEvent: conversation not found: ${event.conversation_id}`);
|
|
88
|
+
}
|
|
89
|
+
bucket.push(event);
|
|
90
|
+
const conv = this.conversations.get(event.conversation_id);
|
|
91
|
+
if (conv)
|
|
92
|
+
conv.updated_at = event.ts;
|
|
93
|
+
this.notify(event.conversation_id, bucket);
|
|
94
|
+
}
|
|
95
|
+
async appendEvents(events) {
|
|
96
|
+
if (events.length === 0)
|
|
97
|
+
return;
|
|
98
|
+
// Group by conversation for one notify per affected conversation.
|
|
99
|
+
const touched = new Set();
|
|
100
|
+
for (const e of events) {
|
|
101
|
+
const bucket = this.events.get(e.conversation_id);
|
|
102
|
+
if (!bucket) {
|
|
103
|
+
throw new Error(`appendEvents: conversation not found: ${e.conversation_id}`);
|
|
104
|
+
}
|
|
105
|
+
bucket.push(e);
|
|
106
|
+
const conv = this.conversations.get(e.conversation_id);
|
|
107
|
+
if (conv && e.ts > conv.updated_at)
|
|
108
|
+
conv.updated_at = e.ts;
|
|
109
|
+
touched.add(e.conversation_id);
|
|
110
|
+
}
|
|
111
|
+
for (const cid of touched) {
|
|
112
|
+
this.notify(cid, this.events.get(cid));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async listEvents(conversation_id, opts) {
|
|
116
|
+
var _a;
|
|
117
|
+
const bucket = (_a = this.events.get(conversation_id)) !== null && _a !== void 0 ? _a : [];
|
|
118
|
+
let rows = bucket;
|
|
119
|
+
if ((opts === null || opts === void 0 ? void 0 : opts.since_ts) !== undefined) {
|
|
120
|
+
const since = opts.since_ts;
|
|
121
|
+
rows = rows.filter((e) => e.ts >= since);
|
|
122
|
+
}
|
|
123
|
+
if (opts === null || opts === void 0 ? void 0 : opts.limit)
|
|
124
|
+
rows = rows.slice(0, opts.limit);
|
|
125
|
+
// bucket is already in insertion order, which matches ts-ascending
|
|
126
|
+
// because appendEvent never inserts out-of-order events.
|
|
127
|
+
return rows.slice();
|
|
128
|
+
}
|
|
129
|
+
async putBlob(value) {
|
|
130
|
+
const ref = `blob_${++this.blobCounter}_${randomShort()}`;
|
|
131
|
+
this.blobs.set(ref, value);
|
|
132
|
+
return ref;
|
|
133
|
+
}
|
|
134
|
+
async getBlob(ref) {
|
|
135
|
+
var _a;
|
|
136
|
+
return (_a = this.blobs.get(ref)) !== null && _a !== void 0 ? _a : null;
|
|
137
|
+
}
|
|
138
|
+
async deleteBlob(ref) {
|
|
139
|
+
this.blobs.delete(ref);
|
|
140
|
+
}
|
|
141
|
+
subscribeEvents(conversation_id, cb) {
|
|
142
|
+
var _a;
|
|
143
|
+
let set = this.subscribers.get(conversation_id);
|
|
144
|
+
if (!set) {
|
|
145
|
+
set = new Set();
|
|
146
|
+
this.subscribers.set(conversation_id, set);
|
|
147
|
+
}
|
|
148
|
+
set.add(cb);
|
|
149
|
+
// Fire once with current snapshot.
|
|
150
|
+
cb([...((_a = this.events.get(conversation_id)) !== null && _a !== void 0 ? _a : [])]);
|
|
151
|
+
return () => {
|
|
152
|
+
set.delete(cb);
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
async exportConversation(id) {
|
|
156
|
+
var _a;
|
|
157
|
+
const conversation = this.conversations.get(id);
|
|
158
|
+
if (!conversation)
|
|
159
|
+
throw new Error(`exportConversation: not found: ${id}`);
|
|
160
|
+
const events = ((_a = this.events.get(id)) !== null && _a !== void 0 ? _a : []).slice();
|
|
161
|
+
// Inline every blob referenced by these events.
|
|
162
|
+
const blobs = {};
|
|
163
|
+
for (const e of events) {
|
|
164
|
+
if (e.kind !== 'tool_result')
|
|
165
|
+
continue;
|
|
166
|
+
const ref = extractBlobRef(e.output);
|
|
167
|
+
if (ref) {
|
|
168
|
+
const v = this.blobs.get(ref);
|
|
169
|
+
if (v !== undefined)
|
|
170
|
+
blobs[ref] = v;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { conversation, events, blobs };
|
|
174
|
+
}
|
|
175
|
+
async importConversation(bundle) {
|
|
176
|
+
const { conversation, events, blobs } = bundle;
|
|
177
|
+
this.conversations.set(conversation.id, { ...conversation });
|
|
178
|
+
this.events.set(conversation.id, [...events]);
|
|
179
|
+
for (const [ref, v] of Object.entries(blobs)) {
|
|
180
|
+
this.blobs.set(ref, v);
|
|
181
|
+
}
|
|
182
|
+
return conversation.id;
|
|
183
|
+
}
|
|
184
|
+
notify(conversation_id, snapshot) {
|
|
185
|
+
const set = this.subscribers.get(conversation_id);
|
|
186
|
+
if (!set || set.size === 0)
|
|
187
|
+
return;
|
|
188
|
+
const frozen = [...snapshot];
|
|
189
|
+
for (const cb of set) {
|
|
190
|
+
try {
|
|
191
|
+
cb(frozen);
|
|
192
|
+
}
|
|
193
|
+
catch (_a) {
|
|
194
|
+
// Subscribers shouldn't throw; swallow to protect the store.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Helpers
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
/**
|
|
203
|
+
* Recognise `{kind: 'blob_ref', ref: '...'}` payloads in tool_result.output
|
|
204
|
+
* so exportConversation can inline the referenced blob.
|
|
205
|
+
*/
|
|
206
|
+
function extractBlobRef(output) {
|
|
207
|
+
if (output &&
|
|
208
|
+
typeof output === 'object' &&
|
|
209
|
+
output.kind === 'blob_ref' &&
|
|
210
|
+
typeof output.ref === 'string') {
|
|
211
|
+
return output.ref;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
function nowMs() {
|
|
216
|
+
return Date.now();
|
|
217
|
+
}
|
|
218
|
+
function randomId(prefix) {
|
|
219
|
+
return `${prefix}_${nowMs().toString(36)}_${randomShort()}`;
|
|
220
|
+
}
|
|
221
|
+
function randomShort() {
|
|
222
|
+
return Math.random().toString(36).slice(2, 10);
|
|
223
|
+
}
|