@amalgm/agents 0.1.0 → 0.1.1

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
@@ -11,6 +11,8 @@ remain usable without Chat or Amalgm Engine.
11
11
  Engine composes Agents with harnesses, credentials, Toolbox, files, Realtime,
12
12
  and UI. This package stores references to those products and delegates
13
13
  execution to an injected driver; it does not duplicate their state.
14
+ It also discovers installed agent skills across the canonical project and
15
+ harness roots so every transport presents the same available skill catalog.
14
16
 
15
17
  ## Primitives
16
18
 
package/README.md CHANGED
@@ -71,6 +71,18 @@ session stays pinned to that revision even after the agent is edited.
71
71
  | MCP | `@amalgm/agents/mcp`, `amalgm-agents-mcp` | Agent CRUD and agent-to-agent calls |
72
72
  | Skill | `skills/amalgm-agents` | Minimal workflow over the Agents tools |
73
73
 
74
+ Installed skills are available from the REST surface without introducing a
75
+ second registry:
76
+
77
+ ```bash
78
+ curl http://127.0.0.1:PORT/skills
79
+ curl 'http://127.0.0.1:PORT/skills/list?include_content=true'
80
+ ```
81
+
82
+ The scanner reads the same canonical project, shared, Codex, Claude Code, and
83
+ OpenCode roots as Engine. Hosts inject `skillRoots` when they need a selected
84
+ project or an isolated home.
85
+
74
86
  ## CLI
75
87
 
