Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 51 additions & 3 deletions .claude/skills/swamp-extension-model/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,22 +199,70 @@ Each data output in the `dataOutputs` array has:
| `type: "file"` | File artifacts |
| `type: "resource"` | External resource state |

## Extending Existing Model Types

You can add new methods to existing model types (built-in or user-defined)
without changing their schema. Use `export const extension` instead of
`export const model`.

### Extension Structure

```typescript
// extensions/models/echo_audit.ts
export const extension = {
type: "swamp/echo", // target type to extend
methods: [{
audit: {
description: "Audit the echo message",
execute: async (definition, _context) => ({
data: {
attributes: { audited: true, name: definition.name },
name: "audit-result",
},
}),
},
}],
};
```

| Field | Required | Description |
| --------- | -------- | ------------------------------------- |
| `type` | Yes | Target model type to extend |
| `methods` | Yes | Array of method record objects to add |

### Extension Rules

- Extensions **cannot** change the target model's Zod schema
- Extensions **only** add new methods — no overriding existing methods
- `methods` is always an array of `Record<string, MethodDef>` objects
- One file can contain one method or many methods
- Multiple extension files can target the same type
- Extension methods without `inputAttributesSchema` inherit the target model's
- Models are loaded first, then extensions (two-pass loading)

## Model Discovery

Swamp discovers extension models in this order:
Swamp discovers models and extensions recursively:

1. **Repository extensions**: `{repo}/extensions/models/*.ts`
1. **Repository extensions**: `{repo}/extensions/models/**/*.ts` (nested dirs
supported)
2. **Built-in models**: Bundled with swamp binary

Files are classified by export name: `export const model` defines new types,
`export const extension` adds methods to existing types. Files can live in
subdirectories for organization (e.g., `extensions/models/aws/s3_audit.ts`).

Repository models take precedence, allowing you to override built-in types.

## Key Rules

1. **Export**: Must use `export const model = { ... }`
1. **Export**: Use `export const model = { ... }` for new types or
`export const extension = { ... }` for extending existing types
2. **Import**: Only `import { z } from "npm:zod@4";` is needed
3. **Type naming**: Use `namespace/name` format to avoid conflicts
4. **No type annotations**: Avoid TypeScript types in execute parameters
5. **File naming**: Use snake_case (`my_model.ts`), test files excluded
6. **Nesting**: Files can live in subdirectories for organization

## Data Ownership

Expand Down
72 changes: 72 additions & 0 deletions .claude/skills/swamp-extension-model/references/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,75 @@ attributes:
This pattern enables dynamic configuration where one model generates values that
are consumed by dependent models, with the workflow engine automatically
resolving execution order based on expression dependencies.

## Extending Existing Model Types

### Single Method Extension

```typescript
// extensions/models/echo_audit.ts
export const extension = {
type: "swamp/echo",
methods: [{
audit: {
description: "Audit the echo message",
execute: async (definition, _context) => ({
data: {
attributes: {
audited: true,
name: definition.name,
auditedAt: new Date().toISOString(),
},
name: "audit-result",
},
}),
},
}],
};
```

### Multiple Methods in One Extension File

```typescript
// extensions/models/echo_extras.ts
export const extension = {
type: "swamp/echo",
methods: [{
audit: {
description: "Audit the echo message",
execute: async (definition, _context) => ({
data: {
attributes: { audited: true, name: definition.name },
name: "audit-result",
},
}),
},
validate: {
description: "Validate the echo message format",
execute: async (definition, _context) => ({
data: {
attributes: {
valid: definition.attributes.message.length > 0,
length: definition.attributes.message.length,
},
name: "validation-result",
},
}),
},
}],
};
```

### Nested Directory Organization

Extension and model files can live in subdirectories for organization:

```
extensions/models/
aws/
s3_bucket.ts # export const model (new type)
s3_audit.ts # export const extension (extends aws s3)
monitoring/
health_check.ts # export const model (new type)
echo_audit.ts # export const extension (extends swamp/echo)
```
28 changes: 25 additions & 3 deletions .claude/skills/swamp-extension-model/references/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@

## Common Errors

### "No 'model' export found"
### "No 'model' or 'extension' export found"

Must use named export:
Must use a named export for either a model or extension:

```typescript
// Wrong
const model = { ... };

// Correct
// Correct — new model type
export const model = { ... };

// Correct — extend existing type
export const extension = { ... };
```

