@jskit-ai/assistant-core 0.2.27 → 0.2.29

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/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Claude's model list is kept once per account in the server process instead of once per account-query owner, as plan usage already is: an owner the host creates again (a restarted service, a reopened project) answers `readCatalogue` from the list another owner read, without starting a tool-free Claude, while the account's credentials and Claude Code are unchanged. A credentials change (sign-in, sign-out, token refresh) or a Claude Code update still reads it again, and an owner's `invalidate()` drops the list of every account it used, for every owner.
6
+ - Test fixture only: the responsive assistant fixture's avatar is drawn in the theme's `primary` and `on-primary` roles instead of a raw `#6750a4` and `white`, so it follows the dark scheme like the rest of the conversation. A package test runs `jskit check m3`'s raw-colour rule over the fixture, which the check itself skips with every `fixtures/` folder.
7
+ - The assistant settings resource (`assistantConfigResource`) has a required boolean `canUpdate` attribute: whether the signed-in person may change the settings, as the server decides (`@jskit-ai/assistant-runtime`'s settings pane shows Save only when it is true).
8
+ - **Breaking (data-access redesign).** The assistant operations are API contracts: `assistantContract` and `assistantConfigContract` (view and patch, the two its routes serve), which replace `assistantResource` and `assistantConfigResource`.
5
9
  - The OpenAI SDK loads with an OpenAI-compatible client's first completion instead of with the server, so a boot of an app with the assistant loads 154 fewer modules (0.13 s on their own).
6
10
  - A subscriber hears a streamed delta (an in-progress message) on an authorization under a second old instead of authorizing again for each one: every delta used to wait for one `authorize` call per subscriber before the stream continued (one SELECT each in `@jskit-ai/assistant-runtime`), so a 200-delta reply to one open page cost about 200 reads and now costs as many as a 10-delta one. Every other event (admission, phase, saved message, settlement) still authorizes again, and a revoked subscriber stops hearing the turn within a second.
7
11
  - The conversation's remaining boxes are Material 3 tonal containers instead of outlines, visual only: queued and sent attachments and the questions are `surface-container-high`, code blocks and inline code `surface-container-highest`, tables and details `surface-container-low` (a table keeps `outline-variant` row dividers), question numbers `secondary-container` badges, and the model menu a `surface-container` surface at elevation level 2 with `outline-variant` dividers. The composer stays Material 3's outlined text field, the only outline left.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.27",
12
- "@jskit-ai/http-runtime": "0.2.13",
13
- "@jskit-ai/kernel": "0.2.13",
11
+ "@jskit-ai/assistant-core": "0.2.29",
12
+ "@jskit-ai/http-runtime": "0.2.15",
13
+ "@jskit-ai/kernel": "0.2.15",
14
14
  "@jskit-ai/material-web": "0.1.3",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.2.14",
18
- "@jskit-ai/connectors-catalog": "0.2.14"
17
+ "@jskit-ai/connectors-core": "0.2.16",
18
+ "@jskit-ai/connectors-catalog": "0.2.16"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
@@ -201,7 +201,7 @@ const adapter = reactive({
201
201
  </div>
202
202
  <main class="fixture" :class="{ narrow, 'fixture--avatar': avatarFixture, 'fixture--short': short }">
203
203
  <AssistantConversationElement v-model:avatar-size="avatarSize" :adapter="adapter">
204
- <template v-if="showAvatar" #avatar><svg viewBox="0 0 100 100" role="img" aria-label="Support assistant avatar"><circle cx="50" cy="50" r="45" fill="#6750a4" /><path d="M30 62Q50 80 70 62M35 40h1m28 0h1" fill="none" stroke="white" stroke-width="6" stroke-linecap="round" /></svg></template>
204
+ <template v-if="showAvatar" #avatar><svg viewBox="0 0 100 100" role="img" aria-label="Support assistant avatar"><circle class="fixture-avatar__face" cx="50" cy="50" r="45" /><path class="fixture-avatar__features" d="M30 62Q50 80 70 62M35 40h1m28 0h1" fill="none" stroke-width="6" stroke-linecap="round" /></svg></template>
205
205
  <template v-if="avatarToolsFixture" #avatar-tools="scope">
206
206
  <button class="fixture-avatar-tool" aria-label="Avatar tool" :data-size="scope.size" :data-height="scope.height">Tool</button>
207
207
  </template>
@@ -224,5 +224,8 @@ const adapter = reactive({
224
224
  .fixture--avatar { width: min(320px, 100%); }
225
225
  .fixture-avatar-tool { flex: 0 0 48px; min-width: 48px; min-height: 48px; }
226
226
  .fixture--short { height: 420px; }
227
+ /* The avatar is drawn in the theme's primary roles, so it follows the light and dark schemes. */
228
+ .fixture-avatar__face { fill: rgb(var(--v-theme-primary)); }
229
+ .fixture-avatar__features { stroke: rgb(var(--v-theme-on-primary)); }
227
230
  .feedback { flex: 1 1 24rem; min-width: 0; margin: 0; }
228
231
  </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "jskit test"
@@ -52,8 +52,7 @@
52
52
  "@ai-sdk/togetherai": "^3.0.49",
53
53
  "@ai-sdk/xai": "^4.0.58",
54
54
  "@jskit-ai/agent": "0.1.2",
55
- "@jskit-ai/resource-core": "0.1.204",
56
- "@jskit-ai/resource-crud-core": "0.1.204",
55
+ "@jskit-ai/resource-core": "0.1.206",
57
56
  "@openrouter/ai-sdk-provider": "^3.0.0",
58
57
  "ai": "^7.0.152",
59
58
  "json-rest-schema": "^1.0.17",
@@ -61,8 +60,8 @@
61
60
  "ws": "^8.20.1"
62
61
  },
63
62
  "peerDependencies": {
64
- "@jskit-ai/http-runtime": "0.2.13",
65
- "@jskit-ai/kernel": "0.2.13",
63
+ "@jskit-ai/http-runtime": "0.2.15",
64
+ "@jskit-ai/kernel": "0.2.15",
66
65
  "vue": "^3.5.13",
67
66
  "vuetify": "^4.3.0"
68
67
  },
@@ -290,6 +290,10 @@ const PLAN_USAGE_CURRENT_MS = 420_000;
290
290
  // for the account shares it, so however many ask, the account's one-shot Claude
291
291
  // starts at most once per idle interval.
292
292
  const planUsageReads = new Map();
293
+ // One model list per Claude account in this server process, with the credentials
294
+ // and Claude Code revision it answered for. An owner the host creates again (a
295
+ // restarted service, a reopened project) reads it without starting a Claude.
296
+ const catalogueReads = new Map();
293
297
 
294
298
  /**
295
299
  * Claude Code answers `get_usage` from the usage endpoint, or from its saved copy
@@ -320,9 +324,10 @@ export function claudeCatalogueModels(initialization) {
320
324
 
321
325
  /**
322
326
  * Native account queries share process reuse, caches and retryable cleanup. Plan
323
- * usage is shared by `accountIdentity` with every other owner in the process, so
324
- * that identity names exactly one Claude account. `configRoot` is the account's
325
- * Claude configuration directory, whose credentials the model list follows.
327
+ * usage and the model list are shared by `accountIdentity` with every other owner
328
+ * in the process, so that identity names exactly one Claude account. `configRoot`
329
+ * is the account's Claude configuration directory, whose credentials the model
330
+ * list follows.
326
331
  * @param {object} options
327
332
  * @param {(request: { signal: AbortSignal, purpose: string }) => Promise<ClaudeCodeProcess>} options.createProcess
328
333
  * @param {(context: Record<string, any>) => Promise<string>} options.accountIdentity
@@ -342,7 +347,8 @@ export function createClaudeAccountQueries({
342
347
  });
343
348
  const accountProcessStops = new Set();
344
349
  const accountReads = new Set();
345
- let catalog;
350
+ // Accounts whose shared model list this owner used, and the reads it started.
351
+ const catalogueAccounts = new Set();
346
352
  const catalogStarts = new Map();
347
353
  // Accounts whose shared plan-usage reading this owner used, and the reads it started.
348
354
  const usageAccounts = new Set();
@@ -409,8 +415,10 @@ export function createClaudeAccountQueries({
409
415
  const identity = await accountIdentity(context);
410
416
  assertCurrent(expectedRevision);
411
417
  // An answer is kept until its account, credentials or executable change, so
412
- // reading it again starts no Claude.
413
- const kept = catalog?.identity === identity ? catalog : null;
418
+ // reading it again, from this owner or any other, starts no Claude.
419
+ const catalogueKey = JSON.stringify([configRoot, identity]);
420
+ catalogueAccounts.add(catalogueKey);
421
+ const kept = catalogueReads.get(catalogueKey);
414
422
  if (kept && kept.revision === await catalogueRevision(kept.command)) {
415
423
  assertCurrent(expectedRevision);
416
424
  return kept.value;
@@ -425,7 +433,7 @@ export function createClaudeAccountQueries({
425
433
  messageCode: "assistant.claude.accountChangedRefreshModels"
426
434
  });
427
435
  assertCurrent(expectedRevision);
428
- catalog = { identity, command, revision: answered, value };
436
+ catalogueReads.set(catalogueKey, { command, revision: answered, value });
429
437
  return value;
430
438
  })().finally(() => { catalogStarts.delete(identity); }));
431
439
  return catalogStarts.get(identity);
@@ -495,8 +503,10 @@ export function createClaudeAccountQueries({
495
503
 
496
504
  async function invalidate() {
497
505
  revision++;
498
- // Dropped at once: a read started before this cannot store its answer.
499
- catalog = null;
506
+ // Dropped at once: a read started before this cannot store its answer, and
507
+ // the next request for an account this owner used reads its model list again.
508
+ for (const catalogueKey of catalogueAccounts) catalogueReads.delete(catalogueKey);
509
+ catalogueAccounts.clear();
500
510
  for (const controller of accountReads) {
501
511
  controller.abort(error("Claude account query was cancelled for reconnection.", { messageCode: "assistant.claude.accountQueryCancelled" }));
502
512
  }
@@ -2,8 +2,8 @@ import { createSchema } from "json-rest-schema";
2
2
  import { createCursorListValidator, RECORD_ID_PATTERN } from "@jskit-ai/kernel/shared/validators";
3
3
  import {
4
4
  createSchemaDefinition,
5
- defineResource
6
- } from "@jskit-ai/resource-core/shared/resource";
5
+ defineApiContract
6
+ } from "@jskit-ai/resource-core/shared/apiContract";
7
7
  import { ASSISTANT_ATTACHMENT_ID_MAX_LENGTH, ASSISTANT_ATTACHMENT_MAX_ITEMS } from "./conversation/attachments.js";
8
8
 
9
9
  const MAX_INPUT_CHARS = 8000;
@@ -267,7 +267,7 @@ const conversationMessagesListOutputSchema = createSchema({
267
267
 
268
268
  const assistantConversationOutputValidator = createSchemaDefinition(conversationRecordSchema, "replace");
269
269
 
270
- const assistantResource = defineResource({
270
+ const assistantContract = defineApiContract({
271
271
  namespace: "assistant",
272
272
  operations: {
273
273
  chatStream: {
@@ -292,6 +292,6 @@ export {
292
292
  MAX_INPUT_CHARS,
293
293
  MAX_MESSAGE_PAGE_SIZE,
294
294
  attachmentIdsField,
295
- assistantResource,
295
+ assistantContract,
296
296
  assistantConversationOutputValidator
297
297
  };
@@ -1,7 +1,7 @@
1
1
  import { createSchema } from "json-rest-schema";
2
2
  import { RECORD_ID_PATTERN } from "@jskit-ai/kernel/shared/validators";
3
3
  import { deepFreeze } from "@jskit-ai/kernel/shared/support/deepFreeze";
4
- import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
4
+ import { defineApiContract } from "@jskit-ai/resource-core/shared/apiContract";
5
5
 
6
6
  const MAX_SYSTEM_PROMPT_CHARS = 12_000;
7
7
 
@@ -46,6 +46,12 @@ const assistantConfigRecordSchema = createSchema({
46
46
  maxLength: MAX_SYSTEM_PROMPT_CHARS
47
47
  }
48
48
  })
49
+ },
50
+ // Whether the signed-in person may change these settings, as the server decides; the settings screen shows Save
51
+ // only when it is true.
52
+ canUpdate: {
53
+ type: "boolean",
54
+ required: true
49
55
  }
50
56
  });
51
57
 
@@ -61,16 +67,23 @@ const assistantConfigPatchSchema = createSchema({
61
67
  }
62
68
  });
63
69
 
64
- const assistantConfigResource = defineCrudResource({
70
+ // The assistant's configuration over HTTP: read it, and patch it.
71
+ const assistantConfigContract = defineApiContract({
65
72
  namespace: "assistantConfig",
66
- crudOperations: ["view", "patch"],
67
- crud: {
68
- output: assistantConfigRecordSchema,
69
- patchBody: assistantConfigPatchSchema
73
+ operations: {
74
+ view: {
75
+ method: "GET",
76
+ output: assistantConfigRecordSchema
77
+ },
78
+ patch: {
79
+ method: "PATCH",
80
+ body: assistantConfigPatchSchema,
81
+ output: assistantConfigRecordSchema
82
+ }
70
83
  }
71
84
  });
72
85
 
73
86
  export {
74
87
  MAX_SYSTEM_PROMPT_CHARS,
75
- assistantConfigResource
88
+ assistantConfigContract
76
89
  };
@@ -29,13 +29,13 @@ export {
29
29
  MAX_INPUT_CHARS,
30
30
  MAX_MESSAGE_PAGE_SIZE,
31
31
  attachmentIdsField,
32
- assistantResource,
32
+ assistantContract,
33
33
  assistantConversationOutputValidator
34
34
  } from "./assistantResource.js";
35
35
 
36
36
  export {
37
37
  MAX_SYSTEM_PROMPT_CHARS,
38
- assistantConfigResource
38
+ assistantConfigContract
39
39
  } from "./assistantSettingsResource.js";
40
40
 
41
41
  export {
@@ -2,18 +2,18 @@ import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { validateOperationSection } from "@jskit-ai/http-runtime/shared/validators/operationValidation";
4
4
  import { resolveSchemaTransportSchemaDefinition } from "@jskit-ai/kernel/shared/validators";
5
- import { assistantResource } from "../src/shared/assistantResource.js";
5
+ import { assistantContract } from "../src/shared/assistantResource.js";
6
6
 
7
7
  test("assistant output schemas accept normalized paginated payloads", () => {
8
8
  const conversationsListSchema = resolveSchemaTransportSchemaDefinition(
9
- assistantResource.operations.conversationsList.output,
9
+ assistantContract.operations.conversationsList.output,
10
10
  {
11
11
  context: "assistant conversations list output",
12
12
  defaultMode: "replace"
13
13
  }
14
14
  );
15
15
  const conversationMessagesSchema = resolveSchemaTransportSchemaDefinition(
16
- assistantResource.operations.conversationMessagesList.output,
16
+ assistantContract.operations.conversationMessagesList.output,
17
17
  {
18
18
  context: "assistant conversation messages output",
19
19
  defaultMode: "replace"
@@ -53,14 +53,14 @@ test("assistant output schemas accept normalized paginated payloads", () => {
53
53
 
54
54
  test("assistant conversation message params accept numeric path strings", async () => {
55
55
  const params = resolveSchemaTransportSchemaDefinition(
56
- assistantResource.operations.conversationMessagesList.params,
56
+ assistantContract.operations.conversationMessagesList.params,
57
57
  {
58
58
  context: "assistant conversation message params",
59
59
  defaultMode: "patch"
60
60
  }
61
61
  );
62
62
  const parsed = await validateOperationSection({
63
- operation: assistantResource.operations.conversationMessagesList,
63
+ operation: assistantContract.operations.conversationMessagesList,
64
64
  section: "params",
65
65
  value: { conversationId: "2" }
66
66
  });
@@ -2,11 +2,11 @@ import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { resolveSchemaTransportSchemaDefinition } from "@jskit-ai/kernel/shared/validators";
4
4
  import { validateOperationSection } from "@jskit-ai/http-runtime/shared/validators/operationValidation";
5
- import { assistantConfigResource } from "../src/shared/assistantSettingsResource.js";
5
+ import { assistantConfigContract } from "../src/shared/assistantSettingsResource.js";
6
6
 
7
7
  test("assistant config resource exposes valid output schema", () => {
8
8
  const viewSchema = resolveSchemaTransportSchemaDefinition(
9
- assistantConfigResource.operations.view.output,
9
+ assistantConfigContract.operations.view.output,
10
10
  {
11
11
  context: "assistant config view output",
12
12
  defaultMode: "replace"
@@ -23,7 +23,7 @@ test("assistant config resource exposes valid output schema", () => {
23
23
 
24
24
  test("assistant settings patch validation preserves omitted fields", async () => {
25
25
  const patch = await validateOperationSection({
26
- operation: assistantConfigResource.operations.patch,
26
+ operation: assistantConfigContract.operations.patch,
27
27
  section: "body",
28
28
  value: {}
29
29
  });
@@ -814,6 +814,45 @@ test("Claude model list starts a Claude only after its account, credentials or C
814
814
  assert.equal(await model(), "read-5");
815
815
  });
816
816
 
817
+ test("Claude model list is read once per account for every owner the host creates again", async (t) => {
818
+ const root = await mkdtemp(join(tmpdir(), "jskit-claude-catalogue-owners-"));
819
+ t.after(() => rm(root, { recursive: true, force: true }));
820
+ const configRoot = join(root, ".claude");
821
+ const otherConfigRoot = join(root, "other", ".claude");
822
+ await mkdir(otherConfigRoot, { recursive: true });
823
+ await mkdir(configRoot);
824
+ const credentials = join(configRoot, ".credentials.json");
825
+ const command = join(root, "claude");
826
+ await writeFile(credentials, "signed in");
827
+ await writeFile(command, "2.1.1");
828
+ const starts = [];
829
+ // A restarted service or a reopened project creates its account queries again.
830
+ const owner = (account = "account", accountConfigRoot = configRoot) => createClaudeAccountQueries({
831
+ accountIdentity: async () => account, configRoot: accountConfigRoot,
832
+ createProcess({ purpose }) {
833
+ starts.push(purpose);
834
+ return { command, initialization: { models: [{ value: `read-${starts.length}` }] }, async stop() { return { scopeEmpty: true }; } };
835
+ } });
836
+ const model = async (queries) => (await queries.readCatalogue({})).models[0].value;
837
+ const first = owner();
838
+ assert.equal(await model(first), "read-1");
839
+ const restarted = owner();
840
+ assert.equal(await model(restarted), "read-1");
841
+ // Another account, or the same name in another Claude configuration, reads its own.
842
+ assert.equal(await model(owner("other account")), "read-2");
843
+ assert.equal(await model(owner("account", otherConfigRoot)), "read-3");
844
+ // A token refresh replaces the credentials, so the next read of any owner starts a Claude.
845
+ await writeFile(`${credentials}.next`, "refreshed");
846
+ await rename(`${credentials}.next`, credentials);
847
+ assert.equal(await model(owner()), "read-4");
848
+ assert.equal(await model(restarted), "read-4");
849
+ // One owner's reconnection drops the account's list for every owner.
850
+ await restarted.invalidate();
851
+ assert.equal(await model(first), "read-5");
852
+ assert.deepEqual(starts, Array(5).fill("catalogue"));
853
+ await first.invalidate();
854
+ });
855
+
817
856
  test("Claude account queries label temporary startup and reuse each account's recent reading", async () => {
818
857
  const starts = [];
819
858
  let stops = 0;
@@ -0,0 +1,21 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { checkMaterial3 } from "../../../tooling/jskit-catalog/scripts/material3.mjs";
6
+
7
+ // The fixtures the browser tests draw are app UI too: their colours are the theme's roles, so they follow the light
8
+ // and dark schemes the tests check. jskit check m3 skips every file under fixtures/, so this test checks the fixture
9
+ // as an app of its own.
10
+ test("the responsive assistant fixtures draw with theme colour roles, never raw colours", async () => {
11
+ const result = await checkMaterial3({
12
+ projectRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)),
13
+ paths: ["."]
14
+ });
15
+
16
+ assert.ok(result.files.includes("ConversationFixture.vue"), result.files.join(", "));
17
+ assert.deepEqual(
18
+ result.findings.filter((finding) => finding.rule === "m3/raw-hex").map(({ file, line, message }) => `${file}:${line} ${message}`),
19
+ []
20
+ );
21
+ });