@rimori/client 2.5.54 → 2.5.55

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.
@@ -1,9 +1,18 @@
1
1
  import { EventBusHandler, EventBusMessage } from '../fromRimori/EventBus';
2
2
  export type AccomplishmentMessage = EventBusMessage<MicroAccomplishmentPayload>;
3
3
  export declare const skillCategories: readonly ["reading", "listening", "speaking", "writing", "learning", "community"];
4
+ /**
5
+ * The single declaration of the accomplishment skill categories. Previously re-typed by hand in
6
+ * `useAccomplishmentChecker` (rimori-main) and `UseExerciseHook` (react-client), which could
7
+ * drift apart silently.
8
+ *
9
+ * Distinct from rimori-main's `SkillCategory` in `types/skill_progress.ts`: that one names the
10
+ * columns of the `skill_progress` table (…, 'understanding', 'grammar') and is a different set.
11
+ */
12
+ export type SkillCategory = (typeof skillCategories)[number];
4
13
  interface BaseAccomplishmentPayload {
5
14
  type: 'micro' | 'macro';
6
- skillCategory: (typeof skillCategories)[number];
15
+ skillCategory: SkillCategory;
7
16
  accomplishmentKeyword: string;
8
17
  description: string;
9
18
  meta?: {
@@ -35,12 +35,6 @@ export class AccomplishmentController {
35
35
  if (!['micro', 'macro'].includes(payload.type)) {
36
36
  throw new Error('Invalid accomplishment type ' + payload.type);
37
37
  }
38
- // disabled detection temporarelly to determine how long exercises normally are
39
- //durationMinutes is required
40
- // if (payload.type === 'macro' && payload.durationMinutes < 4) {
41
- // console.warn('The duration must be at least 4 minutes');
42
- // return false;
43
- // }
44
38
  //errorRatio is required
45
39
  if (payload.type === 'macro' && (payload.errorRatio < 0 || payload.errorRatio > 1)) {
46
40
  throw new Error('The error ratio must be between 0 and 1');
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The one validator for "does this value set satisfy an action's declared parameter schema?".
3
+ *
4
+ * There used to be a copy of this rule per caller — the dashboard's CreateExerciseDialog gated
5
+ * its Next button on it, and RouterHandler threw on it before navigating — which let them
6
+ * disagree about what counts as a provided value. They now share this.
7
+ *
8
+ * Note this is a *pre-flight* check, not a trust boundary: it runs in the browser, so anything
9
+ * server-side must re-validate rather than trust a caller that says it already did.
10
+ */
11
+ /** The subset of `ToolParameter` this check needs — kept structural so any superset fits. */
12
+ export interface ActionParameterSchema {
13
+ optional?: boolean;
14
+ }
15
+ /**
16
+ * Names of the required (non-`optional`) parameters that `values` does not provide.
17
+ *
18
+ * "Not provided" is `undefined`, `null` or the empty string only — `false` and `0` are real
19
+ * answers for a boolean/number parameter and must not read as missing.
20
+ */
21
+ export declare function findMissingActionParams(parameters: Record<string, ActionParameterSchema>, values: Record<string, unknown>): string[];
22
+ /** True when every required parameter of the action is provided. */
23
+ export declare function hasRequiredActionParams(parameters: Record<string, ActionParameterSchema>, values: Record<string, unknown>): boolean;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The one validator for "does this value set satisfy an action's declared parameter schema?".
3
+ *
4
+ * There used to be a copy of this rule per caller — the dashboard's CreateExerciseDialog gated
5
+ * its Next button on it, and RouterHandler threw on it before navigating — which let them
6
+ * disagree about what counts as a provided value. They now share this.
7
+ *
8
+ * Note this is a *pre-flight* check, not a trust boundary: it runs in the browser, so anything
9
+ * server-side must re-validate rather than trust a caller that says it already did.
10
+ */
11
+ /**
12
+ * Names of the required (non-`optional`) parameters that `values` does not provide.
13
+ *
14
+ * "Not provided" is `undefined`, `null` or the empty string only — `false` and `0` are real
15
+ * answers for a boolean/number parameter and must not read as missing.
16
+ */
17
+ export function findMissingActionParams(parameters, values) {
18
+ return Object.keys(parameters).filter((key) => {
19
+ if (parameters[key]?.optional)
20
+ return false;
21
+ const value = values[key];
22
+ return value === undefined || value === null || value === '';
23
+ });
24
+ }
25
+ /** True when every required parameter of the action is provided. */
26
+ export function hasRequiredActionParams(parameters, values) {
27
+ return findMissingActionParams(parameters, values).length === 0;
28
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './fromRimori/EventBus';
2
2
  export * from './plugin/RimoriClient';
3
3
  export * from './fromRimori/PluginTypes';
4
+ export * from './fromRimori/ActionParams';
4
5
  export * from './plugin/StandaloneClient';
5
6
  export * from './cli/types/DatabaseTypes';
6
7
  export * from './cli/types/PromptTypes';
@@ -12,15 +13,16 @@ export { setupWorker } from './worker/WorkerSetup';
12
13
  export type { EventBusMessage } from './fromRimori/EventBus';
13
14
  export { AudioController } from './controller/AudioController';
14
15
  export { voiceDebug } from './plugin/VoiceDebugStore';
15
- export type { Exercise } from './plugin/module/ExerciseModule';
16
+ export type { ExerciseRow, CreateExerciseRowParams, CompleteExerciseParams, EvidenceKind } from './plugin/module/ExerciseModule';
16
17
  export { Translator } from './controller/TranslationController';
17
- export type { TriggerAction } from './plugin/module/ExerciseModule';
18
- export type { Message, ToolInvocation } from './plugin/module/AIModule';
18
+ export type { Message, ToolInvocation, WireMessage, ToolCallContentPart, ToolResultContentPart, ToolResultOutput } from './plugin/module/AIModule';
19
+ export { toModelMessages } from './plugin/module/AIModule';
19
20
  export type { Theme, ApplicationMode } from './plugin/module/PluginModule';
20
21
  export type { UserInfo, Language, UserRole, SubscriptionTier, ExplicitUndefined, BasePluginSettings, Buddy } from './plugin/module/PluginModule';
21
22
  export { TIER_ORDER, ROLE_ORDER } from './plugin/module/PluginModule';
22
23
  export type { SharedContent, BasicSharedContent, ContentStatus } from './plugin/module/SharedContentController';
23
- export type { MacroAccomplishmentPayload, MicroAccomplishmentPayload } from './controller/AccomplishmentController';
24
+ export type { MacroAccomplishmentPayload, MicroAccomplishmentPayload, AccomplishmentPayload, SkillCategory, } from './controller/AccomplishmentController';
25
+ export { skillCategories } from './controller/AccomplishmentController';
24
26
  export { StorageModule } from './plugin/module/StorageModule';
25
27
  export { AssetsModule, type AssetKind } from './plugin/module/AssetsModule';
26
28
  export type { PublicityLevel } from './plugin/module/DbModule';
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  export * from './fromRimori/EventBus';
3
3
  export * from './plugin/RimoriClient';
4
4
  export * from './fromRimori/PluginTypes';
5
+ export * from './fromRimori/ActionParams';
5
6
  export * from './plugin/StandaloneClient';
6
7
  export * from './cli/types/DatabaseTypes';
7
8
  export * from './cli/types/PromptTypes';
@@ -12,6 +13,8 @@ export { setupWorker } from './worker/WorkerSetup';
12
13
  export { AudioController } from './controller/AudioController';
13
14
  export { voiceDebug } from './plugin/VoiceDebugStore';
14
15
  export { Translator } from './controller/TranslationController';
16
+ export { toModelMessages } from './plugin/module/AIModule';
15
17
  export { TIER_ORDER, ROLE_ORDER } from './plugin/module/PluginModule';
18
+ export { skillCategories } from './controller/AccomplishmentController';
16
19
  export { StorageModule } from './plugin/module/StorageModule';
17
20
  export { AssetsModule } from './plugin/module/AssetsModule';
package/dist/modules.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export * from './fromRimori/EventBus';
2
2
  export * from './plugin/module/AIModule';
3
3
  export * from './fromRimori/PluginTypes';
4
+ export * from './fromRimori/ActionParams';
5
+ export type { MacroAccomplishmentPayload, MicroAccomplishmentPayload, AccomplishmentPayload, SkillCategory, } from './controller/AccomplishmentController';
6
+ export { skillCategories } from './controller/AccomplishmentController';
4
7
  export * from './cli/types/DatabaseTypes';
5
8
  export * from './plugin/TTS/MessageSender';
package/dist/modules.js CHANGED
@@ -2,5 +2,7 @@
2
2
  export * from './fromRimori/EventBus';
3
3
  export * from './plugin/module/AIModule';
4
4
  export * from './fromRimori/PluginTypes';
5
+ export * from './fromRimori/ActionParams';
6
+ export { skillCategories } from './controller/AccomplishmentController';
5
7
  export * from './cli/types/DatabaseTypes';
6
8
  export * from './plugin/TTS/MessageSender';
@@ -31,6 +31,14 @@ export declare class RimoriClient {
31
31
  */
32
32
  static createWithInfo(info: RimoriInfo, applicationMode?: ApplicationMode): RimoriClient;
33
33
  static getInstance(pluginId?: string): Promise<RimoriClient>;
34
+ /**
35
+ * Resolves once no plugin operation that still has to emit on the event bus is in flight.
36
+ *
37
+ * The host (PluginRenderer) awaits this before tearing the plugin's event bus down, so a
38
+ * fire-and-forget mutation started just before the user navigated away still gets its event
39
+ * delivered. Extend this if another module ever gains an emit that happens *after* an await.
40
+ */
41
+ whenIdle(): Promise<void>;
34
42
  navigation: {
35
43
  toDashboard: () => void;
36
44
  };
@@ -89,6 +89,16 @@ export class RimoriClient {
89
89
  }
90
90
  return RimoriClient.instance;
91
91
  }
92
+ /**
93
+ * Resolves once no plugin operation that still has to emit on the event bus is in flight.
94
+ *
95
+ * The host (PluginRenderer) awaits this before tearing the plugin's event bus down, so a
96
+ * fire-and-forget mutation started just before the user navigated away still gets its event
97
+ * delivered. Extend this if another module ever gains an emit that happens *after* an await.
98
+ */
99
+ async whenIdle() {
100
+ await this.exercise.whenIdle();
101
+ }
92
102
  navigation = {
93
103
  toDashboard: () => {
94
104
  this.event.emit('global.navigation.triggerToDashboard');
@@ -35,6 +35,12 @@ export interface ToolInvocation {
35
35
  toolCallId: string;
36
36
  toolName: string;
37
37
  args: Record<string, string>;
38
+ /**
39
+ * The tool's own return value, recorded locally right after `execute()` resolves (see the
40
+ * `tool:` branch in `streamObject`). Optional so a call whose execution failed still
41
+ * round-trips as a call without a result rather than being dropped entirely.
42
+ */
43
+ result?: unknown;
38
44
  }
39
45
  export interface Message {
40
46
  id?: string;
@@ -43,6 +49,57 @@ export interface Message {
43
49
  toolCalls?: ToolInvocation[];
44
50
  }
45
51
  export type OnLLMResponse = (id: string, response: string, finished: boolean, toolInvocations?: ToolInvocation[]) => void;
52
+ /** Wire-format tool-call content part of an `assistant` message — mirrors the AI SDK's `ToolCallPart`. */
53
+ export interface ToolCallContentPart {
54
+ type: 'tool-call';
55
+ toolCallId: string;
56
+ toolName: string;
57
+ input: unknown;
58
+ }
59
+ /** Wire-format tagged output envelope the AI SDK requires on a tool result (bare values are rejected). */
60
+ export type ToolResultOutput = {
61
+ type: 'text';
62
+ value: string;
63
+ } | {
64
+ type: 'json';
65
+ value: unknown;
66
+ };
67
+ /** Wire-format tool-result content part of a `tool` message — mirrors the AI SDK's `ToolResultPart`. */
68
+ export interface ToolResultContentPart {
69
+ type: 'tool-result';
70
+ toolCallId: string;
71
+ toolName: string;
72
+ output: ToolResultOutput;
73
+ }
74
+ /**
75
+ * A message as it travels over the wire to `/ai/llm` — this is what the backend's `MessageDto`
76
+ * accepts. Distinct from `Message` (the client-side chat-history shape) because a single
77
+ * `Message` with N tool calls expands into 2N+1 wire messages (see `toModelMessages`).
78
+ */
79
+ export interface WireMessage {
80
+ id?: string;
81
+ role: 'user' | 'assistant' | 'system' | 'tool';
82
+ content: string | ToolCallContentPart[] | ToolResultContentPart[];
83
+ }
84
+ /**
85
+ * Expands client-side chat history into the flat wire format the backend/AI SDK expects.
86
+ * Every tool call on an assistant message becomes its own `assistant(tool-call)` +
87
+ * `tool(tool-result)` pair, in call order, immediately adjacent — providers reject a `tool`
88
+ * message that doesn't immediately follow the assistant message that issued its call, and the
89
+ * client has no reliable way to know the backend's original step boundaries, so 1:1 pairing is
90
+ * the only ordering that is always valid (the only fidelity loss is that calls that were
91
+ * genuinely parallel within one step replay as sequential ones, which is semantically
92
+ * irrelevant to the model).
93
+ *
94
+ * An assistant row with neither visible text nor tool calls is dropped — the AI SDK's message
95
+ * schema rejects it (this is the crash `UseChatHook.ts`'s `append` already filters out at the
96
+ * state layer; this is the second, defense-in-depth place, since `BuddyAssistant` does not
97
+ * filter its own state).
98
+ *
99
+ * Exported for direct unit testing — this is pure and the cheapest place to cover the wire
100
+ * format's edge cases (see packages/rimori-client/test).
101
+ */
102
+ export declare function toModelMessages(history: Message[]): WireMessage[];
46
103
  /**
47
104
  * Controller for AI-related operations.
48
105
  * Provides access to text generation, voice synthesis, and object generation.
@@ -1,3 +1,84 @@
1
+ /**
2
+ * How many of the most recent tool-calling turns keep their full tool-result payload when
3
+ * serialized. Older turns keep the call (so the model still "remembers that it looked") but
4
+ * have their result replaced with a short placeholder — tool results are the fastest-growing
5
+ * part of context (see ai.service.ts's per-step inputTokens log), and after this fix they
6
+ * accumulate for the whole conversation instead of being forgotten every turn, so trimming is
7
+ * load-bearing, not a nicety.
8
+ */
9
+ const FULL_FIDELITY_TOOL_RESULT_TURNS = 3;
10
+ function toToolResultOutput(result, keepFull) {
11
+ if (!keepFull) {
12
+ return {
13
+ type: 'text',
14
+ value: '(result omitted to save context — this tool was already called earlier in the conversation)',
15
+ };
16
+ }
17
+ if (result === undefined)
18
+ return { type: 'text', value: '(no result recorded)' };
19
+ if (typeof result === 'string')
20
+ return { type: 'text', value: result };
21
+ return { type: 'json', value: result };
22
+ }
23
+ /**
24
+ * Expands client-side chat history into the flat wire format the backend/AI SDK expects.
25
+ * Every tool call on an assistant message becomes its own `assistant(tool-call)` +
26
+ * `tool(tool-result)` pair, in call order, immediately adjacent — providers reject a `tool`
27
+ * message that doesn't immediately follow the assistant message that issued its call, and the
28
+ * client has no reliable way to know the backend's original step boundaries, so 1:1 pairing is
29
+ * the only ordering that is always valid (the only fidelity loss is that calls that were
30
+ * genuinely parallel within one step replay as sequential ones, which is semantically
31
+ * irrelevant to the model).
32
+ *
33
+ * An assistant row with neither visible text nor tool calls is dropped — the AI SDK's message
34
+ * schema rejects it (this is the crash `UseChatHook.ts`'s `append` already filters out at the
35
+ * state layer; this is the second, defense-in-depth place, since `BuddyAssistant` does not
36
+ * filter its own state).
37
+ *
38
+ * Exported for direct unit testing — this is pure and the cheapest place to cover the wire
39
+ * format's edge cases (see packages/rimori-client/test).
40
+ */
41
+ export function toModelMessages(history) {
42
+ const toolCallRowIndices = history
43
+ .map((m, i) => (m.toolCalls && m.toolCalls.length > 0 ? i : -1))
44
+ .filter((i) => i !== -1);
45
+ const fullFidelityIndices = new Set(toolCallRowIndices.slice(-FULL_FIDELITY_TOOL_RESULT_TURNS));
46
+ const out = [];
47
+ history.forEach((message, index) => {
48
+ const hasToolCalls = !!message.toolCalls && message.toolCalls.length > 0;
49
+ const hasText = typeof message.content === 'string' && message.content.length > 0;
50
+ if (!hasToolCalls) {
51
+ // A user row is always kept even if somehow empty; a text-less, tool-call-less
52
+ // assistant/system row is exactly the schema-invalid shape called out above.
53
+ if (!hasText && message.role !== 'user')
54
+ return;
55
+ out.push({ role: message.role, content: message.content });
56
+ return;
57
+ }
58
+ const keepFullResults = fullFidelityIndices.has(index);
59
+ for (const call of message.toolCalls) {
60
+ out.push({
61
+ role: 'assistant',
62
+ content: [{ type: 'tool-call', toolCallId: call.toolCallId, toolName: call.toolName, input: call.args }],
63
+ });
64
+ out.push({
65
+ role: 'tool',
66
+ content: [
67
+ {
68
+ type: 'tool-result',
69
+ toolCallId: call.toolCallId,
70
+ toolName: call.toolName,
71
+ output: toToolResultOutput(call.result, keepFullResults),
72
+ },
73
+ ],
74
+ });
75
+ }
76
+ if (hasText) {
77
+ out.push({ role: 'assistant', content: message.content });
78
+ }
79
+ });
80
+ return out;
81
+ }
1
82
  /**
2
83
  * Controller for AI-related operations.
3
84
  * Provides access to text generation, voice synthesis, and object generation.
@@ -55,14 +136,14 @@ export class AIModule {
55
136
  */
56
137
  async getText(params) {
57
138
  const { messages, tools, cache = false, prompt, variables } = params;
58
- const { result } = await this.streamObject({
139
+ const { data } = await this.streamObject({
59
140
  cache,
60
141
  tools,
61
142
  messages,
62
143
  prompt,
63
144
  variables,
64
145
  });
65
- return result;
146
+ return data.result;
66
147
  }
67
148
  /**
68
149
  * Stream text generation from messages using AI.
@@ -76,7 +157,7 @@ export class AIModule {
76
157
  async getStreamedText(params) {
77
158
  const { messages, onMessage, tools, cache = false, prompt, variables, } = params;
78
159
  const messageId = Math.random().toString(36).substring(3);
79
- const { result } = await this.streamObject({
160
+ const { data, toolExchanges } = await this.streamObject({
80
161
  cache,
81
162
  tools,
82
163
  messages,
@@ -84,8 +165,10 @@ export class AIModule {
84
165
  variables,
85
166
  onResult: ({ result }) => onMessage(messageId, result, false),
86
167
  });
87
- onMessage(messageId, result, true);
88
- return result;
168
+ // Only the final callback carries the turn's tool exchanges — intermediate `finished:
169
+ // false` calls fire on every partial chunk and should not churn the caller's tool state.
170
+ onMessage(messageId, data.result, true, toolExchanges.length > 0 ? toolExchanges : undefined);
171
+ return data.result;
89
172
  }
90
173
  /**
91
174
  * Generate voice audio from text using AI.
@@ -184,13 +267,14 @@ export class AIModule {
184
267
  */
185
268
  async getObject(params) {
186
269
  const { cache = false, tools = [], prompt, variables } = params;
187
- return await this.streamObject({
270
+ const { data } = await this.streamObject({
188
271
  messages: [],
189
272
  cache,
190
273
  tools,
191
274
  prompt,
192
275
  variables,
193
276
  });
277
+ return data;
194
278
  }
195
279
  /**
196
280
  * Generate a streamed structured object from a request using AI.
@@ -205,7 +289,7 @@ export class AIModule {
205
289
  */
206
290
  async getStreamedObject(params) {
207
291
  const { onResult, cache = false, tools = [], prompt, variables, settledOnly = false } = params;
208
- return await this.streamObject({
292
+ const { data } = await this.streamObject({
209
293
  messages: [],
210
294
  onResult,
211
295
  cache,
@@ -214,13 +298,21 @@ export class AIModule {
214
298
  variables,
215
299
  settledOnly,
216
300
  });
301
+ return data;
217
302
  }
218
303
  async streamObject(params) {
219
304
  const { messages, onResult = () => null, cache = false, tools = [], prompt, variables, settledOnly = false, } = params;
220
- const chatMessages = messages.map((message, index) => ({
305
+ // toModelMessages expands any tool calls in history into their own assistant/tool wire
306
+ // messages first (C9: ids are positional, so they must be assigned after expansion).
307
+ const chatMessages = toModelMessages(messages).map((message, index) => ({
221
308
  ...message,
222
309
  id: `${index + 1}`,
223
310
  }));
311
+ // Records each tool call this request makes together with the result the client computed
312
+ // for it, so the caller (getStreamedText → BuddyAssistant/useChat) can persist the pair into
313
+ // chat history — this is the only place that ever sees both halves together (see the `tool:`
314
+ // branch below).
315
+ const exchanges = [];
224
316
  const payload = {
225
317
  cache,
226
318
  tools,
@@ -284,7 +376,7 @@ export class AIModule {
284
376
  isLoading = false;
285
377
  onResult(currentObject, false);
286
378
  logFinalResult();
287
- return currentObject;
379
+ return { data: currentObject, toolExchanges: exchanges };
288
380
  }
289
381
  //the check needs to be behind readerDone because in closed connections the value is undefined
290
382
  if (!value)
@@ -333,7 +425,7 @@ export class AIModule {
333
425
  isLoading = false;
334
426
  onResult(currentObject, false);
335
427
  logFinalResult();
336
- return currentObject;
428
+ return { data: currentObject, toolExchanges: exchanges };
337
429
  }
338
430
  if (command === 'data:') {
339
431
  currentObject = JSON.parse(dataStr);
@@ -344,6 +436,7 @@ export class AIModule {
344
436
  const tool = tools.find((tool) => tool.name === toolName);
345
437
  if (tool && tool.execute) {
346
438
  const result = await tool.execute(args);
439
+ exchanges.push({ toolCallId, toolName, args, result });
347
440
  // Send the result to the backend
348
441
  await this.sendToolResult(toolCallId, result);
349
442
  }
@@ -366,12 +459,23 @@ export class AIModule {
366
459
  }
367
460
  }
368
461
  }
369
- return currentObject;
462
+ return { data: currentObject, toolExchanges: exchanges };
370
463
  }
371
464
  async sendToolResult(toolCallId, result) {
465
+ // A tool returning nothing used to be sent to the backend as the literal '[DONE]' sentinel,
466
+ // which the backend treats as an explicit "stop the conversation" signal and throws —
467
+ // turning a small tool bug into a dead chat. No tool in this codebase ever intentionally
468
+ // returns undefined to end the conversation, so instead send a non-empty error string the
469
+ // model can react to (e.g. apologize and retry) rather than silently killing the turn.
470
+ if (result === undefined || result === null || result === '') {
471
+ console.error(`Tool call ${toolCallId} returned an empty result — sending a fallback error instead.`);
472
+ }
473
+ const safeResult = result === undefined || result === null || result === ''
474
+ ? 'The tool call failed to produce a result. Tell the user something went wrong and suggest they try again.'
475
+ : result;
372
476
  await this.controller.fetchBackend('/ai/llm/tool_result', {
373
477
  method: 'POST',
374
- body: JSON.stringify({ toolCallId, result: result ?? '[DONE]' }),
478
+ body: JSON.stringify({ toolCallId, result: safeResult }),
375
479
  });
376
480
  }
377
481
  }
@@ -1,55 +1,101 @@
1
1
  import { SupabaseClient } from '../CommunicationHandler';
2
2
  import { RimoriCommunicationHandler, RimoriInfo } from '../CommunicationHandler';
3
3
  import { EventModule } from './EventModule';
4
- export type TriggerAction = {
5
- action_key: string;
6
- } & Record<string, string | number | boolean>;
7
- export interface CreateExerciseParams {
8
- plugin_id: string;
9
- start_date: string;
10
- end_date: string;
11
- trigger_action: TriggerAction;
12
- name: string;
13
- description: string;
14
- estimated_duration: number;
15
- achievement_topic: string;
16
- }
17
- export interface Exercise {
4
+ /**
5
+ * A row of `public.exercises` (see planning/exercise-system-simplification.md). Flat
6
+ * `plugin_id`/`action_key`/`params` replace the old `attributes` jsonb blob, and `status`
7
+ * replaces the achievement_event_log anti-join.
8
+ */
9
+ export interface ExerciseRow {
18
10
  id: string;
11
+ user_id: string;
19
12
  plugin_id: string;
13
+ action_key: string;
14
+ params: Record<string, string | number | boolean | undefined>;
15
+ name: string | null;
16
+ description: string | null;
20
17
  start_date: string;
21
18
  end_date: string;
22
- attributes: TriggerAction;
23
- achievement_topic: string;
24
- name: string;
25
- description: string;
26
- estimated_duration: number;
27
- created_at?: string;
28
- updated_at?: string;
19
+ status: 'pending' | 'completed' | 'expired';
20
+ completed_at: string | null;
21
+ created_at: string;
22
+ }
23
+ /** Body of `POST /exercises` — no achievement_topic, no skill category, no reward fields. */
24
+ export interface CreateExerciseRowParams {
25
+ pluginId: string;
26
+ actionKey: string;
27
+ params?: Record<string, string | number | boolean | undefined>;
28
+ name?: string;
29
+ description?: string;
30
+ /** ISO 8601 */
31
+ startDate: string;
32
+ /** ISO 8601 */
33
+ endDate: string;
29
34
  }
30
35
  /**
31
- * Controller for exercise-related operations.
32
- * Provides access to weekly exercises and exercise management.
36
+ * The kind of evidence an exercise page produces. Constant per page — never calculated from
37
+ * the run's outcome because it describes what the interaction *is* (recognising a word vs.
38
+ * producing one), not how well the user did.
39
+ */
40
+ export type EvidenceKind = 'recognition' | 'comprehension' | 'controlled' | 'production_written' | 'production_spoken';
41
+ /** Body of `POST /exercises/:id/complete` — measurements only, the id identifies the row. */
42
+ export interface CompleteExerciseParams {
43
+ errorRatio?: number;
44
+ durationMinutes?: number;
45
+ meta?: Record<string, unknown>;
46
+ /** The kind of evidence this result rests on. Required — a constant per exercise page. */
47
+ evidenceKind: EvidenceKind;
48
+ /** How many cards/questions/sentences the result rests on. */
49
+ itemCount?: number;
50
+ /** Named part-scores (0..1) where the page computes several and would otherwise discard them. */
51
+ components?: Record<string, number>;
52
+ }
53
+ /** Supabase-style result, per the repo's error-handling convention. */
54
+ type Result<T> = {
55
+ data: T;
56
+ error?: undefined;
57
+ } | {
58
+ data?: undefined;
59
+ error: Error;
60
+ };
61
+ /**
62
+ * Controller for exercise-related operations: reading the current week's open exercises,
63
+ * creating them, and marking them complete.
33
64
  */
34
65
  export declare class ExerciseModule {
35
66
  private supabase;
36
67
  private communicationHandler;
37
68
  private eventModule;
69
+ /**
70
+ * Mutations that still have to emit on the event bus after their backend round trip.
71
+ * The host tears the plugin's event bus down on unmount, so it drains this set first
72
+ * (see `whenIdle`) — otherwise a completion whose response lands after the user
73
+ * navigated away emits into a cleared bus and the dashboard never refetches.
74
+ */
75
+ private pending;
38
76
  constructor(supabase: SupabaseClient, communicationHandler: RimoriCommunicationHandler, _info: RimoriInfo, eventModule: EventModule);
39
77
  /**
40
- * Fetches weekly exercises from the weekly_exercises view.
41
- * Shows exercises for the current week that haven't expired.
42
- * @returns Array of exercise objects.
78
+ * Registers a mutation as in-flight for the duration of its promise. Every tracked method
79
+ * emits its bus event as the last statement before resolving, so a caller awaiting
80
+ * `whenIdle()` is guaranteed to run *after* the emit, not merely after the HTTP response.
81
+ */
82
+ private track;
83
+ /**
84
+ * Resolves once no mutation that still has to emit on the event bus is in flight.
85
+ * Loops because a tracked mutation may itself start another one while draining.
86
+ */
87
+ whenIdle(): Promise<void>;
88
+ /**
89
+ * Reads the current user's open exercises from the `weekly_exercises` view
90
+ * (`status = 'pending'`, inside this week's date window).
43
91
  */
44
- view(): Promise<Exercise[]>;
92
+ view(): Promise<Result<ExerciseRow[]>>;
45
93
  /**
46
- * Creates one or more exercises via the backend API.
47
- * Multiple exercises are sent in a single bulk request to ensure atomicity —
48
- * either all succeed or none are inserted.
49
- * @param params Exercise creation parameters (single or array).
50
- * @returns Created exercise objects.
94
+ * Creates one exercise. Single-row only callers that create a batch fan out, so a partial
95
+ * failure is possible (the old achievements-backed bulk endpoint was all-or-nothing).
51
96
  */
52
- add(params: CreateExerciseParams | CreateExerciseParams[]): Promise<Exercise[]>;
97
+ add(params: CreateExerciseRowParams): Promise<Result<ExerciseRow>>;
98
+ private addInternal;
53
99
  /**
54
100
  * Requests a new exercise session token from rimori-main.
55
101
  * Use this for self-initiated exercises (user navigated to plugin via navbar and clicked Start).
@@ -67,12 +113,15 @@ export declare class ExerciseModule {
67
113
  knowledgeId?: string;
68
114
  }): Promise<void>;
69
115
  /**
70
- * Deletes an exercise via the backend API.
71
- * @param id The exercise ID to delete.
72
- * @returns Success status.
116
+ * Marks an exercise completed. The measurements are reported on the completion but are not
117
+ * stored on the exercise row they travel on the backend's `exercise.completed` event.
73
118
  */
74
- delete(id: string): Promise<{
75
- success: boolean;
76
- message: string;
77
- }>;
119
+ complete(exerciseId: string, params: CompleteExerciseParams): Promise<Result<ExerciseRow>>;
120
+ private completeInternal;
121
+ /** Deletes an exercise. */
122
+ delete(id: string): Promise<Result<{
123
+ success: true;
124
+ }>>;
125
+ private deleteInternal;
78
126
  }
127
+ export {};
@@ -1,48 +1,73 @@
1
1
  /**
2
- * Controller for exercise-related operations.
3
- * Provides access to weekly exercises and exercise management.
2
+ * Controller for exercise-related operations: reading the current week's open exercises,
3
+ * creating them, and marking them complete.
4
4
  */
5
5
  export class ExerciseModule {
6
6
  supabase;
7
7
  communicationHandler;
8
8
  eventModule;
9
+ /**
10
+ * Mutations that still have to emit on the event bus after their backend round trip.
11
+ * The host tears the plugin's event bus down on unmount, so it drains this set first
12
+ * (see `whenIdle`) — otherwise a completion whose response lands after the user
13
+ * navigated away emits into a cleared bus and the dashboard never refetches.
14
+ */
15
+ pending = new Set();
9
16
  constructor(supabase, communicationHandler, _info, eventModule) {
10
17
  this.supabase = supabase;
11
18
  this.communicationHandler = communicationHandler;
12
19
  this.eventModule = eventModule;
13
20
  }
14
21
  /**
15
- * Fetches weekly exercises from the weekly_exercises view.
16
- * Shows exercises for the current week that haven't expired.
17
- * @returns Array of exercise objects.
22
+ * Registers a mutation as in-flight for the duration of its promise. Every tracked method
23
+ * emits its bus event as the last statement before resolving, so a caller awaiting
24
+ * `whenIdle()` is guaranteed to run *after* the emit, not merely after the HTTP response.
25
+ */
26
+ track(operation) {
27
+ this.pending.add(operation);
28
+ // Swallow here only to keep the bookkeeping alive — the original promise is returned
29
+ // untouched, so the caller still sees any rejection.
30
+ void operation.catch(() => { }).finally(() => this.pending.delete(operation));
31
+ return operation;
32
+ }
33
+ /**
34
+ * Resolves once no mutation that still has to emit on the event bus is in flight.
35
+ * Loops because a tracked mutation may itself start another one while draining.
36
+ */
37
+ async whenIdle() {
38
+ while (this.pending.size > 0) {
39
+ await Promise.allSettled([...this.pending]);
40
+ }
41
+ }
42
+ /**
43
+ * Reads the current user's open exercises from the `weekly_exercises` view
44
+ * (`status = 'pending'`, inside this week's date window).
18
45
  */
19
46
  async view() {
20
47
  const { data, error } = await this.supabase.schema('public').from('weekly_exercises').select('*');
21
48
  if (error) {
22
- throw new Error(`Failed to fetch weekly exercises: ${error.message}`);
49
+ return { error: new Error(`Failed to fetch weekly exercises: ${error.message}`) };
23
50
  }
24
- return data || [];
51
+ return { data: (data ?? []) };
25
52
  }
26
53
  /**
27
- * Creates one or more exercises via the backend API.
28
- * Multiple exercises are sent in a single bulk request to ensure atomicity —
29
- * either all succeed or none are inserted.
30
- * @param params Exercise creation parameters (single or array).
31
- * @returns Created exercise objects.
54
+ * Creates one exercise. Single-row only callers that create a batch fan out, so a partial
55
+ * failure is possible (the old achievements-backed bulk endpoint was all-or-nothing).
32
56
  */
33
57
  async add(params) {
34
- const exercises = Array.isArray(params) ? params : [params];
58
+ return this.track(this.addInternal(params));
59
+ }
60
+ async addInternal(params) {
35
61
  const response = await this.communicationHandler.fetchBackend('/exercises', {
36
62
  method: 'POST',
37
- body: JSON.stringify({ exercises }),
63
+ body: JSON.stringify(params),
38
64
  });
39
65
  if (!response.ok) {
40
- const errorText = await response.text();
41
- throw new Error(`Failed to create exercises: ${errorText}`);
66
+ return { error: new Error(`Failed to create exercise: ${await response.text()}`) };
42
67
  }
43
68
  const data = await response.json();
44
69
  this.eventModule.emit('global.exercises.triggerChange');
45
- return data;
70
+ return { data };
46
71
  }
47
72
  /**
48
73
  * Requests a new exercise session token from rimori-main.
@@ -73,19 +98,36 @@ export class ExerciseModule {
73
98
  });
74
99
  }
75
100
  /**
76
- * Deletes an exercise via the backend API.
77
- * @param id The exercise ID to delete.
78
- * @returns Success status.
101
+ * Marks an exercise completed. The measurements are reported on the completion but are not
102
+ * stored on the exercise row they travel on the backend's `exercise.completed` event.
79
103
  */
104
+ async complete(exerciseId, params) {
105
+ return this.track(this.completeInternal(exerciseId, params));
106
+ }
107
+ async completeInternal(exerciseId, params) {
108
+ const response = await this.communicationHandler.fetchBackend(`/exercises/${exerciseId}/complete`, {
109
+ method: 'POST',
110
+ body: JSON.stringify(params),
111
+ });
112
+ if (!response.ok) {
113
+ return { error: new Error(`Failed to complete exercise: ${await response.text()}`) };
114
+ }
115
+ const data = await response.json();
116
+ this.eventModule.emit('global.exercises.triggerChange');
117
+ return { data };
118
+ }
119
+ /** Deletes an exercise. */
80
120
  async delete(id) {
121
+ return this.track(this.deleteInternal(id));
122
+ }
123
+ async deleteInternal(id) {
81
124
  const response = await this.communicationHandler.fetchBackend(`/exercises/${id}`, {
82
125
  method: 'DELETE',
83
126
  });
84
127
  if (!response.ok) {
85
- const errorText = await response.text();
86
- throw new Error(`Failed to delete exercise: ${errorText}`);
128
+ return { error: new Error(`Failed to delete exercise: ${await response.text()}`) };
87
129
  }
88
130
  this.eventModule.emit('global.exercises.triggerChange');
89
- return await response.json();
131
+ return { data: { success: true } };
90
132
  }
91
133
  }
@@ -212,4 +212,12 @@ export interface UserInfo {
212
212
  * Undefined otherwise. Plugins decide how to use this on a per-feature basis.
213
213
  */
214
214
  dialect?: string;
215
+ /**
216
+ * True only while this render was triggered by a shared-exercise link (`/s/:id`) whose
217
+ * visitor's own tier doesn't clear the plugin's gate. UI-only fast-path signal — see
218
+ * `useTierGate` and the `shared_link_id` reserved key on `MainPanelAction`. The backend
219
+ * independently re-verifies the exercise session's shared_link_id before allowing the
220
+ * actual AI call, so this flag grants no access on its own.
221
+ */
222
+ via_shared_link?: boolean;
215
223
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimori/client",
3
- "version": "2.5.54",
3
+ "version": "2.5.55",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "repository": {