@substrat-run/engine-assistant 0.0.0-stage → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,126 @@
1
- # Temporary Holding Version
1
+ # @substrat-run/engine-assistant
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The record of a chat between a person and an assistant, for [Substrat](https://github.com/substrat-run/substrat).
4
+ An append-only event log, a single-writer run lease, and tool calls whose gated writes need an
5
+ approval bound to the exact input.
6
+
7
+ Configured for **public npm publication**; the first npm release is pending. Nothing composes
8
+ it yet; it is the first part of the assistant described in [#2283](https://github.com/substrat-run/substrat/issues/2283).
9
+
10
+ ## How a host uses it
11
+
12
+ The engine is composed **by call** and never talks to a model. A host runs the loop and calls
13
+ the in-scope functions inside its own transaction, as a principal holding the right key:
14
+
15
+ ```ts
16
+ import { assistantModule, sendMessage, acquireRun, appendEvents, claimExecution, resolveApproval } from '@substrat-run/engine-assistant';
17
+
18
+ host.registerModule(assistantModule);
19
+
20
+ // A person (assistant:use) says something, which begins a turn.
21
+ sendMessage(ctx, { threadId, text });
22
+
23
+ // The host (assistant:run) takes the lease and appends what the assistant did.
24
+ acquireRun(ctx, { threadId, runId });
25
+ await appendEvents(ctx, { threadId, runId, events: [{ t: 'text-start', pid: 'p1' }, /* … */] });
26
+
27
+ // A change waits for approval. The request carries what it binds and their digest, with the
28
+ // thread's owner as the approver:
29
+ // digest = await approvalDigest({ version, approver: threadOwner, thread: threadId, step,
30
+ // call: callId, operation, expiresAt, nonce, input })
31
+ // { t: 'approval-request', callId, step, tool, operation, version, input, expiresAt, nonce, digest, preview }
32
+ // The person approves with the digest they were shown (assistant:use, as the owner), on a
33
+ // route the loop cannot call; `version` is the deployed version the HOST serves the answer under.
34
+ await resolveApproval(ctx, { threadId, callId, digest, approved: true, version });
35
+
36
+ // Before running the operation, the host claims its one execution with what it is about to run.
37
+ await claimExecution(ctx, { threadId, runId, callId, operation, version, input });
38
+ ```
39
+
40
+ `approvalDigest` and the event vocabulary (`assistantStreamBody`) are in
41
+ `@substrat-run/contracts/assistant-stream`.
42
+
43
+ **After every answer, the host runs.** An `approval-resolved`, approval or denial, resumes the
44
+ turn rather than ending it, so the owner gets `turn_in_progress` until a run writes
45
+ `turn-finish` or `turn-error`. A host therefore takes the lease after every answer, a denial
46
+ included, and ends the turn (`turn-finish: done` after a denial, or `turn-error` if it cannot
47
+ go on). If a turn is abandoned, because the host never came back, a later run takes the lease
48
+ once the holder is stale and closes it the same way. The owner has no way to end a turn
49
+ without a run; closing the thread is their only way out.
50
+
51
+ ## Two keys
52
+
53
+ `assistant:use` is a person's: open and read their own threads, say something, approve or deny.
54
+ `assistant:run` is for a **system principal only** (the assistant host's), never a role a person
55
+ holds: it writes the assistant's side of the record. The manifest declares it `holder: 'system'`,
56
+ and `pnpm lint:permissions` refuses a role or entity grant shape that grants it. `assistant:audit`
57
+ reads other principals' threads and never writes, and only together with `assistant:use`, which
58
+ every read checks first.
59
+
60
+ ## Files for imports and comparisons
61
+
62
+ The host-facing entry point `@substrat-run/engine-assistant/files` accepts `.csv`, `.tsv`
63
+ and `.xlsx` uploads, up to 10 MiB each. A message can reference up to five files from its
64
+ own thread. Text is optional when files are present; existing text-only calls are unchanged.
65
+ Older `.xls` workbooks must first be saved as `.xlsx`.
66
+
67
+ ```ts
68
+ import { assistantFiles } from '@substrat-run/engine-assistant/files';
69
+ import { readSpreadsheet } from '@substrat-run/attachment-extractors';
70
+
71
+ // HOST code: both handles MUST address the same scope. `person` is an authenticated
72
+ // person's ScopeStub; `storage` is the assistant host's ScopeAttachments, holding
73
+ // assistant:run through its system role. Never hand that storage handle to a client.
74
+ const files = assistantFiles(person, storage);
75
+ const uploaded = await files.upload({ threadId, filename: 'items.csv', body: bytes });
76
+ await person.invoke('assistant/send-message', {
77
+ threadId, text: 'Compare these rows with the current items', attachmentIds: [uploaded.id],
78
+ });
79
+
80
+ // In a host's file-reading tool, authorize before opening bytes, then read structured rows.
81
+ const opened = await files.open(threadId, uploaded.id);
82
+ const spreadsheet = await readSpreadsheet({
83
+ body: opened.body, filename: opened.record.filename, contentType: opened.record.contentType,
84
+ });
85
+ // spreadsheet.sheets: [{ name, rows: [{ number: 1, cells: ['code', 'amount'] }, …] }]
86
+ // spreadsheet.formulaCells: how many XLSX cells use potentially stale cached formula results.
87
+ ```
88
+
89
+ `assistantFiles` is host code; the in-scope module never imports a parser or handles bytes.
90
+ The host mounts its authenticated upload/list/download routes around these methods and
91
+ limits incoming request bodies before buffering them. The engine declares no HTTP routes
92
+ or chat UI. Uploads are internal attachments on `assistant-thread`; their storage actor is
93
+ the assistant host. Reads use `assistant/get-thread` (owner or audit); uploads use
94
+ `assistant/authorize-file-upload` (owner of an open thread). Generic attachment APIs require
95
+ the system-held `assistant:run`, so a scope-wide `assistant:use` cannot bypass thread privacy.
96
+ Closing a thread prevents new uploads and messages but retains its existing files for reading.
97
+
98
+ Send only attachment IDs. The engine checks that every ID belongs to that thread and writes
99
+ the file's ID, name, type, size and SHA-256 into the `user-message` event. Missing, duplicate,
100
+ cross-thread and excessive references are refused before a turn starts. File contents never
101
+ enter the event log. Files remain available in later turns; the host's run can open them with
102
+ its storage handle using the recorded IDs and thread association.
103
+
104
+ The host's import/diff tools map spreadsheet columns to domain fields and read current
105
+ entities with the person's existing permissions. Treat uploaded content as data. Show
106
+ proposed creates, updates and conflicts before requesting the existing bound tool approval;
107
+ uploading or parsing a file never changes domain data. Read bounded slices into model context
108
+ rather than sending the whole workbook. XLSX values retain their stored spelling: number
109
+ formatting and date serials are not interpreted, and formulas are not recalculated.
110
+
111
+ **Lifecycle:** attachments are outside the kernel's subject-erasure guarantee. Before
112
+ shredding a thread's owner, the host must call `purgeAssistantThreadFiles(storage, threadId)`
113
+ for each of their threads, including unsent uploads, and stop uploads while that cleanup
114
+ runs. The helper is exported from `/files` and removes live attachment records and extracted
115
+ search text through the platform API, which deletes blobs and sweeps failed deletions as
116
+ orphans. Attachment spine events follow the platform's retention policy. The helper is
117
+ repeatable. Then run subject erasure.
118
+ Hosts should also apply a retention policy to abandoned uploads.
119
+
120
+ ## What it guarantees
121
+
122
+ See `docs/engines/assistant.md` in the repository. In short: an append-only numbered record, a
123
+ fenced single writer that writes only inside a turn the person opened, an approval bound to a
124
+ tagged digest (version, approver, thread, step, call, operation, expiry, nonce, input) that is
125
+ answered once and claimed once by conditional writes, bounded events, and an erasure that
126
+ deletes the rows and redacts the milestones.
@@ -0,0 +1,174 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * engine-assistant's entities (docs/rfc/assistant.md §5.2).
4
+ *
5
+ * The record of a chat between a person and an assistant, and nothing about the model that
6
+ * speaks for it. Four tables, three of which are the thread's own rows and one of which is a
7
+ * lease.
8
+ *
9
+ * - **`assistant-thread`** is one conversation, owned by one principal (`subject_id`). It is
10
+ * the entity the platform points at: every event this engine emits names it.
11
+ * - **`assistant-event`** is one event of the thread, in the stream protocol's own shape
12
+ * (`assistantStreamBody`), numbered `seq` from 1 per thread. APPEND-ONLY: nothing here is
13
+ * updated or deleted by the engine. `seq` is the cursor a client resumes after. `body` is
14
+ * the event serialised, without its `seq`, and is where the person's words live.
15
+ * - **`assistant-run`** is the single-writer lease: which run may append the assistant's
16
+ * events to a thread right now, and when it last beat. Not an entity of the person's, but
17
+ * it carries `subject_id` so an erasure leaves no row naming a thread that is gone.
18
+ * - **`assistant-tool-call`** is the state a tool call passes through, which is where the
19
+ * engine's central invariant lives: a gated write runs only from `approved`, once, with the
20
+ * input whose digest the user saw.
21
+ *
22
+ * Erasure (K-46). Everything that holds what the person said or what the assistant did for
23
+ * them is theirs whole, so each table names `subject_id` as its subject column and erases by
24
+ * `delete`. The rows have no `id` of their own to survive an erasure with, and nothing about
25
+ * a thread is meaningful once its owner is gone. The events this engine EMITS carry no text,
26
+ * no tool input or output and no digest of one, and every one is `pseudonymous` with the
27
+ * thread's subject as its `subjectId`, so the same erasure redacts their payloads too.
28
+ */
29
+ export declare const assistantEntities: {
30
+ readonly 'assistant-thread': {
31
+ readonly table: "assistant_threads";
32
+ readonly fields: z.ZodObject<{
33
+ id: z.ZodString;
34
+ subject_id: z.ZodString;
35
+ title: z.ZodNullable<z.ZodString>;
36
+ status: z.ZodEnum<{
37
+ closed: "closed";
38
+ open: "open";
39
+ }>;
40
+ created_at: z.ZodString;
41
+ last_event_at: z.ZodString;
42
+ closed_at: z.ZodNullable<z.ZodString>;
43
+ }, z.core.$strip>;
44
+ readonly erasable: readonly ["title"];
45
+ readonly erasure: {
46
+ readonly subjects: readonly ["subject_id"];
47
+ readonly mode: "delete";
48
+ };
49
+ };
50
+ readonly 'assistant-event': {
51
+ readonly table: "assistant_events";
52
+ readonly fields: z.ZodObject<{
53
+ thread_id: z.ZodString;
54
+ seq: z.ZodNumber;
55
+ type: z.ZodString;
56
+ body: z.ZodString;
57
+ subject_id: z.ZodString;
58
+ created_at: z.ZodString;
59
+ }, z.core.$strip>;
60
+ readonly primaryKey: readonly ["thread_id", "seq"];
61
+ readonly erasable: readonly ["body"];
62
+ readonly erasure: {
63
+ readonly subjects: readonly ["subject_id"];
64
+ readonly mode: "delete";
65
+ };
66
+ };
67
+ readonly 'assistant-run': {
68
+ readonly table: "assistant_runs";
69
+ readonly fields: z.ZodObject<{
70
+ thread_id: z.ZodString;
71
+ run_id: z.ZodString;
72
+ subject_id: z.ZodString;
73
+ beat_at: z.ZodString;
74
+ }, z.core.$strip>;
75
+ readonly primaryKey: readonly ["thread_id"];
76
+ readonly erasable: readonly ["subject_id"];
77
+ readonly erasure: {
78
+ readonly subjects: readonly ["subject_id"];
79
+ readonly mode: "delete";
80
+ };
81
+ };
82
+ readonly 'assistant-tool-call': {
83
+ readonly table: "assistant_tool_calls";
84
+ readonly fields: z.ZodObject<{
85
+ thread_id: z.ZodString;
86
+ call_id: z.ZodString;
87
+ tool: z.ZodString;
88
+ state: z.ZodEnum<{
89
+ approved: "approved";
90
+ "awaiting-approval": "awaiting-approval";
91
+ denied: "denied";
92
+ done: "done";
93
+ executing: "executing";
94
+ failed: "failed";
95
+ running: "running";
96
+ }>;
97
+ digest: z.ZodNullable<z.ZodString>;
98
+ step: z.ZodNullable<z.ZodString>;
99
+ operation: z.ZodNullable<z.ZodString>;
100
+ version: z.ZodNullable<z.ZodString>;
101
+ expires_at: z.ZodNullable<z.ZodString>;
102
+ nonce: z.ZodNullable<z.ZodString>;
103
+ request_seq: z.ZodNullable<z.ZodNumber>;
104
+ decided_by: z.ZodNullable<z.ZodString>;
105
+ decided_at: z.ZodNullable<z.ZodString>;
106
+ subject_id: z.ZodString;
107
+ created_at: z.ZodString;
108
+ updated_at: z.ZodString;
109
+ }, z.core.$strip>;
110
+ readonly primaryKey: readonly ["thread_id", "call_id"];
111
+ readonly erasable: readonly ["subject_id", "decided_by"];
112
+ readonly erasure: {
113
+ readonly subjects: readonly ["subject_id"];
114
+ readonly mode: "delete";
115
+ };
116
+ };
117
+ };
118
+ /** One thread, as stored. */
119
+ export declare const threadRow: z.ZodObject<{
120
+ id: z.ZodString;
121
+ subject_id: z.ZodString;
122
+ title: z.ZodNullable<z.ZodString>;
123
+ status: z.ZodEnum<{
124
+ closed: "closed";
125
+ open: "open";
126
+ }>;
127
+ created_at: z.ZodString;
128
+ last_event_at: z.ZodString;
129
+ closed_at: z.ZodNullable<z.ZodString>;
130
+ }, z.core.$strip>;
131
+ /** One event of a thread, as stored. */
132
+ export declare const eventRow: z.ZodObject<{
133
+ thread_id: z.ZodString;
134
+ seq: z.ZodNumber;
135
+ type: z.ZodString;
136
+ body: z.ZodString;
137
+ subject_id: z.ZodString;
138
+ created_at: z.ZodString;
139
+ }, z.core.$strip>;
140
+ /** The lease on a thread, as stored. */
141
+ export declare const runRow: z.ZodObject<{
142
+ thread_id: z.ZodString;
143
+ run_id: z.ZodString;
144
+ subject_id: z.ZodString;
145
+ beat_at: z.ZodString;
146
+ }, z.core.$strip>;
147
+ /** One tool call's state, as stored. */
148
+ export declare const toolCallRow: z.ZodObject<{
149
+ thread_id: z.ZodString;
150
+ call_id: z.ZodString;
151
+ tool: z.ZodString;
152
+ state: z.ZodEnum<{
153
+ approved: "approved";
154
+ "awaiting-approval": "awaiting-approval";
155
+ denied: "denied";
156
+ done: "done";
157
+ executing: "executing";
158
+ failed: "failed";
159
+ running: "running";
160
+ }>;
161
+ digest: z.ZodNullable<z.ZodString>;
162
+ step: z.ZodNullable<z.ZodString>;
163
+ operation: z.ZodNullable<z.ZodString>;
164
+ version: z.ZodNullable<z.ZodString>;
165
+ expires_at: z.ZodNullable<z.ZodString>;
166
+ nonce: z.ZodNullable<z.ZodString>;
167
+ request_seq: z.ZodNullable<z.ZodNumber>;
168
+ decided_by: z.ZodNullable<z.ZodString>;
169
+ decided_at: z.ZodNullable<z.ZodString>;
170
+ subject_id: z.ZodString;
171
+ created_at: z.ZodString;
172
+ updated_at: z.ZodString;
173
+ }, z.core.$strip>;
174
+ //# sourceMappingURL=entities.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entities.d.ts","sourceRoot":"","sources":["../src/entities.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuE5B,CAAC;AAEH,6BAA6B;AAC7B,eAAO,MAAM,SAAS;;;;;;;;;;;iBAA+C,CAAC;AACtE,wCAAwC;AACxC,eAAO,MAAM,QAAQ;;;;;;;iBAA8C,CAAC;AACpE,wCAAwC;AACxC,eAAO,MAAM,MAAM;;;;;iBAA4C,CAAC;AAChE,wCAAwC;AACxC,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;iBAAkD,CAAC"}
@@ -0,0 +1,110 @@
1
+ import { defineEntities } from '@substrat-run/contracts';
2
+ import { z } from 'zod';
3
+ /**
4
+ * engine-assistant's entities (docs/rfc/assistant.md §5.2).
5
+ *
6
+ * The record of a chat between a person and an assistant, and nothing about the model that
7
+ * speaks for it. Four tables, three of which are the thread's own rows and one of which is a
8
+ * lease.
9
+ *
10
+ * - **`assistant-thread`** is one conversation, owned by one principal (`subject_id`). It is
11
+ * the entity the platform points at: every event this engine emits names it.
12
+ * - **`assistant-event`** is one event of the thread, in the stream protocol's own shape
13
+ * (`assistantStreamBody`), numbered `seq` from 1 per thread. APPEND-ONLY: nothing here is
14
+ * updated or deleted by the engine. `seq` is the cursor a client resumes after. `body` is
15
+ * the event serialised, without its `seq`, and is where the person's words live.
16
+ * - **`assistant-run`** is the single-writer lease: which run may append the assistant's
17
+ * events to a thread right now, and when it last beat. Not an entity of the person's, but
18
+ * it carries `subject_id` so an erasure leaves no row naming a thread that is gone.
19
+ * - **`assistant-tool-call`** is the state a tool call passes through, which is where the
20
+ * engine's central invariant lives: a gated write runs only from `approved`, once, with the
21
+ * input whose digest the user saw.
22
+ *
23
+ * Erasure (K-46). Everything that holds what the person said or what the assistant did for
24
+ * them is theirs whole, so each table names `subject_id` as its subject column and erases by
25
+ * `delete`. The rows have no `id` of their own to survive an erasure with, and nothing about
26
+ * a thread is meaningful once its owner is gone. The events this engine EMITS carry no text,
27
+ * no tool input or output and no digest of one, and every one is `pseudonymous` with the
28
+ * thread's subject as its `subjectId`, so the same erasure redacts their payloads too.
29
+ */
30
+ export const assistantEntities = defineEntities({
31
+ 'assistant-thread': {
32
+ table: 'assistant_threads',
33
+ fields: z.object({
34
+ id: z.string(),
35
+ subject_id: z.string(),
36
+ title: z.string().nullable(),
37
+ status: z.enum(['open', 'closed']),
38
+ created_at: z.string(),
39
+ last_event_at: z.string(),
40
+ closed_at: z.string().nullable(),
41
+ }),
42
+ erasable: ['title'],
43
+ erasure: { subjects: ['subject_id'], mode: 'delete' },
44
+ },
45
+ 'assistant-event': {
46
+ table: 'assistant_events',
47
+ fields: z.object({
48
+ thread_id: z.string(),
49
+ seq: z.number(),
50
+ type: z.string(),
51
+ body: z.string(),
52
+ subject_id: z.string(),
53
+ created_at: z.string(),
54
+ }),
55
+ primaryKey: ['thread_id', 'seq'],
56
+ erasable: ['body'],
57
+ erasure: { subjects: ['subject_id'], mode: 'delete' },
58
+ },
59
+ 'assistant-run': {
60
+ table: 'assistant_runs',
61
+ fields: z.object({
62
+ thread_id: z.string(),
63
+ run_id: z.string(),
64
+ subject_id: z.string(),
65
+ beat_at: z.string(),
66
+ }),
67
+ primaryKey: ['thread_id'],
68
+ // Holds no words, but the principal's id says whose thread this lease was on.
69
+ erasable: ['subject_id'],
70
+ erasure: { subjects: ['subject_id'], mode: 'delete' },
71
+ },
72
+ 'assistant-tool-call': {
73
+ table: 'assistant_tool_calls',
74
+ fields: z.object({
75
+ thread_id: z.string(),
76
+ call_id: z.string(),
77
+ tool: z.string(),
78
+ state: z.enum(['running', 'awaiting-approval', 'approved', 'denied', 'executing', 'done', 'failed']),
79
+ digest: z.string().nullable(),
80
+ // What the approval request bound (`approvalDigest`), kept so an answer and a claim can
81
+ // recompute the digest rather than trust one. Set together with `digest`, or all null.
82
+ // The input itself is not copied here: `request_seq` points at the request event.
83
+ step: z.string().nullable(),
84
+ operation: z.string().nullable(),
85
+ version: z.string().nullable(),
86
+ expires_at: z.string().nullable(),
87
+ nonce: z.string().nullable(),
88
+ request_seq: z.number().nullable(),
89
+ decided_by: z.string().nullable(),
90
+ decided_at: z.string().nullable(),
91
+ subject_id: z.string(),
92
+ created_at: z.string(),
93
+ updated_at: z.string(),
94
+ }),
95
+ primaryKey: ['thread_id', 'call_id'],
96
+ // The tool name and the digest are not the person's, but who the call was for, and who
97
+ // decided it, are.
98
+ erasable: ['subject_id', 'decided_by'],
99
+ erasure: { subjects: ['subject_id'], mode: 'delete' },
100
+ },
101
+ });
102
+ /** One thread, as stored. */
103
+ export const threadRow = assistantEntities['assistant-thread'].fields;
104
+ /** One event of a thread, as stored. */
105
+ export const eventRow = assistantEntities['assistant-event'].fields;
106
+ /** The lease on a thread, as stored. */
107
+ export const runRow = assistantEntities['assistant-run'].fields;
108
+ /** One tool call's state, as stored. */
109
+ export const toolCallRow = assistantEntities['assistant-tool-call'].fields;
110
+ //# sourceMappingURL=entities.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entities.js","sourceRoot":"","sources":["../src/entities.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,106 @@
1
+ /**
2
+ * engine-assistant's event contract (#696) — what a VERTICAL imports so that consuming this
3
+ * engine by event is checked rather than guessed.
4
+ *
5
+ * Milestones only. The thread's own record is `assistant_events`; these are what the rest of
6
+ * the platform learns about it. A text delta is not a platform event: forty of them make one
7
+ * answer, and an outbox row each would be the record written twice.
8
+ *
9
+ * **No text and no tool data ride an event.** A message, a tool input and a tool output are
10
+ * the person's data. So a payload names the thread, the call and the tool, and nothing else:
11
+ * not the approval's `preview` wording, which can restate what the person asked for, and not
12
+ * its digest either — a SHA-256 of a small input is recovered by guessing the input, so it
13
+ * is the input. Nor does a payload name the person: who a thread belongs to is the
14
+ * envelope's `subjectId`.
15
+ *
16
+ * **Every milestone is `pseudonymous`, keyed to the thread's subject** (`emitAssistantEvent`
17
+ * makes that the only way to emit one). A thread id names one person's conversation, so the
18
+ * outbox rows that carry it are that person's; with a `subjectId`, an erasure (K-46) redacts
19
+ * their payloads along with the thread's own rows.
20
+ *
21
+ * VERTICAL-FACING ONLY. A sibling engine consuming one of these must NOT import it (R1,
22
+ * star topology).
23
+ *
24
+ * Additive, like every other engine surface: a new event type may appear, an existing
25
+ * payload field does not change shape without a `schemaVersion` bump.
26
+ */
27
+ import { type DomainEventInput } from '@substrat-run/contracts';
28
+ import type { OperationContext } from '@substrat-run/kernel';
29
+ /** A thread was opened. Whose it is, is the envelope's `subjectId`. */
30
+ export interface AssistantThreadCreatedPayload {
31
+ threadId: string;
32
+ }
33
+ /** The user said something and a turn began. */
34
+ export interface AssistantTurnStartedPayload {
35
+ threadId: string;
36
+ }
37
+ /** A run came to rest on a turn: done, waiting for an approval, stopped, or out of budget. */
38
+ export interface AssistantTurnFinishedPayload {
39
+ threadId: string;
40
+ reason: 'done' | 'awaiting-approval' | 'stopped' | 'budget';
41
+ }
42
+ /**
43
+ * A gated tool call is waiting for the user. The digest it is bound to stays in the record
44
+ * (`assistant_events`), never here: it is a hash of the person's input.
45
+ */
46
+ export interface AssistantApprovalRequestedPayload {
47
+ threadId: string;
48
+ callId: string;
49
+ tool: string;
50
+ }
51
+ /** The user approved or denied a gated tool call. */
52
+ export interface AssistantApprovalResolvedPayload {
53
+ threadId: string;
54
+ callId: string;
55
+ tool: string;
56
+ approved: boolean;
57
+ }
58
+ /** The thread was closed; nothing more is appended. */
59
+ export interface AssistantThreadClosedPayload {
60
+ threadId: string;
61
+ }
62
+ /**
63
+ * engine-assistant's event contract — the six event types this engine emits and the payload
64
+ * each one carries. Satisfies the kernel's `EventContract`.
65
+ */
66
+ export type AssistantEvents = {
67
+ events: {
68
+ 'assistant.thread-created': AssistantThreadCreatedPayload;
69
+ 'assistant.turn-started': AssistantTurnStartedPayload;
70
+ 'assistant.turn-finished': AssistantTurnFinishedPayload;
71
+ 'assistant.approval-requested': AssistantApprovalRequestedPayload;
72
+ 'assistant.approval-resolved': AssistantApprovalResolvedPayload;
73
+ 'assistant.thread-closed': AssistantThreadClosedPayload;
74
+ };
75
+ };
76
+ /** Every event type this engine emits. */
77
+ export type AssistantEventType = keyof AssistantEvents['events'];
78
+ /**
79
+ * Every event type this engine emits, and the `schemaVersion` each is emitted at — the ONE
80
+ * home for that number (#1597). `emitAssistantEvent` stamps it and the manifest's `emits` is
81
+ * derived from it, so the two cannot drift. Bumping a version is K-39's REPLACE.
82
+ */
83
+ export declare const assistantEventVersions: {
84
+ readonly 'assistant.thread-created': 1;
85
+ readonly 'assistant.turn-started': 1;
86
+ readonly 'assistant.turn-finished': 1;
87
+ readonly 'assistant.approval-requested': 1;
88
+ readonly 'assistant.approval-resolved': 1;
89
+ readonly 'assistant.thread-closed': 1;
90
+ };
91
+ /** The manifest's `events.emits`, read off {@link assistantEventVersions} — never hand-declared. */
92
+ export declare const assistantEmitDeclarations: {
93
+ type: string;
94
+ schemaVersion: number;
95
+ }[];
96
+ /**
97
+ * `ctx.emit`, with the event type and its payload welded together, so the map above is the
98
+ * source rather than a description nothing holds in agreement. It stamps `schemaVersion`, and
99
+ * makes every milestone `pseudonymous` under `subject` — the thread's owner — so an erasure
100
+ * of that person reaches it; there is no way to emit one as `none`.
101
+ */
102
+ export declare function emitAssistantEvent<K extends AssistantEventType>(ctx: OperationContext, subject: string, event: Omit<DomainEventInput, 'type' | 'payload' | 'schemaVersion' | 'piiClass' | 'subjectId'> & {
103
+ type: K;
104
+ payload: AssistantEvents['events'][K];
105
+ }): void;
106
+ //# sourceMappingURL=events.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,EAAiB,KAAK,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC/E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAE7D,uEAAuE;AACvE,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,gDAAgD;AAChD,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,8FAA8F;AAC9F,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS,GAAG,QAAQ,CAAC;CAC7D;AAED;;;GAGG;AACH,MAAM,WAAW,iCAAiC;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd;AAED,qDAAqD;AACrD,MAAM,WAAW,gCAAgC;IAC/C,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,uDAAuD;AACvD,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE;QACN,0BAA0B,EAAE,6BAA6B,CAAC;QAC1D,wBAAwB,EAAE,2BAA2B,CAAC;QACtD,yBAAyB,EAAE,4BAA4B,CAAC;QACxD,8BAA8B,EAAE,iCAAiC,CAAC;QAClE,6BAA6B,EAAE,gCAAgC,CAAC;QAChE,yBAAyB,EAAE,4BAA4B,CAAC;KACzD,CAAC;CACH,CAAC;AAEF,0CAA0C;AAC1C,MAAM,MAAM,kBAAkB,GAAG,MAAM,eAAe,CAAC,QAAQ,CAAC,CAAC;AAEjE;;;;GAIG;AACH,eAAO,MAAM,sBAAsB;yCACL,CAAC;uCACH,CAAC;wCACA,CAAC;6CACI,CAAC;4CACF,CAAC;wCACL,CAAC;CACyB,CAAC;AAExD,oGAAoG;AACpG,eAAO,MAAM,yBAAyB,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,EAEpB,CAAC;AAE5D;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,kBAAkB,EAC7D,GAAG,EAAE,gBAAgB,EACrB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,IAAI,CAAC,gBAAgB,EAAE,MAAM,GAAG,SAAS,GAAG,eAAe,GAAG,UAAU,GAAG,WAAW,CAAC,GAAG;IAC/F,IAAI,EAAE,CAAC,CAAC;IACR,OAAO,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;CACvC,GACA,IAAI,CAON"}
package/dist/events.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * engine-assistant's event contract (#696) — what a VERTICAL imports so that consuming this
3
+ * engine by event is checked rather than guessed.
4
+ *
5
+ * Milestones only. The thread's own record is `assistant_events`; these are what the rest of
6
+ * the platform learns about it. A text delta is not a platform event: forty of them make one
7
+ * answer, and an outbox row each would be the record written twice.
8
+ *
9
+ * **No text and no tool data ride an event.** A message, a tool input and a tool output are
10
+ * the person's data. So a payload names the thread, the call and the tool, and nothing else:
11
+ * not the approval's `preview` wording, which can restate what the person asked for, and not
12
+ * its digest either — a SHA-256 of a small input is recovered by guessing the input, so it
13
+ * is the input. Nor does a payload name the person: who a thread belongs to is the
14
+ * envelope's `subjectId`.
15
+ *
16
+ * **Every milestone is `pseudonymous`, keyed to the thread's subject** (`emitAssistantEvent`
17
+ * makes that the only way to emit one). A thread id names one person's conversation, so the
18
+ * outbox rows that carry it are that person's; with a `subjectId`, an erasure (K-46) redacts
19
+ * their payloads along with the thread's own rows.
20
+ *
21
+ * VERTICAL-FACING ONLY. A sibling engine consuming one of these must NOT import it (R1,
22
+ * star topology).
23
+ *
24
+ * Additive, like every other engine surface: a new event type may appear, an existing
25
+ * payload field does not change shape without a `schemaVersion` bump.
26
+ */
27
+ import { dataSubjectId } from '@substrat-run/contracts';
28
+ /**
29
+ * Every event type this engine emits, and the `schemaVersion` each is emitted at — the ONE
30
+ * home for that number (#1597). `emitAssistantEvent` stamps it and the manifest's `emits` is
31
+ * derived from it, so the two cannot drift. Bumping a version is K-39's REPLACE.
32
+ */
33
+ export const assistantEventVersions = {
34
+ 'assistant.thread-created': 1,
35
+ 'assistant.turn-started': 1,
36
+ 'assistant.turn-finished': 1,
37
+ 'assistant.approval-requested': 1,
38
+ 'assistant.approval-resolved': 1,
39
+ 'assistant.thread-closed': 1,
40
+ };
41
+ /** The manifest's `events.emits`, read off {@link assistantEventVersions} — never hand-declared. */
42
+ export const assistantEmitDeclarations = Object.entries(assistantEventVersions).map(([type, schemaVersion]) => ({ type, schemaVersion }));
43
+ /**
44
+ * `ctx.emit`, with the event type and its payload welded together, so the map above is the
45
+ * source rather than a description nothing holds in agreement. It stamps `schemaVersion`, and
46
+ * makes every milestone `pseudonymous` under `subject` — the thread's owner — so an erasure
47
+ * of that person reaches it; there is no way to emit one as `none`.
48
+ */
49
+ export function emitAssistantEvent(ctx, subject, event) {
50
+ ctx.emit({
51
+ ...event,
52
+ schemaVersion: assistantEventVersions[event.type],
53
+ piiClass: 'pseudonymous',
54
+ subjectId: dataSubjectId.parse(subject),
55
+ });
56
+ }
57
+ //# sourceMappingURL=events.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events.js","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,EAAE,aAAa,EAAyB,MAAM,yBAAyB,CAAC;AA4D/E;;;;GAIG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG;IACpC,0BAA0B,EAAE,CAAC;IAC7B,wBAAwB,EAAE,CAAC;IAC3B,yBAAyB,EAAE,CAAC;IAC5B,8BAA8B,EAAE,CAAC;IACjC,6BAA6B,EAAE,CAAC;IAChC,yBAAyB,EAAE,CAAC;CACyB,CAAC;AAExD,oGAAoG;AACpG,MAAM,CAAC,MAAM,yBAAyB,GAA8C,MAAM,CAAC,OAAO,CAChG,sBAAsB,CACvB,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,aAAa,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,aAAa,EAAE,CAAC,CAAC,CAAC;AAE5D;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAChC,GAAqB,EACrB,OAAe,EACf,KAGC;IAED,GAAG,CAAC,IAAI,CAAC;QACP,GAAG,KAAK;QACR,aAAa,EAAE,sBAAsB,CAAC,KAAK,CAAC,IAAI,CAAC;QACjD,QAAQ,EAAE,cAAc;QACxB,SAAS,EAAE,aAAa,CAAC,KAAK,CAAC,OAAO,CAAC;KACxC,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * HOST code, separate from the in-scope engine. Bind `caller` to the authenticated
3
+ * person and `storage` to the assistant host's system principal, in the SAME scope.
4
+ * Never expose `storage` itself to a browser or model tool.
5
+ */
6
+ import { type AttachmentRecord } from '@substrat-run/contracts';
7
+ import type { OpenedAttachment, ScopeAttachments, ScopeStub } from '@substrat-run/kernel';
8
+ export declare const ASSISTANT_FILE_MAX_BYTES: number;
9
+ export interface AssistantFileUpload {
10
+ threadId: string;
11
+ filename: string;
12
+ body: Uint8Array;
13
+ }
14
+ /**
15
+ * A host mounts these methods on its own authenticated routes. Bytes bypass invoke;
16
+ * ownership and audit checks go through the engine's real operation handlers.
17
+ * There is intentionally no automatic import: the host's tools preview a diff and
18
+ * use the existing approval/claim flow before changing domain entities.
19
+ */
20
+ export declare function assistantFiles(caller: ScopeStub, storage: ScopeAttachments): {
21
+ upload(input: AssistantFileUpload): Promise<AttachmentRecord>;
22
+ list: (threadId: string) => Promise<AttachmentRecord[]>;
23
+ open(threadId: string, attachmentId: string): Promise<OpenedAttachment>;
24
+ };
25
+ /**
26
+ * Trusted host lifecycle hook: remove blobs BEFORE erasing a thread's owner. The
27
+ * kernel's subject erasure deletes engine rows, but does not shred attachment blobs.
28
+ * Repeatable; failed blob deletions follow the platform's orphan-sweep policy.
29
+ * Do not expose as an unauthenticated route.
30
+ */
31
+ export declare function purgeAssistantThreadFiles(storage: ScopeAttachments, threadId: string): Promise<void>;
32
+ //# sourceMappingURL=files.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../src/files.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAqC,KAAK,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AACnG,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAG1F,eAAO,MAAM,wBAAwB,QAAmB,CAAC;AAGzD,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,UAAU,CAAC;CAClB;AAYD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,gBAAgB;kBASnD,mBAAmB,GAAG,OAAO,CAAC,gBAAgB,CAAC;qBANvC,MAAM,KAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;mBAkB3C,MAAM,gBAAgB,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;EAShF;AAED;;;;;GAKG;AACH,wBAAsB,yBAAyB,CAAC,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAI1G"}