@rimori/client 2.5.48 → 2.5.49

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.
@@ -0,0 +1,9 @@
1
+ import { Config } from './release';
2
+ /**
3
+ * Read `rimori/rimori.config.ts` and store it on the plugin's `plugin` row, so the backend has
4
+ * a live source of truth for the plugin's pages and actions (see planning/plugin-config-in-db.md).
5
+ *
6
+ * Unlike db.config.ts / prompts.config.ts (both optional and skipped when absent),
7
+ * rimori.config.ts is mandatory — a plugin without one cannot be released.
8
+ */
9
+ export default function configUpload(config: Config): Promise<void>;
@@ -0,0 +1,63 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import ts from 'typescript';
4
+ /**
5
+ * Read `rimori/rimori.config.ts` and store it on the plugin's `plugin` row, so the backend has
6
+ * a live source of truth for the plugin's pages and actions (see planning/plugin-config-in-db.md).
7
+ *
8
+ * Unlike db.config.ts / prompts.config.ts (both optional and skipped when absent),
9
+ * rimori.config.ts is mandatory — a plugin without one cannot be released.
10
+ */
11
+ export default async function configUpload(config) {
12
+ const configPath = path.resolve('./rimori/rimori.config.ts');
13
+ try {
14
+ await fs.promises.access(configPath);
15
+ }
16
+ catch {
17
+ throw new Error('Could not find rimori.config.ts in ./rimori/ directory');
18
+ }
19
+ const configContent = await fs.promises.readFile(configPath, 'utf8');
20
+ // The `import type { RimoriPluginConfig } from '@rimori/client'` at the top of every plugin
21
+ // config is elided by ts.transpile (type-only), which is why importing the transpiled file
22
+ // works without resolving @rimori/client from the plugin's directory.
23
+ const transpiled = ts.transpile(configContent, {
24
+ target: ts.ScriptTarget.ES2020,
25
+ module: ts.ModuleKind.ES2020,
26
+ });
27
+ const tempFile = path.join(process.cwd(), 'temp_rimori_config.js');
28
+ await fs.promises.writeFile(tempFile, transpiled);
29
+ let configObject;
30
+ try {
31
+ const imported = await import(`file://${tempFile}`);
32
+ configObject = imported.default;
33
+ }
34
+ finally {
35
+ await fs.promises.unlink(tempFile).catch(() => { });
36
+ }
37
+ if (!configObject) {
38
+ throw new Error('rimori.config.ts has no default export');
39
+ }
40
+ console.log('⚙️ Sending plugin configuration...');
41
+ const response = await fetch(`${config.domain}/release/config`, {
42
+ method: 'POST',
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ Authorization: `Bearer ${config.token}`,
46
+ },
47
+ body: JSON.stringify({
48
+ plugin_id: config.plugin_id,
49
+ config: configObject,
50
+ version: config.version,
51
+ }),
52
+ }).catch((e) => {
53
+ console.log('error', e);
54
+ throw new Error('Error sending plugin configuration');
55
+ });
56
+ const responseText = await response.text();
57
+ if (!response.ok) {
58
+ // The release-time validation lives in syncConfig() and its message names the offending
59
+ // page/action, so surface it verbatim rather than a generic failure.
60
+ throw new Error(`Plugin configuration rejected (${response.status}): ${responseText}`);
61
+ }
62
+ console.log('✅ Plugin configuration deployed successfully!');
63
+ }
@@ -13,6 +13,7 @@
13
13
  import 'dotenv/config';
14
14
  import fs from 'fs';
15
15
  import path from 'path';
16
+ import configUpload from './release-config-upload.js';
16
17
  import dbUpdate from './release-db-update.js';
17
18
  import promptsUpload from './release-prompts-upload.js';
18
19
  // Read version from package.json
@@ -59,6 +60,9 @@ async function releaseProcess() {
59
60
  console.log(`🚀 Releasing ${config.plugin_id} to ${config.release_channel}...`);
60
61
  }
61
62
  console.log(`📡 Deploying to: ${config.domain}`);
63
+ // Upload rimori.config.ts first: it upserts the `plugin` row, so the db sync below is
64
+ // guaranteed to find a row to write `db_schema` onto. Mandatory — hard-fails if missing.
65
+ await configUpload(config);
62
66
  // Upload prompts (if prompts.config.ts exists)
63
67
  await promptsUpload(config);
64
68
  // Migrate tables (if db.config.ts exists)
@@ -1,3 +1,4 @@
1
+ import { SubscriptionTier } from '../../plugin/module/PluginModule';
1
2
  /**
2
3
  * Formatting options for string array variables when rendered into prompts.
3
4
  */
@@ -75,4 +76,6 @@ export interface PromptDefinition {
75
76
  schema?: Record<string, any>;
76
77
  tools?: any[];
77
78
  model?: string;
79
+ /** Minimum subscription tier allowed to invoke this prompt. Defaults to 'free' when unset. */
80
+ minTier?: SubscriptionTier;
78
81
  }
