@tangleai/assistant 0.21.1 → 0.25.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/src/controller.js CHANGED
@@ -1,15 +1,12 @@
1
- //@ts-check
2
1
  import { createChatClient, probeProvider } from '@tangleai/models';
3
2
  import { createAgent, createRefiner } from '@tangleai/agents';
4
3
  import { createEnvironment } from '@tangleai/context';
5
4
  import { applyJSONPatch, compileJsonQuery } from '@jarenjs/json';
6
- import { isConfigured } from './settings.js';
5
+ import { isConfigured } from "./settings.js";
7
6
  /** Chat turns sent back to the model per request (persisted transcripts can be long). */
8
7
  const HISTORY_WINDOW = 20;
9
-
10
8
  /** Chat turns kept in the persisted transcript (a localStorage slot, not an archive). */
11
9
  const SAVED_WINDOW = 100;
12
-
13
10
  /**
14
11
  * How many memories the agent carries into a turn. Small on purpose:
15
12
  * they are paid for out of the same history budget the conversation is,
@@ -17,306 +14,305 @@ const SAVED_WINDOW = 100;
17
14
  * assistant with a better memory.
18
15
  */
19
16
  const MEMORY_WINDOW = 5;
20
-
21
17
  /**
22
18
  * The ledger as the panel shows it: the active objective (a superseded
23
19
  * or abandoned one is not "what we are doing" and is not shown), what
24
20
  * has been recorded against it, and how many dropped rounds are sitting
25
21
  * in slots waiting for a `recall`. One read, so the panel can never
26
22
  * disagree with itself about which turn it is describing.
27
- * @param {any} ledger
28
23
  */
29
24
  async function readLedger(ledger) {
30
- const goal = await ledger.getGoal();
31
- const slots = await ledger.listSlots();
32
- return {
33
- goal: goal !== null && goal.status === 'active'
34
- ? { objective: goal.objective, progress: goal.progress, checkpoint: goal.checkpoint }
35
- : null,
36
- memories: (await ledger.listMemories()).length,
37
- archived: slots.filter((slot) => slot.kind === 'agent-round').length,
38
- persistence: ledger.storageStatus?.() ?? null,
39
- retention: await ledger.retentionReport?.() ?? null,
40
- };
25
+ const goal = await ledger.getGoal();
26
+ const slots = await ledger.listSlots();
27
+ return {
28
+ goal: goal !== null && goal.status === 'active'
29
+ ? { objective: goal.objective, progress: goal.progress, checkpoint: goal.checkpoint }
30
+ : null,
31
+ memories: (await ledger.listMemories()).length,
32
+ archived: slots.filter((slot) => slot.kind === 'agent-round').length,
33
+ persistence: ledger.storageStatus?.() ?? null,
34
+ retention: await ledger.retentionReport?.() ?? null,
35
+ };
41
36
  }
42
-
43
37
  /**
44
38
  * The assistant's impure effects: the streaming agent turn, the
45
39
  * settings persistence, the transcript persistence and the ledger. The
46
40
  * injected `aiFetch` keeps the whole thing testable against a scripted
47
41
  * transport, and the injected `ledger` keeps it testable without a
48
42
  * store.
49
- * @param {{ toolbox: any, getApp: () => any,
50
- * aiFetch?: typeof fetch,
51
- * aiStorage: { read: () => any, write: (data: any) => any },
52
- * aiChat: { read: () => any, write: (data: any) => any },
53
- * ledger: any, system?: string, headers?: Record<string, string>, onDispose?: () => any }} deps
54
43
  */