76
88
  ```bash
@@ -4,6 +4,7 @@ import { asAgentError } from '../errors.js';
4
4
  import { routeAgents } from './agent-routes.js';
5
5
  import { guardRequest, readJson, sendJson } from './request.js';
6
6
  import { routeSessions } from './session-routes.js';
7
+ import { routeSkills } from './skill-routes.js';
7
8
  import { streamEvents } from './stream.js';
8
9
  export function createRestServer(options = {}) {
9
10
  const agents = options.agents || new Agents(options);
@@ -37,22 +38,25 @@ export function createRestServer(options = {}) {
37
38
  return;
38
39
  }
39
40
  const path = url.pathname.split('/').filter(Boolean);
40
- if (path.shift() !== 'v1') {
41
- sendJson(response, 404, { error: { code: 'not_found', message: 'Route not found.' } });
42
- return;
43
- }
44
- if (path[0] === 'sessions' && path[2] === 'events' && path[3] === 'stream' && request.method === 'GET') {
45
- streamEvents(agents, path[1] || '', request, response, Number(url.searchParams.get('after') || 0));
46
- return;
47
- }
48
41
  const context = {
49
42
  agents,
50
43
  method: request.method || 'GET',
51
44
  path,
52
45
  url,
46
+ skillRoots: options.skillRoots || {},
53
47
  body: () => readJson(request, bodyLimit),
54
48
  json: (status, value) => sendJson(response, status, value),
55
49
  };
50
+ if (await routeSkills(context))
51
+ return;
52
+ if (path.shift() !== 'v1') {
53
+ sendJson(response, 404, { error: { code: 'not_found', message: 'Route not found.' } });
54
+ return;
55
+ }
56
+ if (path[0] === 'sessions' && path[2] === 'events' && path[3] === 'stream' && request.method === 'GET') {
57
+ streamEvents(agents, path[1] || '', request, response, Number(url.searchParams.get('after') || 0));
58
+ return;
59
+ }
56
60
  if (await routeAgents(context) || await routeSessions(context))
57
61
  return;
58
62
  sendJson(response, 404, { error: { code: 'not_found', message: 'Route not found.' } });
@@ -0,0 +1,3 @@
1
+ /** HTTP laws from Engine `server/routes/agents.js` and `skills/rest.js`. */
2
+ import type { RouteContext } from '../http-types.js';
3
+ export declare function routeSkills(context: RouteContext): Promise<boolean>;
@@ -0,0 +1,25 @@
1
+ /** HTTP laws from Engine `server/routes/agents.js` and `skills/rest.js`. */
2
+ import { scanInstalledSkills } from '../skills/scanner.js';
3
+ function truthy(value) {
4
+ return value === '1' || value?.toLowerCase() === 'true';
5
+ }
6
+ export async function routeSkills(context) {
7
+ const [resource, action] = context.path;
8
+ if (resource !== 'skills')
9
+ return false;
10
+ const includeContent = truthy(context.url.searchParams.get('include_content'));
11
+ if ((!action || action === 'list') && context.method === 'GET') {
12
+ context.json(200, {
13
+ skills: scanInstalledSkills({ ...context.skillRoots, includeContent }).skills,
14
+ });
15
+ return true;
16
+ }
17
+ if (action === 'refresh' && context.method === 'POST') {
18
+ context.json(200, {
19
+ ok: true,
20
+ skills: scanInstalledSkills(context.skillRoots).skills,
21
+ });
22
+ return true;
23
+ }
24
+ return false;
25
+ }
@@ -2,10 +2,12 @@ import type { Server } from 'node:http';
2
2
  import type { AddressInfo } from 'node:net';
3
3
  import type { Agents } from './agents.js';
4
4
  import type { AgentsOptions } from './types.js';
5
+ import type { SkillRootOptions } from './skills/roots.js';
5
6
  export interface RestServerOptions extends AgentsOptions {
6
7
  agents?: Agents;
7
8
  token?: string;
8
9
  bodyLimitBytes?: number;
10
+ skillRoots?: SkillRootOptions;
9
11
  }
10
12
  export interface RestServer {
11
13
  agents: Agents;
@@ -18,6 +20,7 @@ export interface RouteContext {
18
20
  method: string;
19
21
  path: string[];
20
22
  url: URL;
23
+ skillRoots: SkillRootOptions;
21
24
  body(): Promise<Record<string, unknown>>;
22
25
  json(status: number, value: unknown): void;
23
26
  }
package/dist/index.d.ts CHANGED
@@ -4,3 +4,7 @@ export { defineDriver, driverSpecifiers, loadDriverModules } from './drivers.js'
4
4
  export { definitionHash, normalizeDefinition, patchDefinition } from './definition.js';
5
5
  export { messageText, normalizeMessage } from './messages.js';
6
6
  export type * from './types.js';
7
+ export { buildCanonicalSkillRoots, classifySkillRoot } from './skills/roots.js';
8
+ export type { SkillRootClassification, SkillRootOptions } from './skills/roots.js';
9
+ export { parseSkillFrontmatter, scanInstalledSkills } from './skills/scanner.js';
10
+ export type { InstalledSkill, SkillScanOptions } from './skills/scanner.js';
package/dist/index.js CHANGED
@@ -3,3 +3,5 @@ export { AgentError, asAgentError } from './errors.js';
3
3
  export { defineDriver, driverSpecifiers, loadDriverModules } from './drivers.js';
4
4
  export { definitionHash, normalizeDefinition, patchDefinition } from './definition.js';
5
5
  export { messageText, normalizeMessage } from './messages.js';
6
+ export { buildCanonicalSkillRoots, classifySkillRoot } from './skills/roots.js';
7
+ export { parseSkillFrontmatter, scanInstalledSkills } from './skills/scanner.js';
@@ -16,7 +16,7 @@ export function createMcpServer(options = {}) {
16
16
  return {
17
17
  protocolVersion: message.params?.protocolVersion || '2024-11-05',
18
18
  capabilities: { tools: { listChanged: false } },
19
- serverInfo: { name: 'amalgm-agents', version: '0.1.0' },
19
+ serverInfo: { name: 'amalgm-agents', version: '0.1.1' },
20
20
  };
21
21
  }
22
22
  if (message.method === 'ping')
@@ -0,0 +1,16 @@
1
+ /** Canonical installed-skill roots from Engine `skills/roots.js`. */
2
+ export interface SkillRootOptions {
3
+ selectedCwd?: string;
4
+ homeDir?: string;
5
+ codexHome?: string;
6
+ claudeConfigDir?: string;
7
+ }
8
+ export interface SkillRootClassification {
9
+ provider: 'shared' | 'codex' | 'claude_code' | 'opencode';
10
+ scope: 'project' | 'global';
11
+ rootLabel: string;
12
+ }
13
+ export declare function normalizePath(value: unknown): string;
14
+ export declare function buildCanonicalSkillRoots(options?: SkillRootOptions): string[];
15
+ export declare function isCodexWorktreeRoot(rootPath: string, options?: SkillRootOptions): boolean;
16
+ export declare function classifySkillRoot(rootPath: string, options?: SkillRootOptions): SkillRootClassification;
@@ -0,0 +1,105 @@
1
+ /** Canonical installed-skill roots from Engine `skills/roots.js`. */
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ export function normalizePath(value) {
6
+ return typeof value === 'string' && value.trim() ? path.resolve(value.trim()) : '';
7
+ }
8
+ function existingDirectory(value) {
9
+ const directory = normalizePath(value);
10
+ if (!directory)
11
+ return '';
12
+ try {
13
+ return fs.statSync(directory).isDirectory() ? directory : '';
14
+ }
15
+ catch {
16
+ return '';
17
+ }
18
+ }
19
+ function uniquePaths(values) {
20
+ return [...new Set(values.map(normalizePath).filter(Boolean))];
21
+ }
22
+ function nativeHome(options) {
23
+ return normalizePath(options.homeDir || process.env.AMALGM_NATIVE_HOME || os.homedir());
24
+ }
25
+ function projectRoots(selectedCwd) {
26
+ const roots = [];
27
+ let current = existingDirectory(selectedCwd);
28
+ while (current) {
29
+ const root = existingDirectory(path.join(current, '.agents', 'skills'));
30
+ if (root)
31
+ roots.push(root);
32
+ const parent = path.dirname(current);
33
+ if (!parent || parent === current)
34
+ break;
35
+ current = parent;
36
+ }
37
+ return roots;
38
+ }
39
+ function worktreeRoots(home) {
40
+ const worktrees = existingDirectory(path.join(home, '.codex', 'worktrees'));
41
+ if (!worktrees)
42
+ return [];
43
+ try {
44
+ return fs.readdirSync(worktrees, { withFileTypes: true })
45
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
46
+ .map((entry) => existingDirectory(path.join(worktrees, entry.name, '.agents', 'skills')))
47
+ .filter(Boolean);
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ }
53
+ export function buildCanonicalSkillRoots(options = {}) {
54
+ const selectedCwd = normalizePath(options.selectedCwd);
55
+ const home = nativeHome(options);
56
+ const codexHome = normalizePath(options.codexHome || process.env.CODEX_HOME);
57
+ const claudeConfig = normalizePath(options.claudeConfigDir || process.env.CLAUDE_CONFIG_DIR);
58
+ return uniquePaths([
59
+ ...projectRoots(selectedCwd),
60
+ home && path.join(home, '.agents', 'skills'),
61
+ codexHome && path.join(codexHome, 'skills'),
62
+ home && path.join(home, '.codex', 'skills'),
63
+ ...worktreeRoots(home),
64
+ claudeConfig && path.join(claudeConfig, 'skills'),
65
+ home && path.join(home, '.claude', 'skills'),
66
+ home && path.join(home, '.config', 'claude', 'skills'),
67
+ home && path.join(home, '.config', 'opencode', 'skills'),
68
+ home && path.join(home, '.opencode', 'skills'),
69
+ ]).filter(existingDirectory);
70
+ }
71
+ export function isCodexWorktreeRoot(rootPath, options = {}) {
72
+ const root = normalizePath(rootPath);
73
+ const home = nativeHome(options);
74
+ const worktrees = `${normalizePath(path.join(home, '.codex', 'worktrees'))}${path.sep}`;
75
+ return Boolean(root && home && root.startsWith(worktrees)
76
+ && root.endsWith(path.normalize(path.join('.agents', 'skills'))));
77
+ }
78
+ export function classifySkillRoot(rootPath, options = {}) {
79
+ const root = normalizePath(rootPath);
80
+ const selectedCwd = normalizePath(options.selectedCwd);
81
+ const home = nativeHome(options);
82
+ const codexHome = normalizePath(options.codexHome || process.env.CODEX_HOME);
83
+ const claudeConfig = normalizePath(options.claudeConfigDir || process.env.CLAUDE_CONFIG_DIR);
84
+ if ((selectedCwd && root.startsWith(path.join(selectedCwd, '.agents', 'skills')))
85
+ || root.endsWith(path.normalize(path.join('.agents', 'skills')))) {
86
+ return { provider: 'shared', scope: 'project', rootLabel: 'Project skills' };
87
+ }
88
+ if (root === normalizePath(path.join(home, '.agents', 'skills'))) {
89
+ return { provider: 'shared', scope: 'global', rootLabel: 'Shared global skills' };
90
+ }
91
+ if ((codexHome && root === normalizePath(path.join(codexHome, 'skills')))
92
+ || root === normalizePath(path.join(home, '.codex', 'skills'))) {
93
+ return { provider: 'codex', scope: 'global', rootLabel: 'Codex skills' };
94
+ }
95
+ if ((claudeConfig && root === normalizePath(path.join(claudeConfig, 'skills')))
96
+ || root === normalizePath(path.join(home, '.claude', 'skills'))
97
+ || root === normalizePath(path.join(home, '.config', 'claude', 'skills'))) {
98
+ return { provider: 'claude_code', scope: 'global', rootLabel: 'Claude Code skills' };
99
+ }
100
+ if (root === normalizePath(path.join(home, '.config', 'opencode', 'skills'))
101
+ || root === normalizePath(path.join(home, '.opencode', 'skills'))) {
102
+ return { provider: 'opencode', scope: 'global', rootLabel: 'OpenCode skills' };
103
+ }
104
+ return { provider: 'shared', scope: 'global', rootLabel: 'Installed skills' };
105
+ }
@@ -0,0 +1,32 @@
1
+ /** Installed-skill scanner from Engine `skills/scanner.js`. */
2
+ import type { SkillRootOptions } from './roots.js';
3
+ export interface InstalledSkill {
4
+ id: string;
5
+ name: string;
6
+ description: string;
7
+ content?: string;
8
+ contentHash?: string;
9
+ source: {
10
+ kind: 'installed';
11
+ provider: string;
12
+ scope: string;
13
+ rootLabel: string;
14
+ path: string;
15
+ directoryPath: string;
16
+ resolvedPath: string;
17
+ resolvedDirectoryPath: string;
18
+ rootPath: string;
19
+ viaSymlink: boolean;
20
+ };
21
+ updatedAt: string;
22
+ discoveredAt: string;
23
+ }
24
+ export interface SkillScanOptions extends SkillRootOptions {
25
+ includeContent?: boolean;
26
+ now?: () => string;
27
+ }
28
+ export declare function parseSkillFrontmatter(markdown: unknown): Record<string, string>;
29
+ export declare function scanInstalledSkills(options?: SkillScanOptions): {
30
+ roots: string[];
31
+ skills: InstalledSkill[];
32
+ };
@@ -0,0 +1,117 @@
1
+ /** Installed-skill scanner from Engine `skills/scanner.js`. */
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { buildCanonicalSkillRoots, classifySkillRoot, isCodexWorktreeRoot, normalizePath, } from './roots.js';
6
+ const FRONTMATTER = /^---\s*\n([\s\S]*?)\n---/;
7
+ function clean(value) {
8
+ return typeof value === 'string' && value.trim() ? value.trim() : '';
9
+ }
10
+ function trimSlash(value) {
11
+ return value === path.sep ? value : value.replace(/[\\/]+$/, '');
12
+ }
13
+ export function parseSkillFrontmatter(markdown) {
14
+ const match = String(markdown || '').match(FRONTMATTER);
15
+ if (!match?.[1])
16
+ return {};
17
+ const fields = {};
18
+ for (const line of match[1].split(/\r?\n/)) {
19
+ const parts = line.match(/^\s*([A-Za-z0-9_-]+)\s*:\s*(.*?)\s*$/);
20
+ if (!parts?.[1])
21
+ continue;
22
+ let value = parts[2]?.trim() || '';
23
+ if ((value.startsWith('"') && value.endsWith('"'))
24
+ || (value.startsWith("'") && value.endsWith("'")))
25
+ value = value.slice(1, -1);
26
+ fields[parts[1]] = value;
27
+ }
28
+ return fields;
29
+ }
30
+ function scanDirectory(root, name, options) {
31
+ const directory = path.join(root, name);
32
+ const file = path.join(directory, 'SKILL.md');
33
+ let stat;
34
+ try {
35
+ stat = fs.statSync(file);
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ if (!stat.isFile())
41
+ return null;
42
+ const content = fs.readFileSync(file, 'utf8');
43
+ const frontmatter = parseSkillFrontmatter(content);
44
+ const canonicalPath = normalizePath(file);
45
+ const classification = classifySkillRoot(root, options);
46
+ const skill = {
47
+ id: `skill-${crypto.createHash('sha256').update(canonicalPath).digest('hex').slice(0, 16)}`,
48
+ name: clean(frontmatter.name) || name,
49
+ description: clean(frontmatter.description),
50
+ source: {
51
+ kind: 'installed',
52
+ ...classification,
53
+ path: canonicalPath,
54
+ directoryPath: trimSlash(normalizePath(directory)),
55
+ resolvedPath: normalizePath(fs.realpathSync(file)),
56
+ resolvedDirectoryPath: trimSlash(fs.realpathSync(directory)),
57
+ rootPath: normalizePath(root),
58
+ viaSymlink: fs.lstatSync(directory).isSymbolicLink() || fs.lstatSync(file).isSymbolicLink(),
59
+ },
60
+ updatedAt: stat.mtime.toISOString(),
61
+ discoveredAt: options.now?.() || new Date().toISOString(),
62
+ };
63
+ if (options.includeContent) {
64
+ skill.content = content;
65
+ skill.contentHash = crypto.createHash('sha256').update(content).digest('hex');
66
+ }
67
+ return skill;
68
+ }
69
+ function entries(root) {
70
+ try {
71
+ return fs.readdirSync(root, { withFileTypes: true }).filter((entry) => {
72
+ if (entry.name.startsWith('.'))
73
+ return false;
74
+ if (entry.isDirectory())
75
+ return true;
76
+ if (!entry.isSymbolicLink())
77
+ return false;
78
+ try {
79
+ return fs.statSync(path.join(root, entry.name)).isDirectory();
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ });
85
+ }
86
+ catch {
87
+ return [];
88
+ }
89
+ }
90
+ export function scanInstalledSkills(options = {}) {
91
+ const roots = buildCanonicalSkillRoots(options);
92
+ const paths = new Set();
93
+ const resolved = new Set();
94
+ const signatures = new Set();
95
+ const skills = [];
96
+ for (const root of roots) {
97
+ for (const entry of entries(root)) {
98
+ const skill = scanDirectory(root, entry.name, { ...options, includeContent: true });
99
+ if (!skill || paths.has(skill.source.path) || resolved.has(skill.source.resolvedPath))
100
+ continue;
101
+ const signature = `${skill.name.toLowerCase()}::${skill.contentHash || ''}`;
102
+ if (isCodexWorktreeRoot(root, options) && signatures.has(signature))
103
+ continue;
104
+ paths.add(skill.source.path);
105
+ resolved.add(skill.source.resolvedPath);
106
+ signatures.add(signature);
107
+ if (!options.includeContent) {
108
+ delete skill.content;
109
+ delete skill.contentHash;
110
+ }
111
+ skills.push(skill);
112
+ }
113
+ }
114
+ skills.sort((left, right) => left.name.localeCompare(right.name)
115
+ || left.source.path.localeCompare(right.source.path));
116
+ return { roots, skills };
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amalgm/agents",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Local-first agent definitions, immutable revisions, and durable sessions.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,