@@ -1,3 +1 @@
1
- // Prompt configuration type definitions
2
- // Used by plugins in their rimori/prompts.config.ts files
3
1
  export {};
@@ -27,6 +27,22 @@ export interface PluginPage {
27
27
  action?: {
28
28
  key: string;
29
29
  parameters: ObjectTool;
30
+ /**
31
+ * Minimum subscription tier a user must hold to run this exercise. Defaults to 'free'.
32
+ *
33
+ * UI / pre-flight only — the enforcement boundary is the server-side `minTier` on the
34
+ * prompts this action invokes (checked in the backend's ai.controller). Keep the two
35
+ * consistent: this must never be *above* the prompts' minTier, or the UI would block an
36
+ * exercise the backend would happily run.
37
+ *
38
+ * Drives two things in rimori-main: the "account required" badge in the create-exercise
39
+ * picker, and whether the public (anonymous) share toggle is offered — anonymous visitors
40
+ * are granted 'free', so only 'free' actions can be shared publicly.
41
+ *
42
+ * Union is inlined rather than importing SubscriptionTier from plugin/module/PluginModule,
43
+ * which imports this file (circular).
44
+ */
45
+ min_tier?: 'anonymous' | 'free' | 'standard' | 'premium' | 'early_access';
30
46
  supportive_tools?: {
31
47
  key: string;
32
48
  event: string;
@@ -38,8 +38,10 @@ export interface RimoriInfo {
38
38
  * Determined by rimori-main based on release channel:
39
39
  * - 'plugins_alpha' for alpha release channel
40
40
  * - 'plugins' for stable release channel
41
+ * - 'public' for rimori-main's own natively-absorbed features (e.g. studyplan), which own
42
+ * plain `public` tables namespaced by `tablePrefix` instead of a plugin-scoped schema.
41
43
  */
42
- dbSchema: 'plugins' | 'plugins_alpha';
44
+ dbSchema: 'plugins' | 'plugins_alpha' | 'public';
43
45
  /**
44
46
  * Whether text-to-speech is enabled globally (set in rimori-main navbar).
45
47
  */
@@ -31,8 +31,10 @@ export class RimoriClient {
31
31
  this.eventBus = eventBus ?? EventBus;
32
32
  this.sharedContent = new SharedContentController(supabase, this);
33
33
  this.ai = new AIModule(controller);
34
- this.ai.setOnRateLimited((exercisesRemaining) => {
35
- this.eventBus.emit(info.pluginId, 'global.quota.triggerExceeded', { exercises_remaining: exercisesRemaining });
34
+ // Fallback for gated prompts even when a plugin's own useTierGate check is missed or
35
+ // bypassed — mirrors PluginModule.requestUpgrade so both paths open the same modal.
36
+ this.ai.setOnTierGateBlocked((requiredTier) => {
37
+ this.eventBus.emit(info.pluginId, 'global.subscription.triggerUpgrade', { requiredTier });
36
38
  });
37
39
  this.event = new EventModule(info.pluginId, this.ai, this.eventBus);
38
40
  this.db = new DbModule(supabase, controller, info);
@@ -50,7 +50,7 @@ export type OnLLMResponse = (id: string, response: string, finished: boolean, to
50
50
  export declare class AIModule {
51
51
  private controller;
52
52
  private sessionTokenId;
53
- private onRateLimitedCb?;
53
+ private onTierGateBlockedCb?;
54
54
  constructor(controller: RimoriCommunicationHandler);
55
55
  /**
56
56
  * Resolves a prompt name following the event naming convention:
@@ -67,8 +67,12 @@ export declare class AIModule {
67
67
  /** Clears the stored session token. */
68
68
  clear: () => void;
69
69
  };
70
- /** Registers a callback invoked whenever a 429 rate-limit response is received. */
71
- setOnRateLimited(cb: (exercisesRemaining: number) => void): void;
70
+ /**
71
+ * Registers a callback invoked whenever the backend refuses a prompt call with 403 because
72
+ * the caller's subscription tier is below the prompt's `minTier`. This is the fallback path
73
+ * for gated prompts even when a plugin's own UI gate (useTierGate) was missed or bypassed.
74
+ */
75
+ setOnTierGateBlocked(cb: (requiredTier: string) => void): void;
72
76
  /**
73
77
  * Generate text from messages using AI.
74
78
  * @param params.messages The messages to generate text from.
@@ -109,7 +113,9 @@ export declare class AIModule {
109
113
  * @param speed The speed of the voice (default: 1).
110
114
  * @param language Optional language for the voice.
111
115
  * @param cache Whether to cache the result (default: false).
112
- * @returns The generated audio as a Blob.
116
+ * @returns The generated audio as a Blob. Free/anonymous tier gets voice silently truncated
117
+ * to a few words server-side (see voice.service.ts truncateForFreeTier) — the response shape
118
+ * here stays byte-identical for every tier, no flag or signal is exposed to the frontend.
113
119
  *
114
120
  * **Empty input:** If `text` is empty or whitespace-only, no network request is
115
121
  * made and an empty `Blob` is returned immediately. This prevents a 400 error
@@ -137,7 +143,9 @@ export declare class AIModule {
137
143
  * Convert voice audio to text using AI.
138
144
  * @param file The audio file to convert.
139
145
  * @param language Optional language for the voice.
140
- * @returns The transcribed text.
146
+ * @returns The transcribed text. Free/anonymous tier gets the transcript silently truncated
147
+ * to a few words server-side (see voice.service.ts truncateForFreeTier) — no flag or signal
148
+ * is exposed to the frontend.
141
149
  */
142
150
  getTextFromVoice(file: Blob, language?: Language): Promise<string>;
143
151
  /**
@@ -5,7 +5,7 @@
5
5
  export class AIModule {
6
6
  controller;
7
7
  sessionTokenId = null;
8
- onRateLimitedCb;
8
+ onTierGateBlockedCb;
9
9
  constructor(controller) {
10
10
  this.controller = controller;
11
11
  }
@@ -36,9 +36,13 @@ export class AIModule {
36
36
  this.sessionTokenId = null;
37
37
  },
38
38
  };
39
- /** Registers a callback invoked whenever a 429 rate-limit response is received. */
40
- setOnRateLimited(cb) {
41
- this.onRateLimitedCb = cb;
39
+ /**
40
+ * Registers a callback invoked whenever the backend refuses a prompt call with 403 because
41
+ * the caller's subscription tier is below the prompt's `minTier`. This is the fallback path
42
+ * for gated prompts even when a plugin's own UI gate (useTierGate) was missed or bypassed.
43
+ */
44
+ setOnTierGateBlocked(cb) {
45
+ this.onTierGateBlockedCb = cb;
42
46
  }
43
47
  /**
44
48
  * Generate text from messages using AI.
@@ -90,7 +94,9 @@ export class AIModule {
90
94
  * @param speed The speed of the voice (default: 1).
91
95
  * @param language Optional language for the voice.
92
96
  * @param cache Whether to cache the result (default: false).
93
- * @returns The generated audio as a Blob.
97
+ * @returns The generated audio as a Blob. Free/anonymous tier gets voice silently truncated
98
+ * to a few words server-side (see voice.service.ts truncateForFreeTier) — the response shape
99
+ * here stays byte-identical for every tier, no flag or signal is exposed to the frontend.
94
100
  *
95
101
  * **Empty input:** If `text` is empty or whitespace-only, no network request is
96
102
  * made and an empty `Blob` is returned immediately. This prevents a 400 error
@@ -143,7 +149,9 @@ export class AIModule {
143
149
  * Convert voice audio to text using AI.
144
150
  * @param file The audio file to convert.
145
151
  * @param language Optional language for the voice.
146
- * @returns The transcribed text.
152
+ * @returns The transcribed text. Free/anonymous tier gets the transcript silently truncated
153
+ * to a few words server-side (see voice.service.ts truncateForFreeTier) — no flag or signal
154
+ * is exposed to the frontend.
147
155
  */
148
156
  async getTextFromVoice(file, language) {
149
157
  const formData = new FormData();
@@ -159,10 +167,7 @@ export class AIModule {
159
167
  body: formData,
160
168
  })
161
169
  .then((r) => r.json())
162
- .then((r) => {
163
- // console.log("STT response: ", r);
164
- return r.text;
165
- });
170
+ .then((r) => r.text);
166
171
  }
167
172
  /**
168
173
  * Generate a structured object from a request using AI.
@@ -227,11 +232,11 @@ export class AIModule {
227
232
  body: JSON.stringify(payload),
228
233
  });
229
234
  if (!response.ok) {
230
- if (response.status === 429) {
235
+ if (response.status === 403) {
231
236
  const body = await response.json().catch(() => ({}));
232
- const remaining = body.exercises_remaining ?? 0;
233
- this.onRateLimitedCb?.(remaining);
234
- throw new Error(`Rate limit exceeded: ${body.error ?? 'Daily exercise limit reached'}. exercises_remaining: ${remaining}`);
237
+ const requiredTier = body.required_tier ?? 'standard';
238
+ this.onTierGateBlockedCb?.(requiredTier);
239
+ throw new Error(`Upgrade required: ${body.error ?? 'Upgrade required for this feature'}. required_tier: ${requiredTier}`);
235
240
  }
236
241
  throw new Error(`Failed to stream object: ${response.status} ${response.statusText}`);
237
242
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimori/client",
3
- "version": "2.5.48",
3
+ "version": "2.5.49",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "repository": {