@amalgm/tools 0.1.1 → 0.1.3

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/PURPOSE.md CHANGED
@@ -28,6 +28,8 @@ registry or execution implementation.
28
28
  - A **driver** executes actions for one tool type.
29
29
  - A **system tool** is an embedder-owned definition projected into the catalog;
30
30
  it is not user state.
31
+ - A **notification** is one request to inform the current user; a channel
32
+ adapter turns that intent into delivery.
31
33
 
32
34
  ## Axioms
33
35
 
@@ -74,11 +76,21 @@ registry or execution implementation.
74
76
  18. Tools may store opaque references owned by another product, but neither
75
77
  Tools nor a Core adapter silently rewrites that product. A cross-product
76
78
  workflow has one explicit product owner.
79
+ 19. `notifications.notify_user` addresses only the current user. Callers may
80
+ supply content and severity, never a recipient or delivery credential.
81
+ 20. Notification channels are host capabilities. Tools owns validation and
82
+ result semantics; an injected channel adapter owns formatting, recipient
83
+ lookup, credentials, and delivery.
84
+ 21. Reopening a Toolbox preserves the temporal fields of every semantically
85
+ unchanged system projection already present in its portable index; boot
86
+ never rewrites tracked bytes merely to stamp the current machine's time.
77
87
 
78
88
  ## Predictable behavior
79
89
 
80
- Applying the same definition twice produces the same catalog. Applying a
81
- changed action set replaces the old set atomically. Selecting a whole tool
90
+ Applying the same definition twice produces the same catalog. Reopening the
91
+ same system projection produces the same portable index bytes on every
92
+ machine. Applying a changed action set replaces the old set atomically.
93
+ Selecting a whole tool
82
94
  grants all of its enabled actions; selecting one action grants only that
83
95
  action. Disabling or deleting a tool immediately removes all of its actions
84
96
  from list and call surfaces. CLI arguments are passed directly to a process,
package/README.md CHANGED
@@ -65,6 +65,14 @@ The read adapter serves the Engine-compatible `GET /toolbox`,
65
65
  the same live Toolbox used by CLI and MCP consumers. Mutation routes return
66
66
  405 until their legacy record fields can be represented without data loss.
67
67
 
68
+ ## Notifications
69
+
70
+ `Notifications` projects the first-party `notifications.notify_user` action
71
+ into the Toolbox. The action validates one channel-neutral request and hands
72
+ it to an injected email delivery adapter; recipient lookup and credentials
73
+ never enter the tool record. `createNotificationsHttpServer` exposes
74
+ `GET /email` as the channel capability document used by the shell.
75
+
68
76
  ## Tool types
69
77
 
70
78
  - `cli`: executed directly with `spawn`; a shell is never involved.
@@ -1,3 +1,4 @@
1
+ import type { ToolboxIndexDocument } from './artifacts.js';
1
2
  import type { ActionRecord, Catalog, ToolRecord } from './types.js';
2
3
  declare class ArtifactFiles {
3
4
  private readonly directory;
@@ -9,6 +10,7 @@ declare class ArtifactFiles {
9
10
  }): string;
10
11
  removeTool(toolId: string): void;
11
12
  writeIndex(catalog: Catalog): void;
13
+ readIndex(): ToolboxIndexDocument | null;
12
14
  /** Give every persisted user tool its portable artifact and refresh the
13
15
  * index. Additive only: a file for a tool this catalog does not know is
14
16
  * never deleted here — removal is an explicit mutation. */