### "Model must have at least one of resourceAttributesSchema or dataAttributesSchema"
Expand Down Expand Up @@ -41,6 +44,25 @@ type: "echo"; // May conflict
type: "mycompany/echo"; // Unique
```

### "Cannot extend unregistered model type: ..."

The extension targets a model type that isn't registered. Ensure the type string
matches exactly (e.g., `"swamp/echo"`, not `"echo"`). If extending a user model,
both files must be in the same models directory — models are loaded before
extensions automatically.

### "Method 'X' already exists on model type 'Y'"

The extension tries to add a method with the same name as an existing method.
Extensions can only add new methods, not override existing ones. Use a different
method name.

### "Duplicate method name 'X' within extension methods array"

The same method name appears in multiple elements of the `methods` array within
a single extension file. Each method name must be unique across all array
elements.

### Syntax errors on load

Avoid inline TypeScript type annotations in execute parameters:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ experiments/webapp/frontend/dist/
experiments/webapp/frontend/tsconfig.tsbuildinfo
.vault-*/
.claude/settings.local.json
CLAUDE.local.md
.envrc
7 changes: 7 additions & 0 deletions src/cli/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ async function loadUserModels(): Promise<void> {
const loader = new UserModelLoader();
const result = await loader.loadModels(absoluteModelsDir);

// Log extension successes at debug level
if (Deno.env.get("SWAMP_DEBUG")) {
for (const file of result.extended) {
console.debug(`Extended model type from ${file}`);
}
}

// Log failures as warnings (don't block CLI startup)
for (const failure of result.failed) {
console.error(
Expand Down
39 changes: 39 additions & 0 deletions src/domain/models/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,45 @@ export class ModelRegistry {
this.models.set(key, model);
}

/**
* Extends an existing model with additional methods.
* Creates a new merged ModelDefinition (immutable — doesn't mutate the existing object).
*
* @param type - The model type to extend (raw or normalized)
* @param methods - Additional methods to add
* @throws If the target type is not registered
* @throws If any method name conflicts with existing methods
*/
extend(
type: string | ModelType,
methods: Record<string, MethodDefinition>,
): void {
const modelType = typeof type === "string" ? ModelType.create(type) : type;
const key = modelType.normalized;
const existing = this.models.get(key);

if (!existing) {
throw new Error(`Cannot extend unregistered model type: ${key}`);
}

// Check for method name conflicts
for (const methodName of Object.keys(methods)) {
if (existing.methods[methodName]) {
throw new Error(
`Method '${methodName}' already exists on model type '${key}'`,
);
}
}

// Create a new merged ModelDefinition (immutable)
const merged: ModelDefinition = {
...existing,
methods: { ...existing.methods, ...methods },
};

this.models.set(key, merged);
}

/**
* Gets a model definition by type.
*
Expand Down
128 changes: 128 additions & 0 deletions src/domain/models/model_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,131 @@ Deno.test("defineModel is idempotent when called with same model", () => {
assertEquals(result1, model);
assertEquals(result2, model);
});

// ModelRegistry.extend() tests

Deno.test("ModelRegistry.extend adds methods to existing model", () => {
const registry = new ModelRegistry();
const model = createTestModel("swamp/extend-test");
registry.register(model);

registry.extend("swamp/extend-test", {
read: {
description: "Read the data",
inputAttributesSchema: z.object({ message: z.string() }),
execute: () => Promise.resolve({ dataOutputs: [] }),
},
});

const extended = registry.get("swamp/extend-test");
assertEquals(extended !== undefined, true);
assertEquals("write" in extended!.methods, true);
assertEquals("read" in extended!.methods, true);
});

Deno.test("ModelRegistry.extend throws on unregistered type", () => {
const registry = new ModelRegistry();

assertThrows(
() =>
registry.extend("swamp/nonexistent", {
read: {
description: "Read",
inputAttributesSchema: z.object({}),
execute: () => Promise.resolve({ dataOutputs: [] }),
},
}),
Error,
"Cannot extend unregistered model type: swamp/nonexistent",
);
});

Deno.test("ModelRegistry.extend throws on method name conflict", () => {
const registry = new ModelRegistry();
const model = createTestModel("swamp/conflict-test");
registry.register(model);

assertThrows(
() =>
registry.extend("swamp/conflict-test", {
write: {
description: "Duplicate write",
inputAttributesSchema: z.object({}),
execute: () => Promise.resolve({ dataOutputs: [] }),
},
}),
Error,
"Method 'write' already exists on model type 'swamp/conflict-test'",
);
});

Deno.test("ModelRegistry.extend preserves original methods and schema", () => {
const registry = new ModelRegistry();
const model = createTestModel("swamp/preserve-test");
registry.register(model);

const originalSchema = model.inputAttributesSchema;

registry.extend("swamp/preserve-test", {
read: {
description: "Read the data",
inputAttributesSchema: z.object({}),
execute: () => Promise.resolve({ dataOutputs: [] }),
},
});

const extended = registry.get("swamp/preserve-test");
assertEquals(extended!.inputAttributesSchema, originalSchema);
assertEquals(extended!.version, 1);
assertEquals(extended!.methods.write.description, "Write message to data");
});

Deno.test("ModelRegistry.extend - extended methods are callable", async () => {
const registry = new ModelRegistry();
const model = createTestModel("swamp/callable-test");
registry.register(model);

registry.extend("swamp/callable-test", {
greet: {
description: "Greet",
inputAttributesSchema: z.object({ message: z.string() }),
execute: (definition: Definition, _context: MethodContext) => {
const content = new TextEncoder().encode(
JSON.stringify({
greeting: `Hello, ${definition.attributes.message}`,
}),
);
return Promise.resolve({
dataOutputs: [{
name: "greeting",
specType: DataSpecType.create("data"),
content,
metadata: {
contentType: "application/json",
lifetime: "infinite" as const,
garbageCollection: 10,
streaming: false,
tags: { type: "data" },
ownerDefinition: {
definitionHash: "test-hash",
ownerType: "model-method" as const,
ownerRef: "greet",
},
},
}],
});
},
},
});

const extended = registry.get("swamp/callable-test")!;
const definition = Definition.create({
name: "test",
attributes: { message: "world" },
});
const context = createTestContext(extended.type);

const result = await extended.methods.greet.execute(definition, context);
const attrs = getDataOutputAttributes(result.dataOutputs);
assertEquals(attrs?.greeting, "Hello, world");
});
Loading