@ixo/oracle-runtime-workers 0.2.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 +110 -0
- package/package.json +77 -0
- package/src/attachments/classify.ts +114 -0
- package/src/attachments/content-blocks.ts +51 -0
- package/src/attachments/download.ts +235 -0
- package/src/attachments/extract.ts +241 -0
- package/src/attachments/index.ts +84 -0
- package/src/attachments/magic.ts +133 -0
- package/src/attachments/pipeline.ts +409 -0
- package/src/attachments/retention.ts +205 -0
- package/src/attachments/route.ts +41 -0
- package/src/attachments/sandbox-archive.ts +90 -0
- package/src/attachments/sanitize.ts +35 -0
- package/src/attachments/types.ts +86 -0
- package/src/attachments/view.ts +107 -0
- package/src/core/env.ts +200 -0
- package/src/core/index.ts +513 -0
- package/src/core/llm.ts +842 -0
- package/src/core/main-agent-types.ts +123 -0
- package/src/core/main-agent.ts +399 -0
- package/src/core/manifest.ts +365 -0
- package/src/core/meta-tools.ts +232 -0
- package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
- package/src/core/middlewares/capability-gate.ts +76 -0
- package/src/core/middlewares/index.ts +18 -0
- package/src/core/middlewares/page-context.ts +98 -0
- package/src/core/middlewares/safety-guardrail.ts +117 -0
- package/src/core/middlewares/summarization.ts +99 -0
- package/src/core/middlewares/tool-repetition-guard.ts +125 -0
- package/src/core/middlewares/tool-validation.ts +83 -0
- package/src/core/middlewares/work-status.ts +57 -0
- package/src/core/openrouter-pricing.ts +124 -0
- package/src/core/plugin-loader.ts +192 -0
- package/src/core/plugins/index.ts +6 -0
- package/src/core/plugins/skills/index.ts +8 -0
- package/src/core/plugins/skills/skills-tools.ts +356 -0
- package/src/core/plugins/skills/skills.plugin.ts +113 -0
- package/src/core/plugins/weather/index.ts +8 -0
- package/src/core/plugins/weather/weather-client.ts +153 -0
- package/src/core/plugins/weather/weather-middleware.ts +27 -0
- package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
- package/src/core/plugins/weather/weather-tools.ts +103 -0
- package/src/core/plugins/weather/weather.plugin.ts +227 -0
- package/src/core/prompt-composer.ts +469 -0
- package/src/core/registries.ts +642 -0
- package/src/core/runtime-context.ts +619 -0
- package/src/core/state.ts +108 -0
- package/src/core/sub-agent-fallback.ts +187 -0
- package/src/core/subagent-as-tool.ts +322 -0
- package/src/core/template.ts +245 -0
- package/src/core/utils.ts +246 -0
- package/src/core/wrap-plugin-tool.ts +100 -0
- package/src/do/ambient.ts +266 -0
- package/src/do/contracts.ts +562 -0
- package/src/do/debug-timers.ts +120 -0
- package/src/do/fresh-stub.ts +32 -0
- package/src/do/gateway-retry.ts +73 -0
- package/src/do/secrets-adapter.ts +32 -0
- package/src/do/size-alerts.ts +76 -0
- package/src/do/sse-stream.ts +472 -0
- package/src/do/transcript.ts +192 -0
- package/src/do/turn-body.ts +84 -0
- package/src/do/turn-metadata.ts +146 -0
- package/src/do/ucan-service.ts +339 -0
- package/src/do/user-oracle-do.ts +3035 -0
- package/src/gateway-worker.ts +82 -0
- package/src/index.ts +237 -0
- package/src/llm/byo-adapter.ts +59 -0
- package/src/llm/byo-catalog.ts +520 -0
- package/src/llm/byo-client.ts +280 -0
- package/src/llm/byo-routes.ts +255 -0
- package/src/llm/byo-service.ts +836 -0
- package/src/llm/chatgpt-oauth.ts +430 -0
- package/src/llm/provider-error.ts +283 -0
- package/src/matrix/gateway-do.ts +918 -0
- package/src/matrix/ingest.ts +209 -0
- package/src/matrix/replay-format.ts +34 -0
- package/src/matrix/reply-chain.ts +109 -0
- package/src/matrix/room-state-codec.ts +115 -0
- package/src/matrix/user-homeserver.ts +106 -0
- package/src/matrix/work-status.ts +294 -0
- package/src/memory/session-history-indexer.ts +328 -0
- package/src/owner-store/ixo-vfs-store.ts +570 -0
- package/src/owner-store/matrix-media-store.ts +136 -0
- package/src/owner-store/migrating-store.ts +185 -0
- package/src/owner-store/owner-copy-errors.ts +175 -0
- package/src/owner-store/retry.ts +45 -0
- package/src/owner-store/tus-upload.ts +333 -0
- package/src/owner-store/types.ts +0 -0
- package/src/plugin-api/define-plugin.ts +45 -0
- package/src/plugin-api/index.ts +56 -0
- package/src/plugin-api/oracle-plugin.ts +145 -0
- package/src/plugin-api/tool-helper.ts +82 -0
- package/src/plugin-api/types.ts +816 -0
- package/src/plugin-api/ucan-errors.ts +16 -0
- package/src/plugins/agui/agui-agent.ts +115 -0
- package/src/plugins/agui/agui.plugin.ts +148 -0
- package/src/plugins/agui/index.ts +2 -0
- package/src/plugins/attachments/attachments.plugin.ts +54 -0
- package/src/plugins/attachments/index.ts +6 -0
- package/src/plugins/attachments/view-attachment-tool.ts +91 -0
- package/src/plugins/base64.ts +26 -0
- package/src/plugins/composio/composio-tools.ts +365 -0
- package/src/plugins/composio/composio-ucan.ts +43 -0
- package/src/plugins/composio/composio.plugin.ts +214 -0
- package/src/plugins/composio/index.ts +15 -0
- package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
- package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
- package/src/plugins/domain-indexer/index.ts +3 -0
- package/src/plugins/editor/blocknote-bridge.ts +120 -0
- package/src/plugins/editor/content-session.ts +331 -0
- package/src/plugins/editor/content-tools.ts +686 -0
- package/src/plugins/editor/document-model.ts +0 -0
- package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
- package/src/plugins/editor/editor-agent.ts +34 -0
- package/src/plugins/editor/editor-config.ts +64 -0
- package/src/plugins/editor/editor-mx.ts +128 -0
- package/src/plugins/editor/editor.plugin.ts +143 -0
- package/src/plugins/editor/failures.ts +154 -0
- package/src/plugins/editor/index.ts +31 -0
- package/src/plugins/editor/jsdom-shim.ts +55 -0
- package/src/plugins/editor/prompts.ts +59 -0
- package/src/plugins/editor/prop-policy.ts +147 -0
- package/src/plugins/editor/provider.ts +386 -0
- package/src/plugins/editor/room-membership.ts +95 -0
- package/src/plugins/editor/standalone-editor-tool.ts +200 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
- package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
- package/src/plugins/firecrawl/index.ts +15 -0
- package/src/plugins/flows/action-metadata.ts +3505 -0
- package/src/plugins/flows/actions.ts +190 -0
- package/src/plugins/flows/edit.ts +314 -0
- package/src/plugins/flows/errors.ts +38 -0
- package/src/plugins/flows/explain.ts +43 -0
- package/src/plugins/flows/flow-doc.ts +111 -0
- package/src/plugins/flows/flows.plugin.ts +129 -0
- package/src/plugins/flows/forms.ts +330 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/linkage.ts +147 -0
- package/src/plugins/flows/port-types.ts +53 -0
- package/src/plugins/flows/prompts.ts +65 -0
- package/src/plugins/flows/read.ts +392 -0
- package/src/plugins/flows/references.ts +66 -0
- package/src/plugins/flows/test-support.ts +165 -0
- package/src/plugins/flows/tools/authoring.ts +516 -0
- package/src/plugins/flows/tools/discovery.ts +119 -0
- package/src/plugins/flows/tools/forms.ts +142 -0
- package/src/plugins/flows/tools/inspect.ts +165 -0
- package/src/plugins/flows/tools/linkage.ts +117 -0
- package/src/plugins/flows/tools/settings.ts +184 -0
- package/src/plugins/flows/translator.ts +328 -0
- package/src/plugins/flows/types.ts +210 -0
- package/src/plugins/flows/ydoc-helpers.ts +543 -0
- package/src/plugins/index.ts +70 -0
- package/src/plugins/mcp-call-timeout.ts +38 -0
- package/src/plugins/mcp-tool-adapter.ts +87 -0
- package/src/plugins/memory/index.ts +17 -0
- package/src/plugins/memory/memory-tools.ts +463 -0
- package/src/plugins/memory/memory-ucan.ts +51 -0
- package/src/plugins/memory/memory.plugin.ts +122 -0
- package/src/plugins/memory/types.ts +147 -0
- package/src/plugins/portal/action-log.ts +34 -0
- package/src/plugins/portal/index.ts +2 -0
- package/src/plugins/portal/portal.plugin.ts +203 -0
- package/src/plugins/sandbox/index.ts +34 -0
- package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
- package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
- package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
- package/src/plugins/tasks/index.ts +17 -0
- package/src/plugins/tasks/manifest.ts +81 -0
- package/src/plugins/tasks/middleware.ts +219 -0
- package/src/plugins/tasks/tasks-tools.ts +414 -0
- package/src/plugins/tasks/tasks.plugin.ts +74 -0
- package/src/plugins/ucan-failure.ts +133 -0
- package/src/plugins/user-preferences/index.ts +22 -0
- package/src/plugins/user-preferences/schema.ts +31 -0
- package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
- package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
- package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
- package/src/plugins/vfs/index.ts +40 -0
- package/src/plugins/vfs/vfs-auth.ts +56 -0
- package/src/plugins/vfs/vfs-client.ts +655 -0
- package/src/plugins/vfs/vfs-content.ts +181 -0
- package/src/plugins/vfs/vfs-errors.ts +163 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
- package/src/plugins/vfs/vfs-tools.ts +635 -0
- package/src/plugins/vfs/vfs.plugin.ts +201 -0
- package/src/realtime/frontend-call-registry.ts +163 -0
- package/src/realtime/realtime-endpoint.ts +619 -0
- package/src/realtime/session-socket-hub.ts +249 -0
- package/src/realtime/socket-io-codec.ts +187 -0
- package/src/secrets/jwe.ts +74 -0
- package/src/secrets/pin-cipher.ts +43 -0
- package/src/secrets/secrets-service.ts +319 -0
- package/src/shell/app.ts +564 -0
- package/src/shell/auth.ts +290 -0
- package/src/sqlite/blob-codec.ts +100 -0
- package/src/sqlite/blob-compactor.ts +146 -0
- package/src/sqlite/cache-config.ts +45 -0
- package/src/sqlite/database.ts +643 -0
- package/src/sqlite/do-vfs.ts +1485 -0
- package/src/sqlite/index.ts +61 -0
- package/src/sqlite/serialization.ts +370 -0
- package/src/sqlite/sessions-store.ts +311 -0
- package/src/sqlite/sqlite-saver.ts +919 -0
- package/src/sqlite/vacuum-policy.ts +77 -0
- package/src/sqlite/wa-sqlite-loader.ts +123 -0
- package/src/sqlite/wasm.d.ts +10 -0
- package/src/tasks/schedule.ts +174 -0
- package/src/tasks/scheduler.ts +753 -0
- package/src/tasks/spec.ts +125 -0
- package/src/tasks/store.ts +351 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-turn `work_status` liveness card — the port of the Node runtime's
|
|
3
|
+
* `matrix/work-status-producer.ts` + `matrix/oracle-component-event.ts`.
|
|
4
|
+
*
|
|
5
|
+
* A Matrix turn posts ONE `ixo.oracle.component` event (`component:
|
|
6
|
+
* 'work_status'`) into the user's thread when it starts, then edits it in
|
|
7
|
+
* place (`m.replace`) as the agent moves through phases:
|
|
8
|
+
* routing → working (one "Step n · <action>" per model/tool call) →
|
|
9
|
+
* delivering → done, or superseded when a newer message restarts the turn.
|
|
10
|
+
* Clients that render oracle components show a live card; everything else
|
|
11
|
+
* sees the plain `body` fallback ("Status: Working…").
|
|
12
|
+
*
|
|
13
|
+
* Frames are coalesced per turn: anything staged behind an in-flight post is
|
|
14
|
+
* stale by the time the homeserver would accept it, so the newest frame
|
|
15
|
+
* replaces it — which also guarantees a terminal phase is never dropped.
|
|
16
|
+
* Fire-and-forget throughout: a failed post is logged, never thrown.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const ORACLE_COMPONENT_EVENT_TYPE = 'ixo.oracle.component';
|
|
20
|
+
|
|
21
|
+
export type OracleComponentName =
|
|
22
|
+
| 'list_services'
|
|
23
|
+
| 'show_contract'
|
|
24
|
+
| 'work_status'
|
|
25
|
+
| 'work_delivered'
|
|
26
|
+
| 'payment_update';
|
|
27
|
+
|
|
28
|
+
export interface OracleComponentThreadRelation {
|
|
29
|
+
rel_type: 'm.thread';
|
|
30
|
+
event_id: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface OracleComponentReplaceRelation {
|
|
34
|
+
rel_type: 'm.replace';
|
|
35
|
+
event_id: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface OracleComponentEnvelope {
|
|
39
|
+
component: OracleComponentName;
|
|
40
|
+
props: Record<string, unknown>;
|
|
41
|
+
body: string;
|
|
42
|
+
sessionId: string;
|
|
43
|
+
requestId: string;
|
|
44
|
+
toolCallId?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface OracleComponentEventContent extends OracleComponentEnvelope {
|
|
48
|
+
'm.new_content'?: OracleComponentEnvelope;
|
|
49
|
+
'm.relates_to'?:
|
|
50
|
+
| OracleComponentThreadRelation
|
|
51
|
+
| OracleComponentReplaceRelation;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface OracleComponentInput {
|
|
55
|
+
component: OracleComponentName;
|
|
56
|
+
props: Record<string, unknown>;
|
|
57
|
+
body: string;
|
|
58
|
+
sessionId: string;
|
|
59
|
+
requestId: string;
|
|
60
|
+
toolCallId?: string;
|
|
61
|
+
threadId?: string;
|
|
62
|
+
replacesEventId?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Byte-compatible with the Node runtime's `buildOracleComponentContent`. */
|
|
66
|
+
export function buildOracleComponentContent(
|
|
67
|
+
input: OracleComponentInput,
|
|
68
|
+
): OracleComponentEventContent {
|
|
69
|
+
const { threadId, replacesEventId, toolCallId, ...rest } = input;
|
|
70
|
+
const envelope: OracleComponentEnvelope = {
|
|
71
|
+
...rest,
|
|
72
|
+
...(toolCallId !== undefined && { toolCallId }),
|
|
73
|
+
};
|
|
74
|
+
const relation: OracleComponentEventContent['m.relates_to'] | undefined =
|
|
75
|
+
replacesEventId !== undefined
|
|
76
|
+
? { rel_type: 'm.replace', event_id: replacesEventId }
|
|
77
|
+
: threadId !== undefined
|
|
78
|
+
? { rel_type: 'm.thread', event_id: threadId }
|
|
79
|
+
: undefined;
|
|
80
|
+
return {
|
|
81
|
+
...envelope,
|
|
82
|
+
...(replacesEventId !== undefined && { 'm.new_content': envelope }),
|
|
83
|
+
...(relation !== undefined && { 'm.relates_to': relation }),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type WorkStatusPhase =
|
|
88
|
+
| 'routing'
|
|
89
|
+
| 'working'
|
|
90
|
+
| 'delivering'
|
|
91
|
+
| 'done'
|
|
92
|
+
| 'superseded';
|
|
93
|
+
|
|
94
|
+
const ANCHOR_RETRY_DELAY_MS = 1000;
|
|
95
|
+
|
|
96
|
+
const ANCHOR_PHASES: ReadonlySet<WorkStatusPhase> = new Set([
|
|
97
|
+
'routing',
|
|
98
|
+
'working',
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
const DEFAULT_LABELS: Record<WorkStatusPhase, string> = {
|
|
102
|
+
routing: 'Routing your request…',
|
|
103
|
+
working: 'Working…',
|
|
104
|
+
delivering: 'Sending your reply…',
|
|
105
|
+
done: 'Done',
|
|
106
|
+
superseded: 'Got your new message — restarting',
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export interface WorkStatusTurn {
|
|
110
|
+
requestId: string;
|
|
111
|
+
roomId: string;
|
|
112
|
+
/** Thread the card is posted into (the user's message thread root). */
|
|
113
|
+
threadId: string;
|
|
114
|
+
sessionId: string;
|
|
115
|
+
/** The user's message event the card reports on. */
|
|
116
|
+
forEventId: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface StatusFrame {
|
|
120
|
+
phase: WorkStatusPhase;
|
|
121
|
+
label?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface TurnEntry extends WorkStatusTurn {
|
|
125
|
+
anchorEventId?: string;
|
|
126
|
+
steps: number;
|
|
127
|
+
pending?: StatusFrame;
|
|
128
|
+
draining: boolean;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface WorkStatusProducerDeps {
|
|
132
|
+
/** Posts a room event and resolves its event id (the gateway's `sendEvent`). */
|
|
133
|
+
postEvent: (
|
|
134
|
+
roomId: string,
|
|
135
|
+
eventType: string,
|
|
136
|
+
content: object,
|
|
137
|
+
) => Promise<string>;
|
|
138
|
+
clock?: () => Date;
|
|
139
|
+
logger?: { warn: (message: string) => void };
|
|
140
|
+
/** Delay before the single retry of a failed anchor post (tests: 0). */
|
|
141
|
+
anchorRetryDelayMs?: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export class WorkStatusProducer {
|
|
145
|
+
private readonly turns = new Map<string, TurnEntry>();
|
|
146
|
+
|
|
147
|
+
private readonly postEvent: WorkStatusProducerDeps['postEvent'];
|
|
148
|
+
|
|
149
|
+
private readonly clock: () => Date;
|
|
150
|
+
|
|
151
|
+
private readonly logger: { warn: (message: string) => void };
|
|
152
|
+
|
|
153
|
+
private readonly anchorRetryDelayMs: number;
|
|
154
|
+
|
|
155
|
+
constructor(deps: WorkStatusProducerDeps) {
|
|
156
|
+
this.postEvent = deps.postEvent;
|
|
157
|
+
this.clock = deps.clock ?? (() => new Date());
|
|
158
|
+
this.logger = deps.logger ?? { warn: () => undefined };
|
|
159
|
+
this.anchorRetryDelayMs = deps.anchorRetryDelayMs ?? ANCHOR_RETRY_DELAY_MS;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Register a turn so later `emit`/`step`/`finish` calls have a card to drive. */
|
|
163
|
+
beginTurn(turn: WorkStatusTurn): void {
|
|
164
|
+
this.turns.set(turn.requestId, { ...turn, steps: 0, draining: false });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Post a status phase for a registered turn. The first anchor-capable phase
|
|
169
|
+
* (`routing`/`working`) creates the card; every later phase updates it via
|
|
170
|
+
* `m.replace`. Unregistered requestIds — and closing phases with no card to
|
|
171
|
+
* close — are no-ops.
|
|
172
|
+
*/
|
|
173
|
+
emit(requestId: string, phase: WorkStatusPhase, label?: string): void {
|
|
174
|
+
const entry = this.turns.get(requestId);
|
|
175
|
+
if (!entry) return;
|
|
176
|
+
this.enqueue(entry, phase, label);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Post the next agent step: bumps the turn's counter and emits `working`
|
|
181
|
+
* with a `Step n · <action>` label. The counter can outrun the numbers the
|
|
182
|
+
* room actually sees (coalesced frames are skipped, not renumbered) so the
|
|
183
|
+
* line always moves forward.
|
|
184
|
+
*/
|
|
185
|
+
step(requestId: string, action: string): void {
|
|
186
|
+
const entry = this.turns.get(requestId);
|
|
187
|
+
if (!entry) return;
|
|
188
|
+
entry.steps += 1;
|
|
189
|
+
this.enqueue(entry, 'working', `Step ${entry.steps} · ${action}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Post a final phase (`done`/`superseded`) and unregister the turn. Later
|
|
194
|
+
* emissions for this requestId become no-ops immediately.
|
|
195
|
+
*/
|
|
196
|
+
finish(requestId: string, phase: WorkStatusPhase, label?: string): void {
|
|
197
|
+
const entry = this.turns.get(requestId);
|
|
198
|
+
if (!entry) return;
|
|
199
|
+
this.turns.delete(requestId);
|
|
200
|
+
this.enqueue(entry, phase, label);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async postWithRetry(
|
|
204
|
+
roomId: string,
|
|
205
|
+
content: object,
|
|
206
|
+
retryOnce: boolean,
|
|
207
|
+
): Promise<string> {
|
|
208
|
+
try {
|
|
209
|
+
return await this.postEvent(roomId, ORACLE_COMPONENT_EVENT_TYPE, content);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (!retryOnce) throw error;
|
|
212
|
+
await new Promise((resolve) =>
|
|
213
|
+
setTimeout(resolve, this.anchorRetryDelayMs),
|
|
214
|
+
);
|
|
215
|
+
return this.postEvent(roomId, ORACLE_COMPONENT_EVENT_TYPE, content);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Unregister a turn without posting (error paths, turns with no card). */
|
|
220
|
+
endTurn(requestId: string): void {
|
|
221
|
+
this.turns.delete(requestId);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Whether a turn is registered (tests + the ingest supersede path). */
|
|
225
|
+
has(requestId: string): boolean {
|
|
226
|
+
return this.turns.has(requestId);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private enqueue(
|
|
230
|
+
entry: TurnEntry,
|
|
231
|
+
phase: WorkStatusPhase,
|
|
232
|
+
label?: string,
|
|
233
|
+
): void {
|
|
234
|
+
entry.pending = { phase, label };
|
|
235
|
+
if (entry.draining) return;
|
|
236
|
+
entry.draining = true;
|
|
237
|
+
void this.drain(entry);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async drain(entry: TurnEntry): Promise<void> {
|
|
241
|
+
try {
|
|
242
|
+
while (entry.pending) {
|
|
243
|
+
const frame = entry.pending;
|
|
244
|
+
entry.pending = undefined;
|
|
245
|
+
await this.post(entry, frame);
|
|
246
|
+
}
|
|
247
|
+
} finally {
|
|
248
|
+
entry.draining = false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private async post(entry: TurnEntry, frame: StatusFrame): Promise<void> {
|
|
253
|
+
const { phase, label } = frame;
|
|
254
|
+
if (!entry.anchorEventId && !ANCHOR_PHASES.has(phase)) return;
|
|
255
|
+
const resolvedLabel = label ?? DEFAULT_LABELS[phase];
|
|
256
|
+
try {
|
|
257
|
+
const content = buildOracleComponentContent({
|
|
258
|
+
component: 'work_status',
|
|
259
|
+
props: {
|
|
260
|
+
forEventId: entry.forEventId,
|
|
261
|
+
phase,
|
|
262
|
+
label: resolvedLabel,
|
|
263
|
+
updatedAt: this.clock().toISOString(),
|
|
264
|
+
},
|
|
265
|
+
body: `Status: ${resolvedLabel}`,
|
|
266
|
+
sessionId: entry.sessionId,
|
|
267
|
+
requestId: entry.requestId,
|
|
268
|
+
...(entry.anchorEventId
|
|
269
|
+
? { replacesEventId: entry.anchorEventId }
|
|
270
|
+
: { threadId: entry.threadId }),
|
|
271
|
+
});
|
|
272
|
+
const eventId = await this.postWithRetry(
|
|
273
|
+
entry.roomId,
|
|
274
|
+
content,
|
|
275
|
+
// The anchor decides whether the turn gets a card at all: retry it.
|
|
276
|
+
!entry.anchorEventId,
|
|
277
|
+
);
|
|
278
|
+
if (!entry.anchorEventId) entry.anchorEventId = eventId;
|
|
279
|
+
} catch (error) {
|
|
280
|
+
this.logger.warn(
|
|
281
|
+
`work_status post failed (room=${entry.roomId}, phase=${phase}): ${
|
|
282
|
+
error instanceof Error ? error.message : String(error)
|
|
283
|
+
}`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** `generate_tax_report` → `Generate tax report…`. */
|
|
290
|
+
export function humanizeToolLabel(toolName: string): string {
|
|
291
|
+
const words = toolName.replace(/[_-]+/g, ' ').trim();
|
|
292
|
+
if (words.length === 0) return DEFAULT_LABELS.working;
|
|
293
|
+
return `${words.charAt(0).toUpperCase()}${words.slice(1)}…`;
|
|
294
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-history → memory-engine indexing — the Workers port of the Node
|
|
3
|
+
* runtime's `SessionHistoryProcessor` (`modules/sessions/session-history-
|
|
4
|
+
* processor.service.ts`).
|
|
5
|
+
*
|
|
6
|
+
* Policy (identical to Node):
|
|
7
|
+
* - Runs in the background when a session is CREATED (indexes the most
|
|
8
|
+
* recent previous session) and when a session is DELETED (indexes it
|
|
9
|
+
* before it goes). The request never waits for, or fails on, indexing.
|
|
10
|
+
* - Only messages after the session's `lastProcessedCount` are sent, then
|
|
11
|
+
* the count is advanced — a session is never indexed twice.
|
|
12
|
+
* - Speakers get real identities (the user's preferred name / the oracle's
|
|
13
|
+
* preferred or configured name) so Graphiti's extractor does not pin
|
|
14
|
+
* facts to literal "user"/"assistant" nodes. Tool replies fold into the
|
|
15
|
+
* assistant role (the engine has no tool role).
|
|
16
|
+
* - Auth is the oracle's two-hop UCAN invocation minted from the user's
|
|
17
|
+
* delegation (`ixo:memory` / `memory/*`) — no delegation, no indexing.
|
|
18
|
+
* - Endpoint: `POST <MEMORY_ENGINE_URL>/messages` with `x-room-id`, as
|
|
19
|
+
* `@ixo/common`'s `MemoryEngineService.processConversationHistory`.
|
|
20
|
+
* - A per-session lock prevents overlapping runs; a failed upload is
|
|
21
|
+
* retried 3× with a 10 s pause (Node's numbers).
|
|
22
|
+
*
|
|
23
|
+
* Not ported: Node also asks Matrix for the user's display name when no
|
|
24
|
+
* preferred name is set; here the fallback is the literal "Me" straight away.
|
|
25
|
+
*/
|
|
26
|
+
import type { Logger } from '../plugin-api/types';
|
|
27
|
+
|
|
28
|
+
export interface HistoryMessage {
|
|
29
|
+
/** LangChain message type: `human` | `ai` | `tool` | `system`. */
|
|
30
|
+
type: string;
|
|
31
|
+
content: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MemoryEngineMessage {
|
|
35
|
+
content: string;
|
|
36
|
+
role_type: 'user' | 'assistant' | 'system';
|
|
37
|
+
role?: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
source_description?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface IndexableSession {
|
|
43
|
+
title?: string;
|
|
44
|
+
roomId?: string;
|
|
45
|
+
lastProcessedCount?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SpeakerLabels {
|
|
49
|
+
user: string;
|
|
50
|
+
oracle: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SessionHistoryIndexerDeps {
|
|
54
|
+
/** `MEMORY_ENGINE_URL`; undefined disables indexing (memory plugin absent). */
|
|
55
|
+
memoryEngineUrl: string | undefined;
|
|
56
|
+
getSession(sessionId: string): Promise<IndexableSession | undefined>;
|
|
57
|
+
/** The session transcript, summarisation bookkeeping already removed. */
|
|
58
|
+
listMessages(sessionId: string): Promise<HistoryMessage[]>;
|
|
59
|
+
setProcessedCount(sessionId: string, count: number): Promise<void>;
|
|
60
|
+
/** The user↔oracle room, for sessions whose row carries none. */
|
|
61
|
+
resolveUserRoom(): Promise<{ roomId: string } | null>;
|
|
62
|
+
ucan: {
|
|
63
|
+
hasSigningKey(): boolean;
|
|
64
|
+
resolveServiceDid(serviceUrl: string): Promise<string | null>;
|
|
65
|
+
mintInvocation(
|
|
66
|
+
target: { did: string; capability: string },
|
|
67
|
+
opts?: { can?: string },
|
|
68
|
+
): Promise<string>;
|
|
69
|
+
};
|
|
70
|
+
speakerLabels(roomId: string): Promise<SpeakerLabels>;
|
|
71
|
+
logger: Logger;
|
|
72
|
+
fetchImpl?: typeof fetch;
|
|
73
|
+
/** Test hooks. */
|
|
74
|
+
sleep?: (ms: number) => Promise<void>;
|
|
75
|
+
maxRetries?: number;
|
|
76
|
+
retryDelayMs?: number;
|
|
77
|
+
/** Hard timeout for the memory-engine request (default 20 s). */
|
|
78
|
+
requestTimeoutMs?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type IndexOutcome = 'processed' | 'skipped' | 'failed';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Bounded background work: the indexer runs under `ctx.waitUntil`, so the
|
|
85
|
+
* whole retry envelope keeps the user object resident (and un-hibernatable
|
|
86
|
+
* with sockets attached). Two attempts, a short pause, and a hard timeout on
|
|
87
|
+
* the memory-engine request keep that under a minute; a failed session is
|
|
88
|
+
* simply retried on the next session create (the watermark did not move).
|
|
89
|
+
*/
|
|
90
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
91
|
+
const DEFAULT_RETRY_DELAY_MS = 3_000;
|
|
92
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 20_000;
|
|
93
|
+
|
|
94
|
+
/** Node's `transformMessagesToMemoryEngineFormat`, verbatim in mapping. */
|
|
95
|
+
export function transformMessagesToMemoryEngineFormat(
|
|
96
|
+
messages: readonly HistoryMessage[],
|
|
97
|
+
sessionTitle: string,
|
|
98
|
+
userSpeakerLabel: string,
|
|
99
|
+
oracleSpeakerLabel: string,
|
|
100
|
+
): MemoryEngineMessage[] {
|
|
101
|
+
return messages.map((message) => {
|
|
102
|
+
let role_type: MemoryEngineMessage['role_type'];
|
|
103
|
+
let label: string;
|
|
104
|
+
switch (message.type) {
|
|
105
|
+
case 'human':
|
|
106
|
+
role_type = 'user';
|
|
107
|
+
label = userSpeakerLabel;
|
|
108
|
+
break;
|
|
109
|
+
case 'ai':
|
|
110
|
+
role_type = 'assistant';
|
|
111
|
+
label = oracleSpeakerLabel;
|
|
112
|
+
break;
|
|
113
|
+
case 'system':
|
|
114
|
+
role_type = 'system';
|
|
115
|
+
label = 'System';
|
|
116
|
+
break;
|
|
117
|
+
case 'tool':
|
|
118
|
+
// The engine has no tool role — tool replies are the oracle
|
|
119
|
+
// reporting back, so they share the oracle speaker label.
|
|
120
|
+
role_type = 'assistant';
|
|
121
|
+
label = oracleSpeakerLabel;
|
|
122
|
+
break;
|
|
123
|
+
default:
|
|
124
|
+
role_type = 'user';
|
|
125
|
+
label = userSpeakerLabel;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
content: message.content,
|
|
129
|
+
role_type,
|
|
130
|
+
role: label,
|
|
131
|
+
name: label,
|
|
132
|
+
source_description: `Chat Session: ${sessionTitle}`,
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function describe(err: unknown): string {
|
|
138
|
+
return err instanceof Error ? err.message : String(err);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export class SessionHistoryIndexer {
|
|
142
|
+
private readonly inFlight = new Set<string>();
|
|
143
|
+
|
|
144
|
+
private readonly fetchImpl: typeof fetch;
|
|
145
|
+
|
|
146
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
147
|
+
|
|
148
|
+
private readonly maxRetries: number;
|
|
149
|
+
|
|
150
|
+
private readonly retryDelayMs: number;
|
|
151
|
+
|
|
152
|
+
private readonly requestTimeoutMs: number;
|
|
153
|
+
|
|
154
|
+
constructor(private readonly deps: SessionHistoryIndexerDeps) {
|
|
155
|
+
this.fetchImpl = deps.fetchImpl ?? fetch.bind(globalThis);
|
|
156
|
+
this.sleep =
|
|
157
|
+
deps.sleep ??
|
|
158
|
+
((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
159
|
+
this.maxRetries = deps.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
160
|
+
this.retryDelayMs = deps.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
161
|
+
this.requestTimeoutMs = deps.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Sessions currently being indexed (background work keeping the object busy). */
|
|
165
|
+
get inFlightCount(): number {
|
|
166
|
+
return this.inFlight.size;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Whether indexing is configured at all (memory engine URL present). */
|
|
170
|
+
get enabled(): boolean {
|
|
171
|
+
return typeof this.deps.memoryEngineUrl === 'string';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Index a session's unprocessed messages. Never throws: outcomes are
|
|
176
|
+
* `processed`, `skipped` (nothing to do / prerequisites missing) or
|
|
177
|
+
* `failed` (upload failed after retries). Concurrent calls for the same
|
|
178
|
+
* session collapse into one.
|
|
179
|
+
*/
|
|
180
|
+
async process(sessionId: string): Promise<IndexOutcome> {
|
|
181
|
+
const { logger } = this.deps;
|
|
182
|
+
if (!this.enabled) {
|
|
183
|
+
logger.log(
|
|
184
|
+
`[history-indexer] MEMORY_ENGINE_URL not configured; not indexing session ${sessionId}`,
|
|
185
|
+
);
|
|
186
|
+
return 'skipped';
|
|
187
|
+
}
|
|
188
|
+
if (this.inFlight.has(sessionId)) {
|
|
189
|
+
logger.log(
|
|
190
|
+
`[history-indexer] session ${sessionId} is already being indexed, skipping`,
|
|
191
|
+
);
|
|
192
|
+
return 'skipped';
|
|
193
|
+
}
|
|
194
|
+
this.inFlight.add(sessionId);
|
|
195
|
+
try {
|
|
196
|
+
for (let attempt = 1; attempt <= this.maxRetries; attempt += 1) {
|
|
197
|
+
try {
|
|
198
|
+
const outcome = await this.processOnce(sessionId);
|
|
199
|
+
if (outcome === 'processed') {
|
|
200
|
+
logger.log(
|
|
201
|
+
`[history-indexer] indexed session ${sessionId} into the memory engine`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return outcome;
|
|
205
|
+
} catch (err) {
|
|
206
|
+
logger.warn(
|
|
207
|
+
`[history-indexer] attempt ${attempt}/${this.maxRetries} failed for session ${sessionId}: ${describe(err)}`,
|
|
208
|
+
);
|
|
209
|
+
if (attempt === this.maxRetries) {
|
|
210
|
+
logger.error(
|
|
211
|
+
`[history-indexer] giving up on session ${sessionId} after ${this.maxRetries} attempts`,
|
|
212
|
+
);
|
|
213
|
+
return 'failed';
|
|
214
|
+
}
|
|
215
|
+
await this.sleep(this.retryDelayMs);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return 'failed';
|
|
219
|
+
} finally {
|
|
220
|
+
this.inFlight.delete(sessionId);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private async processOnce(sessionId: string): Promise<IndexOutcome> {
|
|
225
|
+
const { deps } = this;
|
|
226
|
+
const { logger } = deps;
|
|
227
|
+
const memoryEngineUrl = deps.memoryEngineUrl!.replace(/\/+$/, '');
|
|
228
|
+
|
|
229
|
+
const session = await deps.getSession(sessionId);
|
|
230
|
+
if (!session) {
|
|
231
|
+
logger.warn(`[history-indexer] session ${sessionId} not found, skipping`);
|
|
232
|
+
return 'skipped';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const roomId =
|
|
236
|
+
session.roomId ?? (await deps.resolveUserRoom())?.roomId ?? null;
|
|
237
|
+
if (!roomId) {
|
|
238
|
+
logger.warn(
|
|
239
|
+
`[history-indexer] no oracle room for session ${sessionId}, skipping`,
|
|
240
|
+
);
|
|
241
|
+
return 'skipped';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const messages = await deps.listMessages(sessionId);
|
|
245
|
+
if (messages.length === 0) {
|
|
246
|
+
logger.log(`[history-indexer] no messages in session ${sessionId}`);
|
|
247
|
+
return 'skipped';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const lastProcessedCount = session.lastProcessedCount ?? 0;
|
|
251
|
+
const newMessages = messages.slice(lastProcessedCount);
|
|
252
|
+
if (newMessages.length === 0) {
|
|
253
|
+
logger.log(
|
|
254
|
+
`[history-indexer] no new messages for session ${sessionId} (lastProcessedCount: ${lastProcessedCount})`,
|
|
255
|
+
);
|
|
256
|
+
return 'skipped';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const labels = await deps.speakerLabels(roomId);
|
|
260
|
+
const transformed = transformMessagesToMemoryEngineFormat(
|
|
261
|
+
// Tool-call-only AI turns carry no text; the engine has nothing to
|
|
262
|
+
// extract from them and rejects empty episodes.
|
|
263
|
+
newMessages.filter((m) => m.content.trim().length > 0),
|
|
264
|
+
session.title ?? '',
|
|
265
|
+
labels.user,
|
|
266
|
+
labels.oracle,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
if (!deps.ucan.hasSigningKey()) {
|
|
270
|
+
logger.warn(
|
|
271
|
+
`[history-indexer] no UCAN signing key; skipping memory engine processing for session ${sessionId}`,
|
|
272
|
+
);
|
|
273
|
+
return 'skipped';
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Node: a failed mint is a warn + skip, not a retry — the user simply has
|
|
277
|
+
// not delegated `ixo:memory` to this oracle (yet).
|
|
278
|
+
let invocation: string | null = null;
|
|
279
|
+
try {
|
|
280
|
+
const memoryDid = await deps.ucan.resolveServiceDid(memoryEngineUrl);
|
|
281
|
+
if (memoryDid) {
|
|
282
|
+
invocation = await deps.ucan.mintInvocation(
|
|
283
|
+
{ did: memoryDid, capability: 'ixo:memory' },
|
|
284
|
+
// Claim the ability the user's delegation actually grants. A `'*'`
|
|
285
|
+
// claim is satisfiable only by a `'*'` grant.
|
|
286
|
+
{ can: 'memory/*' },
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
} catch (err) {
|
|
290
|
+
logger.warn(
|
|
291
|
+
`[history-indexer] could not mint a memory engine invocation: ${describe(err)}`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (!invocation) {
|
|
295
|
+
logger.warn(
|
|
296
|
+
`[history-indexer] no memory engine invocation for session ${sessionId}; skipping`,
|
|
297
|
+
);
|
|
298
|
+
return 'skipped';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (transformed.length > 0) {
|
|
302
|
+
const res = await this.fetchImpl(`${memoryEngineUrl}/messages`, {
|
|
303
|
+
method: 'POST',
|
|
304
|
+
headers: {
|
|
305
|
+
Authorization: `Bearer ${invocation}`,
|
|
306
|
+
'X-Auth-Type': 'ucan',
|
|
307
|
+
'x-room-id': roomId,
|
|
308
|
+
'Content-Type': 'application/json',
|
|
309
|
+
},
|
|
310
|
+
body: JSON.stringify({ messages: transformed }),
|
|
311
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
312
|
+
});
|
|
313
|
+
if (!res.ok) {
|
|
314
|
+
const text = await res.text().catch(() => '');
|
|
315
|
+
throw new Error(
|
|
316
|
+
`memory engine rejected the conversation (${res.status}): ${text.slice(0, 200)}`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const newCount = lastProcessedCount + newMessages.length;
|
|
322
|
+
await deps.setProcessedCount(sessionId, newCount);
|
|
323
|
+
logger.log(
|
|
324
|
+
`[history-indexer] processed ${newMessages.length} new messages for session ${sessionId} (lastProcessedCount → ${newCount})`,
|
|
325
|
+
);
|
|
326
|
+
return 'processed';
|
|
327
|
+
}
|
|
328
|
+
}
|