@@ -51,6 +51,13 @@ class ArtifactFiles {
51
51
  writeIndex(catalog) {
52
52
  writeJsonIfChanged(this.file(TOOLBOX_INDEX_FILE_NAME), catalogIndexDocument(catalog));
53
53
  }
54
+ readIndex() {
55
+ const value = readJson(this.file(TOOLBOX_INDEX_FILE_NAME));
56
+ if (!value || value.version !== 1 || !value.tools || typeof value.tools !== 'object'
57
+ || !value.toolActions || typeof value.toolActions !== 'object')
58
+ return null;
59
+ return value;
60
+ }
54
61
  /** Give every persisted user tool its portable artifact and refresh the
55
62
  * index. Additive only: a file for a tool this catalog does not know is
56
63
  * never deleted here — removal is an explicit mutation. */
@@ -57,10 +57,12 @@ declare function artifactDocument(input: {
57
57
  declare function isToolArtifactDocument(value: unknown): value is ToolArtifactDocument;
58
58
  declare function toolboxIndexDocument(tools: Record<string, unknown>, toolActions: Record<string, unknown>): ToolboxIndexDocument;
59
59
  declare function catalogIndexDocument(catalog: Pick<Catalog, 'tools' | 'actions'>): ToolboxIndexDocument;
60
+ declare function portableRecordSemantic(value: ToolRecord | ActionRecord): string;
61
+ declare function preserveProjectionTime<T extends ToolRecord | ActionRecord>(candidate: T, previous: unknown): T;
60
62
  /** The migration of one legacy aggregate `toolbox.json` catalog, as a pure
61
63
  * plan: every user tool becomes a per-tool artifact write, the whole legacy
62
64
  * catalog becomes the index, and the aggregate file is renamed out of the
63
65
  * way only after each record has a per-tool replacement. */
64
66
  declare function legacyMigrationPlan(legacy: unknown): LegacyMigrationPlan;
65
- export { LEGACY_TOOLBOX_BACKUP_FILE_NAME, LEGACY_TOOLBOX_FILE_NAME, TOOL_ARTIFACT_KIND, TOOL_ARTIFACT_SCHEMA_VERSION, TOOLBOX_INDEX_FILE_NAME, artifactDocument, catalogIndexDocument, isToolArtifactDocument, legacyMigrationPlan, safeToolId, toolboxIndexDocument, userArtifactFileName, };
67
+ export { LEGACY_TOOLBOX_BACKUP_FILE_NAME, LEGACY_TOOLBOX_FILE_NAME, TOOL_ARTIFACT_KIND, TOOL_ARTIFACT_SCHEMA_VERSION, TOOLBOX_INDEX_FILE_NAME, artifactDocument, catalogIndexDocument, isToolArtifactDocument, legacyMigrationPlan, safeToolId, portableRecordSemantic, preserveProjectionTime, toolboxIndexDocument, userArtifactFileName, };
66
68
  export type { ArtifactFileWrite, LegacyMigrationPlan, ToolArtifactDocument, ToolboxIndexDocument };
package/dist/artifacts.js CHANGED
@@ -81,6 +81,19 @@ function toolboxIndexDocument(tools, toolActions) {
81
81
  function catalogIndexDocument(catalog) {
82
82
  return toolboxIndexDocument(Object.fromEntries(catalog.tools.map((tool) => [tool.id, tool])), Object.fromEntries(catalog.actions.map((action) => [action.id, action])));
83
83
  }
84
+ function portableRecordSemantic(value) {
85
+ const { createdAt, updatedAt, ...portable } = value;
86
+ return JSON.stringify(portable);
87
+ }
88
+ function preserveProjectionTime(candidate, previous) {
89
+ if (!previous || typeof previous !== 'object')
90
+ return candidate;
91
+ const record = previous;
92
+ if (typeof record.createdAt !== 'string' || typeof record.updatedAt !== 'string'
93
+ || portableRecordSemantic(record) !== portableRecordSemantic(candidate))
94
+ return candidate;
95
+ return { ...candidate, createdAt: record.createdAt, updatedAt: record.updatedAt };
96
+ }
84
97
  /** The migration of one legacy aggregate `toolbox.json` catalog, as a pure
85
98
  * plan: every user tool becomes a per-tool artifact write, the whole legacy
86
99
  * catalog becomes the index, and the aggregate file is renamed out of the
@@ -109,4 +122,4 @@ function legacyMigrationPlan(legacy) {
109
122
  rename: { from: LEGACY_TOOLBOX_FILE_NAME, to: LEGACY_TOOLBOX_BACKUP_FILE_NAME },
110
123
  };
111
124
  }
112
- export { LEGACY_TOOLBOX_BACKUP_FILE_NAME, LEGACY_TOOLBOX_FILE_NAME, TOOL_ARTIFACT_KIND, TOOL_ARTIFACT_SCHEMA_VERSION, TOOLBOX_INDEX_FILE_NAME, artifactDocument, catalogIndexDocument, isToolArtifactDocument, legacyMigrationPlan, safeToolId, toolboxIndexDocument, userArtifactFileName, };
125
+ export { LEGACY_TOOLBOX_BACKUP_FILE_NAME, LEGACY_TOOLBOX_FILE_NAME, TOOL_ARTIFACT_KIND, TOOL_ARTIFACT_SCHEMA_VERSION, TOOLBOX_INDEX_FILE_NAME, artifactDocument, catalogIndexDocument, isToolArtifactDocument, legacyMigrationPlan, safeToolId, portableRecordSemantic, preserveProjectionTime, toolboxIndexDocument, userArtifactFileName, };
package/dist/http.d.ts CHANGED
@@ -29,3 +29,5 @@ declare function createToolboxHttpServer(options?: ToolboxOptions & {
29
29
  }): ToolboxHttpServer;
30
30
  export { catalogDocument, createToolboxHttpServer };
31
31
  export type { ToolboxHttpServer };
32
+ export { createNotificationsHttpServer } from './notifications-http.js';
33
+ export type { NotificationsHttpServer } from './notifications-http.js';
package/dist/http.js CHANGED
@@ -113,3 +113,4 @@ function createToolboxHttpServer(options = {}) {
113
113
  };
114
114
  }
115
115
  export { catalogDocument, createToolboxHttpServer };
116
+ export { createNotificationsHttpServer } from './notifications-http.js';
package/dist/index.d.ts CHANGED
@@ -7,5 +7,9 @@ export type { ToolboxHttpServer } from './http.js';
7
7
  export { actionId, actionName, id, mcpName } from './ids.js';
8
8
  export { actionTools, callMcpTool, createMcpTools, findMcpTool, managementTools, } from './mcp.js';
9
9
  export { createMcpServer } from './mcp-server.js';
10
+ export { Notifications, createNotificationsDriver, notificationToolDefinition, } from './notifications.js';
11
+ export type { EmailDelivery, EmailDeliveryReceipt, EmailNotificationRequest, NotificationLevel, NotificationsOptions, } from './notifications.js';
12
+ export { createProxyEmailDelivery } from './proxy-email.js';
13
+ export type { ProxyEmailDeliveryOptions } from './proxy-email.js';
10
14
  export { Toolbox } from './toolbox.js';
11
15
  export type * from './types.js';
package/dist/index.js CHANGED
@@ -5,4 +5,6 @@ export { catalogDocument, createToolboxHttpServer } from './http.js';
5
5
  export { actionId, actionName, id, mcpName } from './ids.js';
6
6
  export { actionTools, callMcpTool, createMcpTools, findMcpTool, managementTools, } from './mcp.js';
7
7
  export { createMcpServer } from './mcp-server.js';
8
+ export { Notifications, createNotificationsDriver, notificationToolDefinition, } from './notifications.js';
9
+ export { createProxyEmailDelivery } from './proxy-email.js';
8
10
  export { Toolbox } from './toolbox.js';
@@ -0,0 +1,12 @@
1
+ import type { Notifications } from './notifications.js';
2
+ interface NotificationsHttpServer {
3
+ listen(port?: number, host?: string): Promise<{
4
+ port: number;
5
+ }>;
6
+ close(): Promise<void>;
7
+ }
8
+ declare function createNotificationsHttpServer(options: {
9
+ notifications: Notifications;
10
+ }): NotificationsHttpServer;
11
+ export { createNotificationsHttpServer };
12
+ export type { NotificationsHttpServer };
@@ -0,0 +1,38 @@
1
+ import { createServer } from 'node:http';
2
+ function createNotificationsHttpServer(options) {
3
+ const server = createServer((request, response) => {
4
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
5
+ const send = (status, body) => {
6
+ response.writeHead(status, { 'content-type': 'application/json' });
7
+ response.end(JSON.stringify(body));
8
+ };
9
+ if (request.method === 'GET' && url.pathname === '/email') {
10
+ send(200, options.notifications.document());
11
+ return;
12
+ }
13
+ send(404, { error: 'not found' });
14
+ });
15
+ return {
16
+ listen(port = 0, host = '127.0.0.1') {
17
+ return new Promise((resolve, reject) => {
18
+ server.once('error', reject);
19
+ server.listen(port, host, () => {
20
+ server.off('error', reject);
21
+ const address = server.address();
22
+ if (!address || typeof address === 'string')
23
+ throw new Error('Notifications HTTP server has no port');
24
+ resolve({ port: address.port });
25
+ });
26
+ });
27
+ },
28
+ close() {
29
+ return new Promise((resolve, reject) => {
30
+ server.close((error) => { if (error)
31
+ reject(error);
32
+ else
33
+ resolve(); });
34
+ });
35
+ },
36
+ };
37
+ }
38
+ export { createNotificationsHttpServer };
@@ -0,0 +1,42 @@
1
+ import type { ToolDefinition, ToolDriver, ToolResult } from './types.js';
2
+ /**
3
+ * Outbound notification law ported from Engine
4
+ * `runtime/scripts/amalgm-mcp/notify/index.js` and its canonical Toolbox ids
5
+ * in `runtime/scripts/amalgm-mcp/server/core-tools.js`.
6
+ */
7
+ type NotificationLevel = 'info' | 'warning' | 'error' | 'success';
8
+ interface EmailNotificationRequest {
9
+ subject: string;
10
+ message: string;
11
+ level: NotificationLevel;
12
+ link?: string;
13
+ sessionId?: string;
14
+ }
15
+ interface EmailDeliveryReceipt {
16
+ email: string;
17
+ replyTo?: string;
18
+ id?: string;
19
+ }
20
+ interface EmailDelivery {
21
+ send(request: EmailNotificationRequest): Promise<EmailDeliveryReceipt>;
22
+ }
23
+ interface NotificationsOptions {
24
+ email?: EmailDelivery;
25
+ }
26
+ declare function notificationToolDefinition(): ToolDefinition;
27
+ declare class Notifications {
28
+ #private;
29
+ constructor(options?: NotificationsOptions);
30
+ document(): {
31
+ service: string;
32
+ actions: string[];
33
+ channels: {
34
+ id: string;
35
+ configured: boolean;
36
+ }[];
37
+ };
38
+ notify(input: Record<string, unknown>, context?: unknown): Promise<ToolResult>;
39
+ }
40
+ declare function createNotificationsDriver(notifications: Notifications): ToolDriver;
41
+ export { Notifications, createNotificationsDriver, notificationToolDefinition };
42
+ export type { EmailDelivery, EmailDeliveryReceipt, EmailNotificationRequest, NotificationLevel, NotificationsOptions, };
@@ -0,0 +1,108 @@
1
+ import { errorResult } from './results.js';
2
+ const LEVELS = new Set(['info', 'warning', 'error', 'success']);
3
+ function nonempty(value, label) {
4
+ const result = typeof value === 'string' ? value.trim() : '';
5
+ if (!result)
6
+ throw new Error(`${label} is required`);
7
+ return result;
8
+ }
9
+ function optional(value) {
10
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
11
+ }
12
+ function contextSessionId(value) {
13
+ if (!value || typeof value !== 'object' || Array.isArray(value))
14
+ return undefined;
15
+ const context = value;
16
+ return optional(context.callerSessionId) || optional(context.sessionId);
17
+ }
18
+ function notificationToolDefinition() {
19
+ return {
20
+ id: 'notifications',
21
+ name: 'Notifications',
22
+ description: 'Notify the current user about important results, completions, and failures.',
23
+ owner: 'amalgm',
24
+ origin: 'system',
25
+ source: { type: 'mcp', transport: 'host', serverName: 'notifications' },
26
+ actions: [{
27
+ name: 'notify_user',
28
+ description: 'Send the current user an email notification. Keep it concise and actionable.',
29
+ target: { name: 'notify_user' },
30
+ inputSchema: {
31
+ type: 'object',
32
+ properties: {
33
+ subject: { type: 'string', description: 'Short subject; defaults to the first 80 message characters.' },
34
+ message: { type: 'string', description: 'Notification body.' },
35
+ level: { type: 'string', enum: [...LEVELS], description: 'Severity; defaults to info.' },
36
+ link: { type: 'string', description: 'Optional HTTP(S) details URL.' },
37
+ },
38
+ required: ['message'],
39
+ additionalProperties: false,
40
+ },
41
+ }],
42
+ };
43
+ }
44
+ class Notifications {
45
+ #email;
46
+ constructor(options = {}) {
47
+ this.#email = options.email;
48
+ }
49
+ document() {
50
+ return {
51
+ service: 'notifications',
52
+ actions: ['notifications.notify_user'],
53
+ channels: [{ id: 'email', configured: Boolean(this.#email) }],
54
+ };
55
+ }
56
+ async notify(input, context) {
57
+ const message = nonempty(input.message, 'message');
58
+ const rawLevel = optional(input.level) || 'info';
59
+ if (!LEVELS.has(rawLevel))
60
+ throw new Error(`Unsupported notification level: ${rawLevel}`);
61
+ const subject = (optional(input.subject) || message.slice(0, 80).replace(/\n/g, ' ')).slice(0, 80);
62
+ const link = optional(input.link);
63
+ if (link && !['http:', 'https:'].includes(new URL(link).protocol)) {
64
+ throw new Error('link must use http or https');
65
+ }
66
+ if (!this.#email)
67
+ throw new Error('Email notification delivery is not configured');
68
+ const level = rawLevel;
69
+ const sessionId = contextSessionId(context);
70
+ const receipt = await this.#email.send({
71
+ subject,
72
+ message,
73
+ level,
74
+ ...(link ? { link } : {}),
75
+ ...(sessionId ? { sessionId } : {}),
76
+ });
77
+ const reply = receipt.replyTo ? ' (replies route to this session)' : '';
78
+ return {
79
+ content: [{
80
+ type: 'text',
81
+ text: `Notification sent to ${receipt.email}${reply}.\n\nMessage: ${message}\nLevel: ${level}${link ? `\nLink: ${link}` : ''}`,
82
+ }],
83
+ structuredContent: { ok: true, channel: 'email', ...receipt },
84
+ };
85
+ }
86
+ }
87
+ function createNotificationsDriver(notifications) {
88
+ return {
89
+ type: 'mcp',
90
+ supports(tool) {
91
+ return tool.id === 'notifications'
92
+ && tool.source.type === 'mcp'
93
+ && tool.source.transport === 'host'
94
+ && tool.source.serverName === 'notifications';
95
+ },
96
+ async call({ action, input, options }) {
97
+ if (action.id !== 'notifications.notify_user')
98
+ return errorResult(`Unknown notification action: ${action.id}`);
99
+ try {
100
+ return await notifications.notify(input, options.context);
101
+ }
102
+ catch (error) {
103
+ return errorResult(error);
104
+ }
105
+ },
106
+ };
107
+ }
108
+ export { Notifications, createNotificationsDriver, notificationToolDefinition };
@@ -0,0 +1,9 @@
1
+ import type { EmailDelivery } from './notifications.js';
2
+ interface ProxyEmailDeliveryOptions {
3
+ baseUrl: string;
4
+ token: string;
5
+ fetch?: typeof globalThis.fetch;
6
+ }
7
+ declare function createProxyEmailDelivery(options: ProxyEmailDeliveryOptions): EmailDelivery;
8
+ export { createProxyEmailDelivery };
9
+ export type { ProxyEmailDeliveryOptions };
@@ -0,0 +1,62 @@
1
+ function escapeHtml(value) {
2
+ return value
3
+ .replaceAll('&', '&amp;')
4
+ .replaceAll('<', '&lt;')
5
+ .replaceAll('>', '&gt;')
6
+ .replaceAll('"', '&quot;')
7
+ .replaceAll("'", '&#39;');
8
+ }
9
+ function emailHtml(message, link) {
10
+ const body = escapeHtml(message).replace(/\n/g, '<br>');
11
+ const details = link
12
+ ? `<p><a href="${escapeHtml(link)}">View details</a></p>`
13
+ : '';
14
+ return `<div>${body}</div>${details}`;
15
+ }
16
+ function notifyEndpoint(raw) {
17
+ const value = raw.trim().replace(/\/+$/, '').replace(/\/anthropic$/, '');
18
+ const url = new URL(value);
19
+ if (!['http:', 'https:'].includes(url.protocol))
20
+ throw new Error('Notification proxy must use http or https');
21
+ if (url.username || url.password)
22
+ throw new Error('Notification proxy URL cannot contain credentials');
23
+ return `${value}/notify`;
24
+ }
25
+ function createProxyEmailDelivery(options) {
26
+ const endpoint = notifyEndpoint(options.baseUrl);
27
+ const token = options.token.trim();
28
+ if (!token)
29
+ throw new Error('Notification proxy token is required');
30
+ const request = options.fetch ?? globalThis.fetch;
31
+ return {
32
+ async send(notification) {
33
+ const response = await request(endpoint, {
34
+ method: 'POST',
35
+ headers: {
36
+ authorization: `Bearer ${token}`,
37
+ 'content-type': 'application/json',
38
+ },
39
+ body: JSON.stringify({
40
+ subject: notification.subject,
41
+ html: emailHtml(notification.message, notification.link),
42
+ text: notification.message,
43
+ ...(notification.sessionId ? { sessionId: notification.sessionId } : {}),
44
+ }),
45
+ });
46
+ if (!response.ok) {
47
+ const detail = (await response.text().catch(() => '')).slice(0, 200);
48
+ throw new Error(`Notification proxy failed: ${response.status}${detail ? ` ${detail}` : ''}`);
49
+ }
50
+ const body = await response.json();
51
+ const email = typeof body.email === 'string' && body.email.trim() ? body.email.trim() : '';
52
+ if (!email)
53
+ throw new Error('Notification proxy response did not identify the recipient');
54
+ return {
55
+ email,
56
+ ...(typeof body.replyTo === 'string' && body.replyTo ? { replyTo: body.replyTo } : {}),
57
+ ...(typeof body.id === 'string' && body.id ? { id: body.id } : {}),
58
+ };
59
+ },
60
+ };
61
+ }
62
+ export { createProxyEmailDelivery };
package/dist/toolbox.js CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { resolveProductStateDir } from '@amalgm/core/identity';
4
4
  import { apiDriver } from './api-driver.js';
5
5
  import { ArtifactFiles } from './artifact-files.js';
6
+ import { portableRecordSemantic, preserveProjectionTime } from './artifacts.js';
6
7
  import { cliDriver } from './cli-driver.js';
7
8
  import { normalizeDefinition } from './definition.js';
8
9
  import { assertMcpNamesUnique, id, mcpName } from './ids.js';
@@ -11,10 +12,6 @@ import { callableActions, queryTools, resolveAction, resolveCallableAction } fro
11
12
  import { findSelected, selected } from './selection.js';
12
13
  import { Store } from './store.js';
13
14
  import { updateAction, updateTool, upsertAction } from './updates.js';
14
- function semantic(value) {
15
- const { createdAt, updatedAt, ...record } = value;
16
- return JSON.stringify(record);
17
- }
18
15
  function defaultDatabase(options) {
19
16
  if (options.databaseFile)
20
17
  return path.resolve(options.databaseFile);
@@ -40,6 +37,7 @@ class Toolbox {
40
37
  const databaseFile = defaultDatabase(options);
41
38
  this.store = new Store(databaseFile);
42
39
  this.artifacts = new ArtifactFiles(path.dirname(databaseFile));
40
+ const priorIndex = this.artifacts.readIndex();
43
41
  this.onChange = options.onChange;
44
42
  for (const driver of [cliDriver, apiDriver, ...(options.drivers || [])])
45
43
  this.drivers.set(driver.type, driver);
@@ -49,8 +47,8 @@ class Toolbox {
49
47
  const normalized = normalizeDefinition({ ...definition, origin: 'system' });
50
48
  if (tools.some((tool) => tool.id === normalized.tool.id))
51
49
  throw new Error(`Duplicate system tool: ${normalized.tool.id}`);
52
- tools.push(normalized.tool);
53
- actions.push(...normalized.actions);
50
+ tools.push(preserveProjectionTime(normalized.tool, priorIndex?.tools[normalized.tool.id]));
51
+ actions.push(...normalized.actions.map((action) => preserveProjectionTime(action, priorIndex?.toolActions[action.id])));
54
52
  }
55
53
  assertMcpNamesUnique(actions);
56
54
  this.system = { version: 1, revision: 0, tools, actions };
@@ -116,9 +114,10 @@ class Toolbox {
116
114
  ...normalized.actions,
117
115
  ];
118
116
  assertMcpNamesUnique(candidateActions);
119
- const unchanged = existing && semantic(existing) === semantic(normalized.tool)
117
+ const unchanged = existing && portableRecordSemantic(existing) === portableRecordSemantic(normalized.tool)
120
118
  && normalized.actions.length === oldActions.size
121
- && normalized.actions.every((action) => semantic(action) === semantic(oldActions.get(action.id)));
119
+ && normalized.actions.every((action) => portableRecordSemantic(action)
120
+ === portableRecordSemantic(oldActions.get(action.id)));
122
121
  if (unchanged)
123
122
  return { tool: existing, actions: [...oldActions.values()].sort((a, b) => a.id.localeCompare(b.id)) };
124
123
  const result = this.store.apply(normalized);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amalgm/tools",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Local-first tool definitions, Toolbox registry, and agent execution surfaces.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -49,7 +49,7 @@
49
49
  "node": ">=20"
50
50
  },
51
51
  "dependencies": {
52
- "@amalgm/core": "^0.1.0",
52
+ "@amalgm/core": "0.2.0",
53
53
  "better-sqlite3": "^12.6.2"
54
54
  },
55
55
  "devDependencies": {