55
44
  export function createAssistantController(deps) {
56
- let disposed = false, epoch = 0, closing;
57
- /** @type {AbortController|null} */
58
- let turnAbort = null;
59
- const lifetime = new AbortController();
60
- const fetchImpl = deps.aiFetch ?? globalThis.fetch;
61
- deps = { ...deps, aiFetch: (url, init = {}) => fetchImpl(url, { ...init,
62
- signal: AbortSignal.any([lifetime.signal, ...(init.signal ? [init.signal] : [])]) }) };
63
- function cancel() { epoch++; turnAbort?.abort(); turnAbort = null; }
64
- function dispose() {
65
- if (disposed) return closing;
66
- disposed = true; cancel(); lifetime.abort();
67
- closing = Promise.resolve().then(() => deps.onDispose?.());
68
- return closing;
69
- }
70
- /**
71
- * The last completed turn, kept here rather than in the state: it is
72
- * the trajectory a refinement reads, it is large, and nothing renders
73
- * it. State holds what the panel draws; this holds what the next
74
- * action needs.
75
- * @type {{ messages: any[], steps: any[] } | null}
76
- */
77
- let lastRun = null;
78
-
79
- /** The client the current settings describe, or a dispatched failure. */
80
- const clientFor = (state, dispatch) => {
81
- const s = state.ai.settings;
82
- try {
83
- return createChatClient({
84
- provider: s.provider,
85
- baseUrl: s.baseUrl,
86
- apiKey: s.apiKey,
87
- model: s.model,
88
- fetch: deps.aiFetch,
89
- headers: deps.headers,
90
- });
45
+ let disposed = false, epoch = 0, closing;
46
+ let turnAbort = null;
47
+ const lifetime = new AbortController();
48
+ const fetchImpl = deps.aiFetch ?? globalThis.fetch;
49
+ deps = {
50
+ ...deps, aiFetch: (url, init = {}) => fetchImpl(url, {
51
+ ...init,
52
+ signal: AbortSignal.any([lifetime.signal, ...(init.signal ? [init.signal] : [])])
53
+ })
54
+ };
55
+ function cancel() { epoch++; turnAbort?.abort(); turnAbort = null; }
56
+ function dispose() {
57
+ if (disposed)
58
+ return closing;
59
+ disposed = true;
60
+ cancel();
61
+ lifetime.abort();
62
+ closing = Promise.resolve().then(() => deps.onDispose?.());
63
+ return closing;
91
64
  }
92
- catch (err) {
93
- dispatch('ai/failed', /** @type {Error} */ (err).message);
94
- return null;
95
- }
96
- };
97
-
98
- const rawEffects = {
99
- 'ai-send': (props, dispatch) => {
100
- const app = deps.getApp();
101
- const state = app.getState();
102
- const draft = state.ai.draft.trim();
103
- if (draft === '' || state.ai.status === 'streaming') return;
104
- if (!isConfigured(state.ai.settings)) {
105
- dispatch('ai/failed', 'Add a provider, model and (for OpenRouter) an API key in settings first.');
106
- return;
107
- }
108
- const runEpoch = epoch;
109
- dispatch('ai/user', draft);
110
-
111
- const client = clientFor(state, dispatch);
112
- if (client === null) return;
113
-
114
- // weak local models are first-class: enough rounds to read an
115
- // engine's { error } result, fetch an example and try again —
116
- // and a studio flow (template → write → patch → repair → save)
117
- // legitimately runs past a dozen rounds on a small model. The
118
- // history budget keeps those long sessions inside a small local
119
- // context window (~6k tokens), which is what makes the higher
120
- // cap affordable.
121
- //
122
- // With the ledger under it, that budget stops destroying: every
123
- // round it drops is archived to a slot first and the model can
124
- // `recall` it back. The same ledger puts the objective and what
125
- // has been learned into the prompt of every turn.
126
- // and with an ENVIRONMENT over that same ledger, the archive stops
127
- // being a list of addresses to fetch one at a time: `env_grep`
128
- // scans every archived round for a pattern and answers with which
129
- // slot matched and one line of context, so "what did we try
130
- // earlier?" costs one call instead of one call per round. The
131
- // corpus and the archive share a store on purpose — the same
132
- // operations reach both.
133
- const environment = createEnvironment({
134
- ledger: deps.ledger,
135
- compileQuery: compileJsonQuery,
136
- });
137
- const agent = createAgent({
138
- client, toolbox: { toFunctionTools: () => deps.toolbox.toFunctionTools(),
139
- execute: (name, args) => disposed || epoch !== runEpoch
140
- ? { error: 'The assistant turn was cancelled.' } : deps.toolbox.execute(name, args) }, system: deps.system ?? 'Help the user complete their task using the available tools.', maxToolRounds: 16,
141
- historyBudget: 24_000,
142
- ledger: deps.ledger,
143
- environment,
144
- retrieval: { memories: { limit: MEMORY_WINDOW } },
145
- });
146
- // build the turn from this effect's own snapshot: the ai/user
147
- // dispatch above is queued FIFO behind the running transaction,
148
- // so a getState() here would still miss the draft
149
- const history = [...state.ai.messages, { role: 'user', content: draft }]
150
- .slice(-HISTORY_WINDOW)
151
- .map((m) => ({ role: m.role, content: m.content }));
152
- turnAbort?.abort(); turnAbort = new AbortController();
153
- return agent.send(history, {
154
- signal: turnAbort.signal,
155
- onDelta: (text) => dispatch('ai/delta', text),
156
- onReasoning: (text) => dispatch('ai/reasoning', text.length),
157
- onToolCall: (call) => dispatch('ai/activity', call.name),
158
- // back to 'Thinking…' between a tool's result and the next token
159
- onToolResult: () => dispatch('ai/activity', null),
160
- }).then(
161
- // an empty final message is a model quirk worth an honest line —
162
- // and a reasoning-only turn deserves to say what happened
163
- (result) => {
164
- if (disposed || epoch !== runEpoch) return;
165
- lastRun = { messages: result.messages, steps: result.steps };
166
- dispatch('ai/reply', result.message.content !== ''
167
- ? result.message.content
168
- : result.message.reasoning !== undefined
169
- ? '*The model spent the whole turn reasoning without a final reply — send another message to continue.*'
170
- : '*The model ended its turn without a reply — whatever it loaded is on screen; send another message to continue.*');
171
- // the archive grows during a turn, so the panel's count is read
172
- // after it: what the model can still reach is a fact about the
173
- // finished turn, not the one that started it
174
- readLedger(deps.ledger).then((view) => dispatch('ai/ledger', view), () => {});
175
- },
176
- (err) => dispatch('ai/failed', err?.message ?? String(err)),
177
- );
178
- },
179
-
180
- // the ledger panel: one read, dispatched as one value. Run on open
181
- // (so a reloaded page shows the objective it was left with) and
182
- // after anything that writes.
183
- 'ai-ledger-read': (props, dispatch) => {
184
- readLedger(deps.ledger).then((view) => dispatch('ai/ledger', view),
185
- (err) => dispatch('ai/failed', err?.message ?? String(err)));
186
- },
187
-
188
- // clearing the conversation clears what compaction archived FROM it:
189
- // an archived round is a piece of a transcript, and its address only
190
- // ever appeared in that transcript's synopsis. Keeping the rounds
191
- // would leave the panel counting recoverable context for a
192
- // conversation that no longer exists — and leave bytes in the store
193
- // that nothing can ever name again. Memories and the goal survive:
194
- // they are what was LEARNED, not what was said.
195
- 'ai-clear-archive': (props, dispatch) => {
196
- (typeof deps.ledger.clearArchives === 'function' ? deps.ledger.clearArchives()
197
- : deps.ledger.listSlots().then((slots) => Promise.all(slots
198
- .filter((slot) => slot.kind === 'agent-round' || slot.kind === 'agent-round-index')
199
- .map((slot) => deps.ledger.deleteSlot(slot.name)))))
200
- .then(() => readLedger(deps.ledger))
201
- .then((view) => dispatch('ai/ledger', view),
202
- (err) => dispatch('ai/failed', err?.message ?? String(err)));
203
- },
204
-
205
- 'ai-goal-set': (props, dispatch) => {
206
- const objective = deps.getApp().getState().ai.goalDraft.trim();
207
- if (objective === '') return;
208
- deps.ledger.setGoal({ objective }).then((goal) => {
209
- if (goal?.error !== undefined) {
210
- dispatch('ai/failed', goal.error);
211
- return;
65
+ /**
66
+ * The last completed turn, kept here rather than in the state: it is
67
+ * the trajectory a refinement reads, it is large, and nothing renders
68
+ * it. State holds what the panel draws; this holds what the next
69
+ * action needs.
70
+ * @type
71
+ */
72
+ let lastRun = null;
73
+ /** The client the current settings describe, or a dispatched failure. */
74
+ const clientFor = (state, dispatch) => {
75
+ const s = state.ai.settings;
76
+ try {
77
+ return createChatClient({
78
+ provider: s.provider,
79
+ baseUrl: s.baseUrl,
80
+ apiKey: s.apiKey,
81
+ model: s.model,
82
+ fetch: deps.aiFetch,
83
+ headers: deps.headers,
84
+ });
212
85
  }
213
- // the draft is cleared by what comes back from the ledger, not by
214
- // the action: the objective on screen is the stored one
215
- return readLedger(deps.ledger).then((view) => dispatch('ai/goal-committed', view));
216
- }, (err) => dispatch('ai/failed', err?.message ?? String(err)));
217
- },
218
-
219
- // "clear" abandons the objective rather than deleting it: the ledger
220
- // keeps what this agent was asked to do, and the panel stops showing
221
- // an objective nobody is working on
222
- 'ai-goal-clear': (props, dispatch) => {
223
- deps.ledger.setGoalStatus('abandoned')
224
- .then(() => readLedger(deps.ledger))
225
- .then((view) => dispatch('ai/ledger', view),
226
- (err) => dispatch('ai/failed', err?.message ?? String(err)));
227
- },
228
-
229
- // the refinement button: the model proposes an RFC 6902 patch over
230
- // its own supplemental state, every stage of the gate runs, and what
231
- // survives is committed. The patch engine is INJECTED here, exactly
232
- // as @tangleai/agents requires — the package never imports @jarenjs/json.
233
- 'ai-remember': (props, dispatch) => {
234
- const state = deps.getApp().getState();
235
- if (lastRun === null) {
236
- dispatch('ai/remembered', { note: 'Nothing to remember yet — send a message first.' });
237
- return;
238
- }
239
- if (!isConfigured(state.ai.settings)) {
240
- dispatch('ai/remembered', { note: 'Add a provider, model and key in settings first.' });
241
- return;
242
- }
243
- const client = clientFor(state, dispatch);
244
- if (client === null) return;
245
- return createRefiner({
246
- client,
247
- ledger: deps.ledger,
248
- applyPatch: (document, patch) => applyJSONPatch(document, patch),
249
- }).refine(lastRun, { signal: lifetime.signal }).then((outcome) => {
250
- const written = outcome.ok === true
251
- ? outcome.memories.length + outcome.skills.length + outcome.progress.length
252
- : 0;
253
- dispatch('ai/remembered', {
254
- note: outcome.ok !== true
255
- ? `Nothing was stored — ${outcome.error}`
256
- : written === 0
257
- ? 'The assistant found nothing worth remembering from this session.'
258
- : `Remembered ${written} evidenced item${written === 1 ? '' : 's'}.`,
259
- });
260
- return readLedger(deps.ledger).then((view) => dispatch('ai/ledger', view));
261
- }, (err) => dispatch('ai/remembered', { note: err?.message ?? String(err) }));
262
- },
263
-
264
- 'ai-save-settings': async (_props, dispatch) => {
265
- try {
266
- if (await deps.aiStorage.write(deps.getApp().getState().ai.settings) === false)
267
- throw new Error('Assistant settings could not be saved.');
268
- }
269
- catch (error) { dispatch('ai/settings-open', true); dispatch('ai/failed', error.message); }
270
- },
271
-
272
- // the settings "Test connection" button: one /models probe with the
273
- // exact auth a chat turn would use; the result object drives the
274
- // status line and the model-name datalist
275
- 'ai-probe': (props, dispatch) => {
276
- const s = deps.getApp().getState().ai.settings;
277
- return probeProvider({
278
- provider: s.provider,
279
- baseUrl: s.baseUrl,
280
- apiKey: s.apiKey,
281
- fetch: deps.aiFetch,
282
- }).then((result) => dispatch('ai/probe-result', result.ok
283
- ? {
284
- status: 'ok',
285
- detail: `Connected — ${result.models.length} model${result.models.length === 1 ? '' : 's'} available.`,
286
- models: result.models.slice(0, 100),
86
+ catch (err) {
87
+ dispatch('ai/failed', err.message);
88
+ return null;
287
89
  }
288
- : { status: 'fail', detail: result.error, models: [] }));
289
- },
290
-
291
- // opening the panel unconfigured lands you in settings — pinned
292
- // open, so the form does not hide the moment typing a model name
293
- // makes the configuration valid (only Save closes and persists it)
294
- 'ai-ensure-settings': (props, dispatch) => {
295
- const state = deps.getApp().getState();
296
- if (state.ai.open && !isConfigured(state.ai.settings)) {
297
- dispatch('ai/settings-open', true);
298
- }
299
- },
300
-
301
- // the transcript mirror: every appended turn (and a clear) writes
302
- // the visible messages through the injected store, so a reload
303
- // resumes the conversation
304
- 'ai-persist': async (_props, dispatch) => {
305
- const messages = deps.getApp().getState().ai.messages;
306
- try {
307
- if (await deps.aiChat.write({ messages: messages.slice(-SAVED_WINDOW) }) === false)
308
- throw new Error('The assistant transcript could not be saved.');
309
- }
310
- catch (error) { dispatch('ai/failed', error.message); }
311
- },
312
- };
313
- const effects = Object.fromEntries(Object.entries(rawEffects).map(([name, effect]) => [name, (props, dispatch) => {
314
- if (disposed) return;
315
- const version = epoch;
316
- const publish = (action, payload) => { if (!disposed && version === epoch) dispatch(action, payload); };
317
- return effect(props, publish);
318
- }]));
319
- effects['ai-cancel'] = () => { if (!disposed) cancel(); };
320
- effects['ai-clear-run'] = () => { if (!disposed) { cancel(); lastRun = null; } };
321
- return { effects, cancel, dispose, get signal() { return turnAbort?.signal ?? lifetime.signal; } };
90
+ };
91
+ const rawEffects = {
92
+ 'ai-send': (props, dispatch) => {
93
+ const app = deps.getApp();
94
+ const state = app.getState();
95
+ const draft = state.ai.draft.trim();
96
+ if (draft === '' || state.ai.status === 'streaming')
97
+ return;
98
+ if (!isConfigured(state.ai.settings)) {
99
+ dispatch('ai/failed', 'Add a provider, model and (for OpenRouter) an API key in settings first.');
100
+ return;
101
+ }
102
+ const runEpoch = epoch;
103
+ dispatch('ai/user', draft);
104
+ const client = clientFor(state, dispatch);
105
+ if (client === null)
106
+ return;
107
+ // weak local models are first-class: enough rounds to read an
108
+ // engine's { error } result, fetch an example and try again —
109
+ // and a studio flow (template write patch repair → save)
110
+ // legitimately runs past a dozen rounds on a small model. The
111
+ // history budget keeps those long sessions inside a small local
112
+ // context window (~6k tokens), which is what makes the higher
113
+ // cap affordable.
114
+ //
115
+ // With the ledger under it, that budget stops destroying: every
116
+ // round it drops is archived to a slot first and the model can
117
+ // `recall` it back. The same ledger puts the objective and what
118
+ // has been learned into the prompt of every turn.
119
+ // and with an ENVIRONMENT over that same ledger, the archive stops
120
+ // being a list of addresses to fetch one at a time: `env_grep`
121
+ // scans every archived round for a pattern and answers with which
122
+ // slot matched and one line of context, so "what did we try
123
+ // earlier?" costs one call instead of one call per round. The
124
+ // corpus and the archive share a store on purpose — the same
125
+ // operations reach both.
126
+ const environment = createEnvironment({
127
+ ledger: deps.ledger,
128
+ compileQuery: compileJsonQuery,
129
+ });
130
+ const agent = createAgent({
131
+ client, toolbox: {
132
+ toFunctionTools: () => deps.toolbox.toFunctionTools(),
133
+ execute: (name, args) => disposed || epoch !== runEpoch
134
+ ? { error: 'The assistant turn was cancelled.' } : deps.toolbox.execute(name, args)
135
+ }, system: deps.system ?? 'Help the user complete their task using the available tools.', maxToolRounds: 16,
136
+ historyBudget: 24_000,
137
+ ledger: deps.ledger,
138
+ environment,
139
+ retrieval: { memories: { limit: MEMORY_WINDOW } },
140
+ });
141
+ // build the turn from this effect's own snapshot: the ai/user
142
+ // dispatch above is queued FIFO behind the running transaction,
143
+ // so a getState() here would still miss the draft
144
+ const history = [...state.ai.messages, { role: 'user', content: draft }]
145
+ .slice(-HISTORY_WINDOW)
146
+ .map((m) => ({ role: m.role, content: m.content }));
147
+ turnAbort?.abort();
148
+ turnAbort = new AbortController();
149
+ return agent.send(history, {
150
+ signal: turnAbort.signal,
151
+ onDelta: (text) => dispatch('ai/delta', text),
152
+ onReasoning: (text) => dispatch('ai/reasoning', text.length),
153
+ onToolCall: (call) => dispatch('ai/activity', call.name),
154
+ // back to 'Thinking…' between a tool's result and the next token
155
+ onToolResult: () => dispatch('ai/activity', null),
156
+ }).then(
157
+ // an empty final message is a model quirk worth an honest line —
158
+ // and a reasoning-only turn deserves to say what happened
159
+ (result) => {
160
+ if (disposed || epoch !== runEpoch)
161
+ return;
162
+ lastRun = { messages: result.messages, steps: result.steps };
163
+ dispatch('ai/reply', result.message.content !== ''
164
+ ? result.message.content
165
+ : result.message.reasoning !== undefined
166
+ ? '*The model spent the whole turn reasoning without a final reply — send another message to continue.*'
167
+ : '*The model ended its turn without a reply — whatever it loaded is on screen; send another message to continue.*');
168
+ // the archive grows during a turn, so the panel's count is read
169
+ // after it: what the model can still reach is a fact about the
170
+ // finished turn, not the one that started it
171
+ readLedger(deps.ledger).then((view) => dispatch('ai/ledger', view), () => { });
172
+ }, (err) => dispatch('ai/failed', err?.message ?? String(err)));
173
+ },
174
+ // the ledger panel: one read, dispatched as one value. Run on open
175
+ // (so a reloaded page shows the objective it was left with) and
176
+ // after anything that writes.
177
+ 'ai-ledger-read': (props, dispatch) => {
178
+ readLedger(deps.ledger).then((view) => dispatch('ai/ledger', view), (err) => dispatch('ai/failed', err?.message ?? String(err)));
179
+ },
180
+ // clearing the conversation clears what compaction archived FROM it:
181
+ // an archived round is a piece of a transcript, and its address only
182
+ // ever appeared in that transcript's synopsis. Keeping the rounds
183
+ // would leave the panel counting recoverable context for a
184
+ // conversation that no longer exists — and leave bytes in the store
185
+ // that nothing can ever name again. Memories and the goal survive:
186
+ // they are what was LEARNED, not what was said.
187
+ 'ai-clear-archive': (props, dispatch) => {
188
+ (typeof deps.ledger.clearArchives === 'function' ? deps.ledger.clearArchives()
189
+ : deps.ledger.listSlots().then((slots) => Promise.all(slots
190
+ .filter((slot) => slot.kind === 'agent-round' || slot.kind === 'agent-round-index')
191
+ .map((slot) => deps.ledger.deleteSlot(slot.name)))))
192
+ .then(() => readLedger(deps.ledger))
193
+ .then((view) => dispatch('ai/ledger', view), (err) => dispatch('ai/failed', err?.message ?? String(err)));
194
+ },
195
+ 'ai-goal-set': (props, dispatch) => {
196
+ const objective = deps.getApp().getState().ai.goalDraft.trim();
197
+ if (objective === '')
198
+ return;
199
+ deps.ledger.setGoal({ objective }).then((goal) => {
200
+ if (goal?.error !== undefined) {
201
+ dispatch('ai/failed', goal.error);
202
+ return;
203
+ }
204
+ // the draft is cleared by what comes back from the ledger, not by
205
+ // the action: the objective on screen is the stored one
206
+ return readLedger(deps.ledger).then((view) => dispatch('ai/goal-committed', view));
207
+ }, (err) => dispatch('ai/failed', err?.message ?? String(err)));
208
+ },
209
+ // "clear" abandons the objective rather than deleting it: the ledger
210
+ // keeps what this agent was asked to do, and the panel stops showing
211
+ // an objective nobody is working on
212
+ 'ai-goal-clear': (props, dispatch) => {
213
+ deps.ledger.setGoalStatus('abandoned')
214
+ .then(() => readLedger(deps.ledger))
215
+ .then((view) => dispatch('ai/ledger', view), (err) => dispatch('ai/failed', err?.message ?? String(err)));
216
+ },
217
+ // the refinement button: the model proposes an RFC 6902 patch over
218
+ // its own supplemental state, every stage of the gate runs, and what
219
+ // survives is committed. The patch engine is INJECTED here, exactly
220
+ // as @tangleai/agents requires — the package never imports @jarenjs/json.
221
+ 'ai-remember': (props, dispatch) => {
222
+ const state = deps.getApp().getState();
223
+ if (lastRun === null) {
224
+ dispatch('ai/remembered', { note: 'Nothing to remember yet — send a message first.' });
225
+ return;
226
+ }
227
+ if (!isConfigured(state.ai.settings)) {
228
+ dispatch('ai/remembered', { note: 'Add a provider, model and key in settings first.' });
229
+ return;
230
+ }
231
+ const client = clientFor(state, dispatch);
232
+ if (client === null)
233
+ return;
234
+ return createRefiner({
235
+ client,
236
+ ledger: deps.ledger,
237
+ applyPatch: (document, patch) => applyJSONPatch(document, patch),
238
+ }).refine(lastRun, { signal: lifetime.signal }).then((outcome) => {
239
+ const written = outcome.ok === true
240
+ ? outcome.memories.length + outcome.skills.length + outcome.progress.length
241
+ : 0;
242
+ dispatch('ai/remembered', {
243
+ note: outcome.ok !== true
244
+ ? `Nothing was stored — ${outcome.error}`
245
+ : written === 0
246
+ ? 'The assistant found nothing worth remembering from this session.'
247
+ : `Remembered ${written} evidenced item${written === 1 ? '' : 's'}.`,
248
+ });
249
+ return readLedger(deps.ledger).then((view) => dispatch('ai/ledger', view));
250
+ }, (err) => dispatch('ai/remembered', { note: err?.message ?? String(err) }));
251
+ },
252
+ 'ai-save-settings': async (_props, dispatch) => {
253
+ try {
254
+ if (await deps.aiStorage.write(deps.getApp().getState().ai.settings) === false)
255
+ throw new Error('Assistant settings could not be saved.');
256
+ }
257
+ catch (error) {
258
+ dispatch('ai/settings-open', true);
259
+ dispatch('ai/failed', error.message);
260
+ }
261
+ },
262
+ // the settings "Test connection" button: one /models probe with the
263
+ // exact auth a chat turn would use; the result object drives the
264
+ // status line and the model-name datalist
265
+ 'ai-probe': (props, dispatch) => {
266
+ const s = deps.getApp().getState().ai.settings;
267
+ return probeProvider({
268
+ provider: s.provider,
269
+ baseUrl: s.baseUrl,
270
+ apiKey: s.apiKey,
271
+ fetch: deps.aiFetch,
272
+ }).then((result) => dispatch('ai/probe-result', result.ok
273
+ ? {
274
+ status: 'ok',
275
+ detail: `Connected — ${result.models.length} model${result.models.length === 1 ? '' : 's'} available.`,
276
+ models: result.models.slice(0, 100),
277
+ }
278
+ : { status: 'fail', detail: result.error, models: [] }));
279
+ },
280
+ // opening the panel unconfigured lands you in settings — pinned
281
+ // open, so the form does not hide the moment typing a model name
282
+ // makes the configuration valid (only Save closes and persists it)
283
+ 'ai-ensure-settings': (props, dispatch) => {
284
+ const state = deps.getApp().getState();
285
+ if (state.ai.open && !isConfigured(state.ai.settings)) {
286
+ dispatch('ai/settings-open', true);
287
+ }
288
+ },
289
+ // the transcript mirror: every appended turn (and a clear) writes
290
+ // the visible messages through the injected store, so a reload
291
+ // resumes the conversation
292
+ 'ai-persist': async (_props, dispatch) => {
293
+ const messages = deps.getApp().getState().ai.messages;
294
+ try {
295
+ if (await deps.aiChat.write({ messages: messages.slice(-SAVED_WINDOW) }) === false)
296
+ throw new Error('The assistant transcript could not be saved.');
297
+ }
298
+ catch (error) {
299
+ dispatch('ai/failed', error.message);
300
+ }
301
+ },
302
+ };
303
+ const effects = Object.fromEntries(Object.entries(rawEffects).map(([name, effect]) => [name, (props, dispatch) => {
304
+ if (disposed)
305
+ return;
306
+ const version = epoch;
307
+ const publish = (action, payload) => { if (!disposed && version === epoch)
308
+ dispatch(action, payload); };
309
+ return effect(props, publish);
310
+ }]));
311
+ effects['ai-cancel'] = () => { if (!disposed)
312
+ cancel(); };
313
+ effects['ai-clear-run'] = () => { if (!disposed) {
314
+ cancel();
315
+ lastRun = null;
316
+ } };
317
+ return { effects, cancel, dispose, get signal() { return turnAbort?.signal ?? lifetime.signal; } };
322
318
  }
package/src/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { ASSISTANT_ACTIONS } from "./actions.js";
2
- export { createAssistantController } from "./controller.js";
3
- export { DEFAULT_AI_SETTINGS, createAssistantState } from "./state.js";
4
- export { PROVIDER_OPTIONS, isConfigured } from "./settings.js";
1
+ export { ASSISTANT_ACTIONS } from './actions.ts';
2
+ export { DEFAULT_AI_SETTINGS, createAssistantState } from './state.ts';
3
+ export { PROVIDER_OPTIONS, isConfigured } from './settings.ts';
4
+ export { createAssistantController } from './controller.ts';
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { ASSISTANT_ACTIONS } from './actions.js';
2
- export { DEFAULT_AI_SETTINGS, createAssistantState } from './state.js';
3
- export { PROVIDER_OPTIONS, isConfigured } from './settings.js';
4
- export { createAssistantController } from './controller.js';
1
+ export { ASSISTANT_ACTIONS } from "./actions.js";
2
+ export { DEFAULT_AI_SETTINGS, createAssistantState } from "./state.js";
3
+ export { PROVIDER_OPTIONS, isConfigured } from "./settings.js";
4
+ export { createAssistantController } from "./controller.js";
package/src/settings.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- export function isConfigured(s: any): boolean;
2
- export const PROVIDER_OPTIONS: {
1
+ export declare const PROVIDER_OPTIONS: {
3
2
  value: string;
4
3
  label: string;
5
4
  local: boolean;
6
5
  }[];
6
+ export declare function isConfigured(s: any): boolean;