stillmade-sdk 0.1.36 → 0.1.38
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/AGENTS.md +8 -8
- package/IMAGE_DESCRIPTION.md +2 -0
- package/MODULES.md +68 -10
- package/NATIVE_MCP.md +18 -7
- package/PASTE_TO_LLM.md +121 -50
- package/PORTABLE.md +7 -3
- package/README.md +19 -6
- package/REMIX.md +6 -0
- package/SDK.md +226 -103
- package/TRANSCRIPTION.md +4 -2
- package/UI_AESTHETIC.md +2 -0
- package/api-index.json +1 -1
- package/blocks/narration/package.js +1 -1
- package/blocks/narration/versions/1.0.1/package.js +221 -0
- package/blocks/native/sm.animated-scene/versions/2.3.9/package.js +769 -0
- package/blocks/native/sm.board-editor/versions/2.2.11/package.js +551 -0
- package/blocks/native/sm.describe-media/versions/2.1.0/package.js +140 -0
- package/blocks/native/sm.panel-strip/versions/2.4.2/package.js +1316 -0
- package/blocks/native/sm.photo-notes/versions/1.1.0/package.js +170 -0
- package/blocks/native/sm.pipeline/versions/2.2.6/package.js +1095 -0
- package/blocks/screen-recorder/package.js +1 -1
- package/blocks/screen-recorder/versions/1.0.1/package.js +223 -0
- package/package.json +1 -1
- package/packages/block-cli/cli.js +91 -35
- package/packages/block-cli/dev-host/assets/host.js +35 -32
- package/packages/block-cli/dev-host/assets/index.css +1 -1
- package/packages/block-cli/dev-server.js +4 -0
- package/packages/block-cli/ready-gate.js +19 -4
- package/packages/block-cli/templates.js +33 -21
- package/packages/block-platform/block-readiness.js +10 -1
- package/packages/block-platform/catalog.js +14 -3
- package/packages/block-platform/chat-packages.js +6 -0
- package/packages/block-platform/flow-inspection.js +3 -1
- package/packages/block-platform/project-target.js +57 -0
- package/packages/block-platform/project-type-composer.js +4 -3
- package/packages/block-platform/project-types.js +5 -2
- package/packages/block-sdk/admission.js +4 -1
- package/packages/block-sdk/block-tier.js +23 -0
- package/packages/block-sdk/builder-contract.js +12 -10
- package/packages/block-sdk/capabilities.js +23 -10
- package/packages/block-sdk/collaboration-admission.js +3 -0
- package/packages/block-sdk/contracts.js +13 -1
- package/packages/block-sdk/example-media-data.js +16 -0
- package/packages/block-sdk/example-media.js +42 -0
- package/packages/block-sdk/first-party-identities.js +8 -0
- package/packages/block-sdk/first-party-packages.js +11 -1
- package/packages/block-sdk/index.d.ts +6 -2
- package/packages/block-sdk/local-admission.js +6 -5
- package/packages/block-sdk/media-transform.js +59 -3
- package/packages/block-sdk/mock-host.js +23 -2
- package/packages/block-sdk/mocks.js +4 -1
- package/packages/block-sdk/module-hosted.js +12 -2
- package/packages/block-sdk/port-types.d.ts +6 -0
- package/packages/block-sdk/port-types.js +32 -10
- package/packages/block-sdk/shared-agent-controls.js +25 -2
- package/packages/block-sdk/shared-controls.js +17 -3
- package/packages/block-sdk/testing.js +46 -3
- package/packages/block-sdk/view-design.js +67 -0
- package/packages/block-sdk/view-renderer.js +50 -8
- package/packages/design-system/block-kit.css +6 -4
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {assert,plain} from '../block-sdk/contracts.js';
|
|
2
|
+
|
|
3
|
+
// A Project Type's target (October 26, 2026; docs/BLOCK_TARGETING_PLAN.md): the same Blocks read as if made for one
|
|
4
|
+
// job. `target` says who the Type is for and the rules its AI steps keep; each Step's `copy` holds the words people see
|
|
5
|
+
// on it (its inputs' labels, hints and placeholders, its results' names) and which inputs it hides or locks. The facts
|
|
6
|
+
// a target asks for before starting are the Type's onboarding questions (project-onboarding.js). Everything here is
|
|
7
|
+
// optional: a Type without a target, and a Step without copy, read as they always have.
|
|
8
|
+
export const TARGET_LIMITS=Object.freeze({audience:120,rules:8,rule:200,label:60,help:200,placeholder:120,ports:12});
|
|
9
|
+
const short=(value,max)=>typeof value==='string'&&value.trim().length>0&&value.length<=max;
|
|
10
|
+
|
|
11
|
+
export function validateProjectTarget(type){
|
|
12
|
+
if(type.target===undefined)return;
|
|
13
|
+
const target=type.target;
|
|
14
|
+
assert(plain(target)&&Object.keys(target).every(key=>['audience','rules'].includes(key)),'PROJECT_TARGET','A target names its audience and the rules its AI steps keep');
|
|
15
|
+
assert(target.audience===undefined||short(target.audience,TARGET_LIMITS.audience),'PROJECT_TARGET',`Say who it is for in up to ${TARGET_LIMITS.audience} characters`);
|
|
16
|
+
assert(target.rules===undefined||Array.isArray(target.rules)&&target.rules.length<=TARGET_LIMITS.rules&&target.rules.every(rule=>short(rule,TARGET_LIMITS.rule)),'PROJECT_TARGET',`Keep up to ${TARGET_LIMITS.rules} rules of up to ${TARGET_LIMITS.rule} characters`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A Step's copy: words for its declared inputs and outputs, and inputs it hides (unless required and unset) or locks (only when set). */
|
|
20
|
+
export function validateStageCopy(stage,manifest){
|
|
21
|
+
if(stage.copy===undefined)return;
|
|
22
|
+
const copy=stage.copy,inputs=manifest?.inputs||{},outputs=manifest?.outputs||{};
|
|
23
|
+
assert(plain(copy)&&Object.keys(copy).every(key=>['inputs','outputs','hidden','locked'].includes(key)),'STEP_COPY','Step wording covers its inputs, results, and hidden or locked inputs');
|
|
24
|
+
const words=(map,declared,fields)=>assert(map===undefined||plain(map)&&Object.keys(map).length<=TARGET_LIMITS.ports&&Object.entries(map).every(([port,value])=>Object.hasOwn(declared,port)&&plain(value)&&Object.keys(value).length>0&&Object.entries(value).every(([field,text])=>fields[field]&&short(text,fields[field]))),'STEP_COPY','Step wording names the Block’s own inputs and results, with short words');
|
|
25
|
+
words(copy.inputs,inputs,{label:TARGET_LIMITS.label,help:TARGET_LIMITS.help,placeholder:TARGET_LIMITS.placeholder});
|
|
26
|
+
words(copy.outputs,outputs,{label:TARGET_LIMITS.label});
|
|
27
|
+
const ports=list=>list===undefined||Array.isArray(list)&&list.length<=TARGET_LIMITS.ports&&new Set(list).size===list.length&&list.every(port=>typeof port==='string'&&Object.hasOwn(inputs,port));
|
|
28
|
+
assert(ports(copy.hidden)&&ports(copy.locked),'STEP_COPY','Hide or lock only the Block’s own inputs');
|
|
29
|
+
const set=port=>Object.hasOwn(stage.config||{},port)||Object.hasOwn(stage.inputSources||{},port);
|
|
30
|
+
assert((copy.hidden||[]).every(port=>set(port)||inputs[port].required===false),'STEP_COPY','An input can be hidden only when the Step sets it or the Block does not need it');
|
|
31
|
+
assert((copy.locked||[]).every(set),'STEP_COPY','Lock only an input the Step sets');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// What a planner may write, cleaned before it reaches a Type: anything malformed or naming a port the Block lacks is
|
|
35
|
+
// dropped, never trusted. steps: [{id, inputs, outputs, config}] as placed; raw copy is keyed by those Step ids.
|
|
36
|
+
const clip=(value,max)=>typeof value==='string'?value.replace(/\s+/g,' ').trim().slice(0,max):'';
|
|
37
|
+
// A question asks for a fact the person types; a file ("Sermon recording", "Listing photos") is taken by a Step itself.
|
|
38
|
+
const ASKS_FOR_FILE=/\b(?:recordings?|audio|videos?|footage|photos?|pictures?|images?|files?|clips?)\s*$/i,COUNTS=/^(?:how many|number of)\b/i;
|
|
39
|
+
const questionId=(label,used)=>{let id=clip(label,40).toLowerCase().replace(/[^a-z0-9]+/g,'_').replace(/^_+|_+$/g,'').replace(/^[^a-z]+/,'')||'detail';id=id.slice(0,32);let next=id;for(let n=2;used.has(next);n++)next=`${id}_${n}`;used.add(next);return next;};
|
|
40
|
+
export function cleanTarget(raw,steps){
|
|
41
|
+
if(!plain(raw))return null;
|
|
42
|
+
const target={},audience=clip(raw.audience,TARGET_LIMITS.audience),rules=(Array.isArray(raw.rules)?raw.rules:[]).map(rule=>clip(rule,TARGET_LIMITS.rule)).filter(Boolean).slice(0,TARGET_LIMITS.rules);
|
|
43
|
+
if(audience)target.audience=audience;
|
|
44
|
+
if(rules.length)target.rules=rules;
|
|
45
|
+
const used=new Set(),questions=(Array.isArray(raw.questions)?raw.questions:[]).filter(plain).map(question=>({label:clip(question.label,120),required:question.required===true,long:question.long===true})).filter(question=>question.label&&(!ASKS_FOR_FILE.test(question.label)||COUNTS.test(question.label))).slice(0,8).map(question=>({id:questionId(question.label,used),label:question.label,type:question.long?'textarea':'text',...(question.required?{required:true}:{})}));
|
|
46
|
+
const copy={};
|
|
47
|
+
for(const step of steps||[]){
|
|
48
|
+
const given=plain(raw.copy)?raw.copy[step.id]:null;if(!plain(given))continue;
|
|
49
|
+
const words=(map,declared,fields)=>Object.fromEntries(Object.entries(plain(map)?map:{}).filter(([port,value])=>Object.hasOwn(declared||{},port)&&plain(value)).map(([port,value])=>[port,Object.fromEntries(Object.entries(fields).map(([field,max])=>[field,clip(value[field],max)]).filter(([,text])=>text))]).filter(([,value])=>Object.keys(value).length).slice(0,TARGET_LIMITS.ports));
|
|
50
|
+
const entry={},inputs=words(given.inputs,step.inputs,{label:TARGET_LIMITS.label,help:TARGET_LIMITS.help,placeholder:TARGET_LIMITS.placeholder}),outputs=words(given.outputs,step.outputs,{label:TARGET_LIMITS.label});
|
|
51
|
+
if(Object.keys(inputs).length)entry.inputs=inputs;
|
|
52
|
+
if(Object.keys(outputs).length)entry.outputs=outputs;
|
|
53
|
+
const label=clip(given.label,80);
|
|
54
|
+
if(label||Object.keys(entry).length)copy[step.id]={...(label?{label}:{}),...entry};
|
|
55
|
+
}
|
|
56
|
+
return Object.keys(target).length||questions.length||Object.keys(copy).length?{...target,...(questions.length?{questions}:{}),...(Object.keys(copy).length?{copy}:{})}:null;
|
|
57
|
+
}
|
|
@@ -2,6 +2,7 @@ import {plainBlockDescription} from './plain-descriptions.js';
|
|
|
2
2
|
import {assert,plain,safeDocument,ROOT_CHAT_ID,stableStringify,valueMatches} from '../block-sdk/contracts.js';
|
|
3
3
|
import {ADAPTERS,validateConnectionAdapter} from '../block-sdk/connection-adapters.js';
|
|
4
4
|
import {STANDARD_SEMANTIC_RELATIONSHIPS} from '../block-sdk/semantic-contracts.js';
|
|
5
|
+
import {isChatPackage} from './chat-packages.js';
|
|
5
6
|
import {createUniversalResolver} from './universal-resolver.js';
|
|
6
7
|
import {builtinSemanticAdapters} from './semantic-adapters.js';
|
|
7
8
|
import {assertInputSource} from '../block-sdk/input-source-policy.js';
|
|
@@ -112,7 +113,7 @@ function possibleConverterMeaning(packages){
|
|
|
112
113
|
const byMeaning=new Map(),withoutMeaning=[];let order=0;
|
|
113
114
|
for(const pkg of packages){
|
|
114
115
|
const manifest=manifestOf(pkg);
|
|
115
|
-
if(!pkg?.manifest||!['javascript','recipe','capability','comfyui'].includes(manifest.runtime)||manifest.id
|
|
116
|
+
if(!pkg?.manifest||!['javascript','recipe','capability','comfyui'].includes(manifest.runtime)||isChatPackage(manifest.id))continue;
|
|
116
117
|
for(const [key,port] of Object.entries(manifest.inputs||{})){
|
|
117
118
|
const entry={pkg,key,port,order:order++};
|
|
118
119
|
if(!port.semantic){withoutMeaning.push(entry);continue;}
|
|
@@ -194,7 +195,7 @@ function blockConversionCandidates(type,available,values,stage,key,requirement,p
|
|
|
194
195
|
function chainedBlockConversionCandidates(type,available,values,stage,key,requirement,permissions,possibleMeaning) {
|
|
195
196
|
const results=[],enabled=type.stages.filter(item=>item.enabled!==false),positions=new Map(type.stages.map((item,index)=>[item.id,index]));
|
|
196
197
|
const selectable=selectableConverters(type,available),searchKey=converterSearchKey(type,available);
|
|
197
|
-
const packages=available.filter(pkg=>pkg?.manifest&&['javascript','recipe','capability','comfyui'].includes(pkg.manifest.runtime)
|
|
198
|
+
const packages=available.filter(pkg=>pkg?.manifest&&['javascript','recipe','capability','comfyui'].includes(pkg.manifest.runtime)&&!isChatPackage(pkg.manifest.id));
|
|
198
199
|
const maxDepth=Math.min(packages.length,100-type.stages.length);
|
|
199
200
|
if(maxDepth<2)return {results,incomplete:false};
|
|
200
201
|
// Work backward through a permissive contract graph. This is only a lower
|
|
@@ -504,7 +505,7 @@ export function chooseProjectTypeConverterRoute(type,{stageId,key,sourceStageId,
|
|
|
504
505
|
assert(step.releaseDigest===undefined||/^[a-f0-9]{64}$/.test(step.releaseDigest),'CONNECTION','Choose a valid exact installed release.');
|
|
505
506
|
assert(step.packageIndex===undefined||Number.isInteger(step.packageIndex)&&step.packageIndex>=0,'CONNECTION','Choose an exact embedded converter slot.');
|
|
506
507
|
const pkg=resolveConverter(type,available,step);
|
|
507
|
-
assert(pkg&&['javascript','recipe','capability','comfyui'].includes(pkg.manifest.runtime)
|
|
508
|
+
assert(pkg&&['javascript','recipe','capability','comfyui'].includes(pkg.manifest.runtime)&&!isChatPackage(pkg.manifest.id)&&pkg.manifest.inputs?.[step.input]&&pkg.manifest.outputs?.[step.output],'CONNECTION','Choose available converter Block ports.');
|
|
508
509
|
return {step,pkg};
|
|
509
510
|
});
|
|
510
511
|
const registry=semanticRegistryForManifests([...type.stages.filter(stage=>stage.enabled!==false).map(stage=>manifestFor(type,stage,available)),...route.map(item=>item.pkg.manifest)]);
|
|
@@ -6,6 +6,7 @@ import {validateGoalSourceInputs} from './goal-source-inputs.js';
|
|
|
6
6
|
import {validateFailurePolicy} from './failure-policy.js';
|
|
7
7
|
import {validateProjectOutcome} from './project-outcome.js';
|
|
8
8
|
import {validateProjectOnboarding} from './project-onboarding.js';
|
|
9
|
+
import {validateProjectTarget,validateStageCopy} from './project-target.js';
|
|
9
10
|
import {validateConnectionAdapter} from '../block-sdk/connection-adapters.js';
|
|
10
11
|
import { assert, compatible, ID, plain, ROOT_CHAT_ID, safeDocument, stableStringify, VERSION, valueMatches } from '../block-sdk/contracts.js';
|
|
11
12
|
import {validateCondition} from './conditions.js';
|
|
@@ -20,13 +21,14 @@ export function validateProjectType(type, catalog, {allowEmpty=false,semantics,e
|
|
|
20
21
|
try {
|
|
21
22
|
safeDocument(type);
|
|
22
23
|
validateProjectOnboarding(type);
|
|
24
|
+
validateProjectTarget(type);
|
|
23
25
|
validateProjectInputs(type);
|
|
24
26
|
validateGoalSourceMaterials(type);
|
|
25
27
|
validateGoalSourceInputs(type);
|
|
26
28
|
validateProjectOutcome(type.outcome);
|
|
27
29
|
assert(type.acceptanceCriteria===undefined||Array.isArray(type.acceptanceCriteria)&&type.acceptanceCriteria.length<=24&&new Set(type.acceptanceCriteria.map(item=>item.key)).size===type.acceptanceCriteria.length&&type.acceptanceCriteria.every(item=>plain(item)&&Object.keys(item).length===3&&typeof item.key==='string'&&/^[a-z][a-z0-9_]{0,63}$/.test(item.key)&&typeof item.description==='string'&&item.description.trim().length>=3&&item.description.length<=240&&['artifact-inspection','workflow-receipt','human-review'].includes(item.verification)),'PROJECT_TYPE','Result checks must be distinct, bounded and unverified until a real result is inspected.');
|
|
28
30
|
assert(plain(type) && type.schemaVersion === 1 && ID.test(type.id || '') && VERSION.test(type.version || ''), 'PROJECT_TYPE', 'Invalid project type identity');
|
|
29
|
-
assert(Object.keys(type).every(k => ['schemaVersion', 'id', 'version', 'name', 'description', 'stages', 'connections', 'legacyMode', 'summary', 'packages', 'provenance','license','defaults','onboarding','outcome','rules','loops','projectInputs','startingChat','acceptanceCriteria','goalSourceMaterials','goalSourceInputs','groups'].includes(k)), 'ROOT_PROTECTED', 'Project type contains unsupported settings');
|
|
31
|
+
assert(Object.keys(type).every(k => ['schemaVersion', 'id', 'version', 'name', 'description', 'stages', 'connections', 'legacyMode', 'summary', 'packages', 'provenance','license','defaults','onboarding','outcome','rules','loops','projectInputs','startingChat','acceptanceCriteria','goalSourceMaterials','goalSourceInputs','groups','target'].includes(k)), 'ROOT_PROTECTED', 'Project type contains unsupported settings');
|
|
30
32
|
assert(type.startingChat === undefined || type.startingChat === 'native', 'ROOT_PROTECTED', 'Starting Chat is provided by StillMade.');
|
|
31
33
|
allowEmpty = allowEmpty || type.startingChat === 'native';
|
|
32
34
|
assert(type.license === undefined || typeof type.license === 'string' && type.license.length > 0 && type.license.length <= 200,'LICENSE','Use a bounded license identifier');
|
|
@@ -48,7 +50,7 @@ export function validateProjectType(type, catalog, {allowEmpty=false,semantics,e
|
|
|
48
50
|
assert(Array.isArray(type.stages) && (allowEmpty || type.stages.length > 0) && type.stages.length <= 100, 'PROJECT_TYPE', 'Expected 1–100 stages');
|
|
49
51
|
const stages = new Map();
|
|
50
52
|
for (const stage of type.stages) {
|
|
51
|
-
assert(plain(stage) && Object.keys(stage).every(k => ['id', 'blockId', 'version', 'packageIndex', 'label', 'enabled', 'config', 'inputSources', 'condition', 'failurePolicy'].includes(k)), 'ROOT_PROTECTED', 'Step accepts identity, version, exact package index, label, enabled and
|
|
53
|
+
assert(plain(stage) && Object.keys(stage).every(k => ['id', 'blockId', 'version', 'packageIndex', 'label', 'enabled', 'config', 'inputSources', 'condition', 'failurePolicy', 'copy'].includes(k)), 'ROOT_PROTECTED', 'Step accepts identity, version, exact package index, label, enabled, configuration and wording');
|
|
52
54
|
assert(/^[a-z][a-z0-9-]*$/.test(stage.id) && !stages.has(stage.id), 'PROJECT_TYPE', 'Duplicate or invalid stage ID');
|
|
53
55
|
assert(stage.label === undefined || typeof stage.label === 'string' && stage.label.trim() && stage.label.length <= 120, 'PROJECT_TYPE', 'Stage label must be 1–120 characters');
|
|
54
56
|
assert(stage.blockId !== ROOT_CHAT_ID, 'ROOT_PROTECTED', 'Starting chat is provided by the host and cannot be added as a Step');
|
|
@@ -66,6 +68,7 @@ export function validateProjectType(type, catalog, {allowEmpty=false,semantics,e
|
|
|
66
68
|
assert(stage.config === undefined || plain(stage.config) && Object.entries(stage.config).every(([key,value])=>Object.hasOwn(manifest.inputs,key) && manifest.inputs[key].semantic!=='workflow_approval' && valueMatches(value,manifest.inputs[key])),'STEP_CONFIG','Step settings must match the block input contract and cannot pre-approve a workflow');
|
|
67
69
|
assert(stage.inputSources===undefined||plain(stage.inputSources)&&Object.entries(stage.inputSources).every(([key,id])=>Object.hasOwn(manifest.inputs,key)&&manifest.inputs[key].semantic!=='workflow_approval'&&typeof id==='string'&&id.length>0&&id.length<=2000&&!Object.hasOwn(stage.config||{},key)),'STEP_SOURCE','Choose an exact context source for a declared input; approval cannot be preselected.');
|
|
68
70
|
validateCondition(stage.condition,manifest);
|
|
71
|
+
validateStageCopy(stage,manifest);
|
|
69
72
|
validateFailurePolicy(stage.failurePolicy,manifest,type.packages||[]);
|
|
70
73
|
stages.set(stage.id, manifest);
|
|
71
74
|
}
|
|
@@ -126,6 +126,9 @@ export function scanAdmissionPackage(pkg){
|
|
|
126
126
|
}
|
|
127
127
|
// onProgress hears each stage as it ends ({stage:'read'|'tests'|'sample', state}) and each fixture test (onTest
|
|
128
128
|
// events with stage:'tests'), so a review can show the real progress; the returned report stays the only verdict.
|
|
129
|
+
// run(pkg,input,options,fixture): fixture is the test the sample input is taken from (the first, unless a
|
|
130
|
+
// sampleInput is given), so a module Block's sample is answered by that fixture's own mocks (October 27, 2026:
|
|
131
|
+
// the sample got the generic stand-ins, and a Block checking model words against its transcript failed).
|
|
129
132
|
export async function admitPackage(pkg,{test=testPackage,run,sampleInput,platformProbe,desktop,onProgress}={}) {
|
|
130
133
|
const tell=event=>{try{onProgress?.(event);}catch{}},onTest=event=>tell({stage:'tests',...event});
|
|
131
134
|
safeDocument(pkg);const scan=scanAdmissionPackage(pkg),scanFailed=scan.results.some(row=>!row.passed && !['Appearance compatibility','Task interface design'].includes(row.name));
|
|
@@ -137,7 +140,7 @@ export async function admitPackage(pkg,{test=testPackage,run,sampleInput,platfor
|
|
|
137
140
|
const tested=await (native?testPackage(pkg,{desktop,onTest}):test(pkg,{onTest}));
|
|
138
141
|
tell({stage:'tests',state:tested.passed?'done':'failed'});const results=[...scan.results,...tested.results.map(result=>({...result,name:`${native?'Desktop host':'Sandbox'} contract test: ${result.name}`}))];
|
|
139
142
|
let preview;
|
|
140
|
-
if(tested.passed && (run||desktop)){tell({stage:'sample',state:'running'});try{preview=native?await runPackageAsync(pkg,sampleInput || pkg.tests[0].input,{desktop}):await run(pkg,sampleInput || pkg.tests[0].input,pkg.manifest.state?{state:initialBlockState(pkg.manifest)}:{});if(pkg.manifest.state)validateBlockState(pkg.manifest,preview.state);results.push({name:'Sample execution and input/output validation',passed:true});tell({stage:'sample',state:'done',outputs:preview.outputs});}catch(error){results.push({name:'Sample execution and input/output validation',passed:false,message:error.message});tell({stage:'sample',state:'failed',message:error.message});}}
|
|
143
|
+
if(tested.passed && (run||desktop)){tell({stage:'sample',state:'running'});try{preview=native?await runPackageAsync(pkg,sampleInput || pkg.tests[0].input,{desktop}):await run(pkg,sampleInput || pkg.tests[0].input,pkg.manifest.state?{state:initialBlockState(pkg.manifest)}:{},sampleInput?undefined:pkg.tests[0]);if(pkg.manifest.state)validateBlockState(pkg.manifest,preview.state);results.push({name:'Sample execution and input/output validation',passed:true});tell({stage:'sample',state:'done',outputs:preview.outputs});}catch(error){results.push({name:'Sample execution and input/output validation',passed:false,message:error.message});tell({stage:'sample',state:'failed',message:error.message});}}
|
|
141
144
|
const sourceDigest=await digest(pkg);
|
|
142
145
|
const platformChecks=await testPlatforms(pkg,{probe:platformProbe,runtimePassed:tested.passed===true && results.every(result=>result.passed),sourceDigest});
|
|
143
146
|
return {...summary(results),digest:sourceDigest,platformChecks,...(preview ? {preview} : {}),...(native?{execution:'desktop-host-executor',liveVerified:false,reviewRequired:true}:pkg.manifest.runtime==='capability'?{liveVerified:false,reviewRequired:true}:{})};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Three sizes of Block (October 20, 2026). A catalog that offers them as peers
|
|
2
|
+
// makes a planner stitch one job out of its ingredients: a script writer, then a
|
|
3
|
+
// voice, for one narration. The tier is derived from the manifest, never typed:
|
|
4
|
+
// app a workspace a person works in (Editor, Canvas, Shots): placed, never wired automatically
|
|
5
|
+
// job one deliverable a person would name, from the capabilities it needs: what Chat picks
|
|
6
|
+
// ingredient one hosted operation with nothing of its own: what a job is made of
|
|
7
|
+
// extension an Editor tool (editor-extension)
|
|
8
|
+
// glue the parts any project may use between Steps (When, Only new items, AI write, Import media)
|
|
9
|
+
export const BLOCK_TIERS=Object.freeze(['app','job','ingredient','extension','glue']);
|
|
10
|
+
const GLUE=new Set(['sm.when','sm.new-items','sm.continue-if','sm.connect-app','sm.ai-write','sm.ai-decide','sm.write-summary','sm.import-media']);
|
|
11
|
+
const hostedOperations=manifest=>(manifest?.permissions?.capabilities||[]).filter(name=>name!=='connection.execute'&&name!=='comfyui.execute');
|
|
12
|
+
|
|
13
|
+
/** The tier of a Block, from its manifest. */
|
|
14
|
+
export function blockTier(manifest){
|
|
15
|
+
if(!manifest)return 'job';
|
|
16
|
+
if(manifest.kind==='editor-extension')return 'extension';
|
|
17
|
+
if(GLUE.has(manifest.id))return 'glue';
|
|
18
|
+
// One declarative hosted operation is an ingredient whatever label it carries: it owns no document and does one thing.
|
|
19
|
+
if(manifest.runtime==='capability'&&hostedOperations(manifest).length===1)return 'ingredient';
|
|
20
|
+
if(manifest.kind==='workspace')return 'app';
|
|
21
|
+
return 'job';
|
|
22
|
+
}
|
|
23
|
+
export const TIER_WORDS=Object.freeze({app:'an app a person works in',job:'a job that makes one deliverable',ingredient:'one hosted operation, an ingredient of a job',extension:'an Editor tool',glue:'glue between Steps'});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import {HOSTED_OPERATION_NAMES} from './contracts.js';
|
|
10
10
|
|
|
11
11
|
export const BUILDER_CONTRACT_ID='stillmade.block-builder';
|
|
12
|
-
export const BUILDER_CONTRACT_VERSION='1.
|
|
12
|
+
export const BUILDER_CONTRACT_VERSION='1.11.0';
|
|
13
13
|
export const BUILDER_CONTRACT_STAMP=`${BUILDER_CONTRACT_ID}@${BUILDER_CONTRACT_VERSION}`;
|
|
14
14
|
export const BUILDER_CONTRACT_DOCS_PATH='/docs/build/block-builder-contract';
|
|
15
15
|
// Block names appear in step bars, cards and pickers; new releases keep them short.
|
|
@@ -34,9 +34,11 @@ const SECTIONS=Object.freeze([
|
|
|
34
34
|
rule('source-of-truth','both',
|
|
35
35
|
'Use only documented SDK runtimes, types, operations, permissions and host APIs; never invent one.',
|
|
36
36
|
'Treat the current SDK (`stillmade.block.json` manifest, `packages/block-sdk` contracts, validators, examples and published docs) as the source of truth. Before writing code, read the manifest contract, at least two working first-party Blocks closest to the request, the runtime you will use, the typed port and resolver contracts, and the shared-interface contract when you need a custom view. Do not invent runtimes, operations, permissions, ports, host services or bridge calls. If the documentation and the validator disagree, the validator wins and the mismatch should be reported.'),
|
|
37
|
+
// October 29, 2026: reuse follows the check's confidence. Only likely or verified-contract does the job; a
|
|
38
|
+
// partial match is a close Block to look at, never a reason to stop building.
|
|
37
39
|
rule('reuse-first','both',
|
|
38
|
-
'Reuse before building: use
|
|
39
|
-
'Before building, check what
|
|
40
|
+
'Reuse before building: use Blocks that do the job, offer a close one; build only what is missing.',
|
|
41
|
+
'Before building, check what exists (`find_existing_blocks` over MCP, or the Marketplace and your library) by the goal\'s inputs, results and operations. Only a `likely` or `verified-contract` match (a Block or a chain of two) does the job: use it. `partial` is a close Block to look at: remix it (license, notices and credit stay attached) or build only the missing step. Never republish another creator\'s source without remix lineage: public copies are refused. Build new when nothing does the job or the person wants their own, and name similar Blocks you considered.'),
|
|
40
42
|
rule('block-tier','both',
|
|
41
43
|
'Three sizes: an app a person works in, a job that makes one deliverable from the hosted operations it needs (what Chat picks), an ingredient (one hosted operation) that only a job is made of.',
|
|
42
44
|
'Blocks come in three sizes and the manifest decides which (packages/block-sdk/block-tier.js): an app is a workspace a person works in (Editor, Canvas, Shots), placed in a Type and never wired automatically; a job is one deliverable a person would name (the narration, the captioned clip), built from the hosted operations it needs (`permissions.capabilities`) and reviewed in one place, the unit Chat picks for a Step; an ingredient is one hosted operation with nothing of its own (a declarative capability Block), what a job is made of. When two ingredients would stand in a row to make one deliverable (writing the words, then voicing them), build or pick the job that does both and keep the review point inside it. A job declares a second output when another Block consumes the intermediate (the script beside the voice); split into two jobs only when the intermediate has a life of its own or the halves have different owners or payment.'),
|
|
@@ -47,13 +49,13 @@ const SECTIONS=Object.freeze([
|
|
|
47
49
|
'Pick the runtime from the job. No hosted AI: a recipe or isolated `javascript`, both able to run unattended. One text, script or brief prompt returning text, script or JSON: a `capability` Block. Hosted operations on other values (a transcript, media, structured data), several hosted operations in a row, or a result shaped in code: one `module` Block, `module/main.js` calling `stillmade.hosted.run(operation, request)` for each operation in `permissions.capabilities` (MODULES.md), never one Block per operation; it runs in the person\'s browser while StillMade is open. Build a new Block in a folder with `stillmade-block create`, `test`, `ready` and `pack`, and import the file `pack` writes (a module Block writes `<id>-<version>.stillmade-module.json`) through Create, then Import Block. A `.stillmade-block` archive is only for adapting a GitHub repository.'),
|
|
48
50
|
rule('block-scope','both',
|
|
49
51
|
'One Step per job: start one only where a person works with the result, where finding new items turns into handling each, or where the result leaves StillMade; all else is a part.',
|
|
50
|
-
'A Step is one job: the work between two moments where a person or the outside world must see the result. Start a new Step only at one of three points; everything else is a part of the neighboring Step.\n\n1. **A person works with the result**: reads, edits, picks or approves it (a workspace Block such as a script, a Canvas or an Editor, or an approval).
|
|
52
|
+
'A Step is one job: the work between two moments where a person or the outside world must see the result. Start a new Step only at one of three points; everything else is a part of the neighboring Step.\n\n1. **A person works with the result**: reads, edits, picks or approves it (a workspace Block such as a script, a Canvas or an Editor, or an approval). So Bible, Canvas and Editor are separate Steps after Chat.\n2. **Finding new things turns into handling each one**: the trigger (a schedule or an app event), the app read, its filter and "only new" are the first job; what is done with each item is the next job. A trigger is a setting of the first job, never a Step of its own.\n3. **The result leaves StillMade**: it is sent, posted, published, paid or written into another app. Consecutive writes to the same app, with the glue that shapes data for them, are one job.\n\nIn a production the Steps are the workspaces a person opens: work feeding the next workspace is its part, else it joins the one before. A Block with its own interface (a workspace, editor or picker) is always its own Step; two such Steps are never one. A video ends in the Editor (1.0), which has captions and export built in, so Export video is never a Step beside it.\n\nA part that only runs in the desktop app cannot share a Step with cloud parts. Before adding a Step, name which of the three reasons makes it separate; if none applies, make it a part of a neighbor. A Step nobody would ever open must not exist on its own. Prefer an app task\'s own filter (a Gmail query such as `has:attachment filename:png`) to an AI check, and use AI decide only for judgement.\n\nBuild one Block for a job one Block can do: a module Block calls every hosted operation the job needs (it transcribes a sermon, then finds the verses said in it), a JavaScript Block does several local transforms, a workspace owns its whole document. A Block being built never takes an ingredient\'s result only so the ingredient can be its own part or Step (Transcribe in front of a verse finder): it calls that operation itself. Parts in one Step (`groups` in the Project Type) are for jobs made of Blocks that already exist and app tasks, each keeping its own permission, approval and credit rules. Never invent a combined runtime.\n\nExamples: "every new email with an image becomes a generated image" is two Steps, "Gmail · New emails with images" (When, fetch with an image filter, only new) and "Image from email" (AI write, Generate image); listing When, Fetch, Only new, AI decide, Continue only if, AI write and Generate image as seven Steps splits one job into its parts. "A new Meta lead goes to HubSpot" is three Steps: "Meta Ads · New leads", "Qualify lead" and "HubSpot · Add contact".'),
|
|
51
53
|
rule('capability-contract','block',
|
|
52
54
|
'Define the contract first: typed inputs (optional with safe defaults where possible), outputs, state, permissions, UI, failures and cancellation. Keep the interface small; keep the job whole.',
|
|
53
55
|
'Translate the request into an explicit capability contract before implementation: what the Block does; each input with its type, meaning and whether it is required (prefer optional settings with safe defaults over extra required questions); each output with its type and meaning; persistent state; project context it reads; permissions; UI; long-running behavior; expected failures; cancellation; determinism; and whether it needs hosted models, media, desktop tools or shared editing. Keep the public interface as small and general as possible (few required inputs, one primary output) and keep implementation details out of it; small means a small interface, never a fragment of a job. A Block that generates images, video or speech declares only its prompt and output: every Step that runs it gets the host’s generation settings (model, quality, length and resolution, voice, speed, how many), saved with the Step, changed on its page or by Chat, and shown with their cost before each run.'),
|
|
54
56
|
rule('compose','both',
|
|
55
|
-
'Blocks compose:
|
|
56
|
-
'A Block is one job: not a mini-app of many jobs and not a fragment of one. Its outputs become other Blocks\' inputs and its inputs come from other Blocks, project context or the user. Use the typed port system: shared StillMade types (`image`, `video`, `audio`, `asset`, `script`, `shot[]`, ...), a `
|
|
57
|
+
'Blocks compose: type and describe every port (never `inputPath: text`); an input taking any clip, photo or text has no `semantic`, so any Step\'s value fits. One primary input and output, reusable StillMade values out. The resolver fills inputs; users never wire JSON, IDs or paths.',
|
|
58
|
+
'A Block is one job: not a mini-app of many jobs and not a fragment of one. Its outputs become other Blocks\' inputs and its inputs come from other Blocks, project context or the user. Use the typed port system: shared StillMade types (`image`, `video`, `audio`, `asset`, `script`, `shot[]`, ...), `required`, a short `description`, and a `semantic` only where it narrows what fits. Mark exactly one primary input and one primary output so Project Type Steps connect automatically, and use `kind: "task"`: a `workspace` Block owns a project document and is never connected automatically. Inputs marked `context` read the project and cannot receive a connection from another Step. Output reusable StillMade values: media references (`{kind,assetId,versionId,url}`) and structured production values with `schemaVersion: 1`, preserving provenance, versions and relationships downstream Blocks need. Never make ordinary users wire JSON, copy IDs, understand internal paths or translate one Block\'s output into another: `createUniversalResolver()` resolves inputs from compatible upstream outputs, permitted project context and, for optional non-context inputs, `StillMade.requestFromUser`.'),
|
|
57
59
|
rule('feature-map','block',
|
|
58
60
|
'Keep `manifest.features` current: one {id,label,does,asks} per separate tool or mode, with requests it answers; never one the code lacks.',
|
|
59
61
|
'Every Block carries a feature map in `manifest.features`, written by whoever builds it while they build, so the person never writes it. Add one entry for each separate thing a person could ask for: a tool, a mode, a preset family or a kind of result (an editor\'s reaction GIFs, its motion presets, a recorder\'s screen recording), not one entry covering several tools. Each entry has a lowercase `id`, a `label` of 40 characters or fewer, `does` (what it does, 160 characters or fewer) and `asks`: 1–8 short requests in a customer\'s own words that this feature answers ("add a fire gif over my car"). `operation` names an id in the Block\'s own `manifest.operations` (omit it when there are none); `inputs`/`outputs` name declared ports. The gate fails a Block without one. Add or update the entry in the same change that adds or alters a tool, and remove it when the tool goes. List only what the code really does; listings are generated from this map, nobody can edit them, and StillMade matches requests to features. Features are not tested at publish: each shows as new until real use proves it.'),
|
|
@@ -67,8 +69,8 @@ const SECTIONS=Object.freeze([
|
|
|
67
69
|
`Name the Block in ${BLOCK_NAME_MAX_LENGTH} characters or fewer, in plain words.`,
|
|
68
70
|
`Give the Block a short, plain name of ${BLOCK_NAME_MAX_LENGTH} characters or fewer that says what it does ("Captioned Clip", not "Captioned Business Clip Generator With Review"). The name appears in step bars, Project Type cards and pickers, so put the detail in the description instead. New releases with a longer name do not pass readiness.`),
|
|
69
71
|
rule('ui','block',
|
|
70
|
-
'Prefer `manifest.ui`; custom views use the Block kit (`sm-*`
|
|
71
|
-
'If the capability needs UI, use the Block UI SDK: generated `manifest.ui` controls first; a sandboxed custom view (`view.html`/`view.css`/`view.js`) only when the core interaction
|
|
72
|
+
'Prefer `manifest.ui`; custom views use the Block kit (`sm-*`) unless a look is named. Pictures over words: preview each option before choosing; few words, fold the rest; buttons carry `data-stillmade-action`; the start button names the action (never "Run") and shows no price (StillMade adds it). A job fits one screen unscrolled and a 320px phone; an app (editor, configurator, CAD tool) works like that app: full canvas, panels, tools, undo, never a form.',
|
|
73
|
+
'If the capability needs UI, use the Block UI SDK: generated `manifest.ui` controls first; a sandboxed custom view (`view.html`/`view.css`/`view.js`) only when the core interaction needs one. Build custom views from the Block kit the host injects (`block-kit.css`): the result in `sm-stage`, picks as pictures in `sm-choices`, one `sm-primary` naming the action (Write, Generate; never "Run") and no price (StillMade\'s run button adds it), extras in a closed `sm-more`; a look the person names wins. Fit one laptop screen without scrolling: label beside control, status in a few words, files as tiles, rare tools as icons, (i) on unclear controls. These compact rules are for a job or an ingredient. Size the view to the Block\'s tier (block-tier.js): an app, a workspace a person works in for a while (video editor, 3D configurator, CAD or drawing tool), looks and works like that kind of app instead of a compact page: its canvas or stage fills the view, with the panels, toolbars, inspector or timeline such an app has, direct manipulation (select, drag, zoom), keyboard shortcuts and undo; it keeps the host appearance and Block kit controls, its panels may scroll, and on a phone its tools fold into sheets while the canvas stays usable. Never shrink an app into a form of fields and one button. Drop words nobody needs; put helpful ones in a closed `sm-hint`. Mark each button `data-stillmade-action="what it does"` and label each field: Chat presses and fills them. Expose the capability, not the implementation: sensible defaults, previews, progress, clear errors, visible outputs. Show no raw JSON, internal IDs, filesystem details, provider terms or developer settings unless the Block is a developer tool. When a person chooses among options they need to hear or see (voices, music, fonts, looks, templates, images), let them preview each option before choosing: a sample or thumbnail from the Block\'s own files or the host\'s free samples, never a paid generation. Skip previews only when there is nothing to preview. Choices StillMade owns (voices, models in the run review) already show samples. Follow the host appearance (Light, Dark and White) and one elastic layout: stacked on a 320px phone with no sideways scroll (result first, action pinned), and from 860px the controls left (≤380px), the result filling the right. Short settings sit side by side, each label beside its control (`sm-field`, `sm-settings`; three or four short options as one `data-segmented` row); many items are one line each (`sm-list`) that open in place; input files are small tiles (`sm-gallery data-size="small"`); on a phone chip rows scroll sideways and long lists show three. Compacting moves controls, never deletes them.'),
|
|
72
74
|
rule('multiplayer','block',
|
|
73
75
|
'Custom views share edits: `StillMade.onShared`, publish every editable field (`data-stillmade-share`, `bindShared`, `updateShared`), respect `canEdit`, never re-run on remote updates.',
|
|
74
76
|
'Projects are multiplayer. A custom view subscribes through `StillMade.onShared(callback)` and publishes every editable field: give static controls a unique `id` and `data-stillmade-share="field"` (the host owns their synchronization), use `StillMade.bindShared(field, elementId)` for controls created dynamically, and `StillMade.updateShared(values, before, options)` for structured values; private variables and DOM values are not shared. Mark a truly device-only control (playback, hover, device selection) with `data-stillmade-local`. Observe shared outputs as well as state, honor `canEdit` in every mutating callback (read-only participants cannot write), never run, click or call a paid integration in response to a remote update, and preserve conflicting drafts. Shared background work uses `sharedWork` with the `work.shared` permission.'),
|
|
@@ -85,7 +87,7 @@ const SECTIONS=Object.freeze([
|
|
|
85
87
|
'Validate inputs early, return only declared outputs, run long work as a job with monotonic progress, honor cancel and timeout, keep state in `manifest.state` with migrations.',
|
|
86
88
|
'A successful run means more than a returned value. Validate inputs before work; return only declared output ports with values matching their types; run long work as a cooperative job (`job.pending` with monotonic progress) instead of blocking; honor cancellation and the host timeout; keep persistent state in `manifest.state` (module Blocks: `stillmade.storage`) with migration routes so a reopened project and a repeated run behave the same; never leave orphaned jobs, temporary values or corrupted project state after cancellation or failure.'),
|
|
87
89
|
rule('errors','block',
|
|
88
|
-
'Fail usefully: throw an Error saying what failed, why, whether retrying helps and what to do; StillMade shows it
|
|
90
|
+
'Fail usefully: throw an Error saying what failed, why, whether retrying helps and what to do; StillMade shows it. Never "Something went wrong".',
|
|
89
91
|
'Errors must say what failed, why it likely failed, whether a retry makes sense and what the user can do, so the native project chat and controller can troubleshoot. In JavaScript, throw an `Error` with that message: the host reports it as a `JAVASCRIPT` run failure carrying your message (guest code cannot set its own error code). Reject invalid or oversized input up front with a message naming the input and the limit. Never reduce a failure to "Something went wrong".'),
|
|
90
92
|
rule('grounded-text','block',
|
|
91
93
|
'Write only supplied facts; drop or generalise a section whose fact is missing.',
|
|
@@ -157,5 +159,5 @@ export function renderBuilderContract(form='compact',{scope='all'}={}){
|
|
|
157
159
|
`## Platform gap classes\n\n${Object.entries(GAP_CLASSES).map(([key,label])=>`- ${key}: ${label}`).join('\n')}`,
|
|
158
160
|
].join('\n\n');
|
|
159
161
|
if(form==='full')return `# StillMade Block builder contract\n\n${body}`;
|
|
160
|
-
return `# Building StillMade Blocks in this folder\n\nThis folder is the StillMade Block SDK. Read this file before writing code; it is the same contract StillMade's own AI and importers follow. Commands below use the downloaded SDK folder form \`node packages/block-cli/cli.js\`; when the SDK came from \`npm install --save-dev stillmade-sdk\`, type \`npx stillmade-block\` in its place. Scaffold with \`node packages/block-cli/cli.js create my-block javascript\`, then iterate with \`validate my-block\` and \`test my-block\`, check
|
|
162
|
+
return `# Building StillMade Blocks in this folder\n\nThis folder is the StillMade Block SDK. Read this file before writing code; it is the same contract StillMade's own AI and importers follow. Commands below use the downloaded SDK folder form \`node packages/block-cli/cli.js\`; when the SDK came from \`npm install --save-dev stillmade-sdk\`, type \`npx stillmade-block\` in its place. Scaffold with \`node packages/block-cli/cli.js create my-block javascript\`, then iterate with \`validate my-block\` and \`test my-block\`, check it with \`gate my-block\`, the same readiness check StillMade runs before an install or a release, and write the file to import in StillMade with \`pack my-block\`: \`<id>-<version>.stillmade-module.json\` for a module Block, \`<id>-<version>.stillmade.json\` for the other runtimes. \`SDK.md\` is the complete reference and \`PASTE_TO_LLM.md\` the detailed authoring guide.\n\n## Readiness gate\n\nDo not tell the user a Block is ready, done or working until \`node packages/block-cli/cli.js gate .\` (\`gate <block-folder>\` for a Block outside this SDK folder) passes after your last edit, and report the exact status it prints. In this folder Claude Code (\`.claude/settings.json\`) and Cursor (\`.cursor/hooks.json\`) run the gate when you finish a turn and send any failures back to you, but only in a session started in this folder: if you downloaded the SDK during the current session, run the gate yourself. Other agents must also run it themselves. A module Block runs code, so the gate a hook starts does not run it and says so: run \`gate\` or \`ready\` on it yourself. If you cannot fix a failure, say plainly that the Block is not ready and list the failing checks.\n\n${body}`;
|
|
161
163
|
}
|
|
@@ -11,6 +11,9 @@ export const SPEECH_CAPABILITY_LIMITS=Object.freeze({text:3800,duration:1400,byt
|
|
|
11
11
|
// The host reads the page or searches; the Block only names what to read and why.
|
|
12
12
|
// media.analyze: up to four images, or one video with one closer-inspected range of at most 12 seconds.
|
|
13
13
|
export const MEDIA_ANALYZE_LIMITS=Object.freeze({images:4,question:2000,range:12});
|
|
14
|
+
// October 30, 2026: detail:"check" is a photo check (owner's option C): images only, at most four a call, each read
|
|
15
|
+
// smaller and answered in a short summary, priced per call rather than per image (server tiers.js 'media:check').
|
|
16
|
+
const MEDIA_ANALYZE_DETAILS=Object.freeze(['check']);
|
|
14
17
|
// text.generate history: earlier turns sent before the prompt.
|
|
15
18
|
export const TEXT_HISTORY_LIMITS=Object.freeze({turns:20,turn:8000,total:24000});
|
|
16
19
|
export const WEB_CAPABILITY_LIMITS=Object.freeze({url:2048,instruction:4000,topic:500,sources:12,answer:32768});
|
|
@@ -21,15 +24,20 @@ export const VIDEO_CAPABILITY_LIMITS=Object.freeze({prompt:32000,bytes:268435456
|
|
|
21
24
|
// width/height/bytes are the legacy fixed OpenAI 1024 square; catalog models
|
|
22
25
|
// return any measured size up to maxSide/maxPixels/maxBytes.
|
|
23
26
|
export const IMAGE_CAPABILITY_LIMITS=Object.freeze({prompt:32000,bytes:12000000,width:1024,height:1024,maxSide:8192,maxPixels:40000000,maxBytes:67108864});
|
|
27
|
+
// audio.transcribe results are checked word by word against the documented
|
|
28
|
+
// 100,000 words and one million characters, so their result budget fits that
|
|
29
|
+
// transcript: four JSON nodes a word plus the envelope and 8 KB of metadata.
|
|
30
|
+
const TRANSCRIPT_RESULT_LIMITS=Object.freeze({bytes:16777216,nodes:100000*4+8192});
|
|
24
31
|
const own=(value,key)=>Object.hasOwn(value,key);
|
|
25
32
|
const only=(value,keys)=>plain(value)&&Object.keys(value).every(key=>keys.includes(key));
|
|
26
33
|
const exact=(value,keys)=>only(value,keys)&&keys.every(key=>own(value,key));
|
|
27
34
|
const forbidden=new Set(['__proto__','prototype','constructor']);
|
|
35
|
+
const resultBudget=operation=>operation==='audio.transcribe'?[TRANSCRIPT_RESULT_LIMITS.bytes,TRANSCRIPT_RESULT_LIMITS.nodes]:[operation==='connection.execute'?2097152:CAPABILITY_LIMITS.packageBytes];
|
|
28
36
|
|
|
29
|
-
function inert(value,bytes=CAPABILITY_LIMITS.packageBytes){
|
|
37
|
+
function inert(value,bytes=CAPABILITY_LIMITS.packageBytes,nodes=50000){
|
|
30
38
|
let count=0;
|
|
31
39
|
const visit=(item,depth=0)=>{
|
|
32
|
-
assert(depth<=32&&++count<=
|
|
40
|
+
assert(depth<=32&&++count<=nodes,'LIMIT','Capability document exceeds its complexity limit');
|
|
33
41
|
if(item===null||['string','boolean'].includes(typeof item))return;
|
|
34
42
|
if(typeof item==='number'){assert(Number.isFinite(item),'CAPABILITY_SCHEMA','Capability values must be finite');return;}
|
|
35
43
|
assert(plain(item)||Array.isArray(item),'CAPABILITY_SCHEMA','Capabilities accept inert JSON data only');
|
|
@@ -194,7 +202,8 @@ export function validateCapability(capability,manifest){
|
|
|
194
202
|
return true;
|
|
195
203
|
}
|
|
196
204
|
if(operation==='media.analyze'){
|
|
197
|
-
assert(only(capability,['schemaVersion','operation','media','question','range','output'])&&['schemaVersion','operation','media','question','output'].every(key=>own(capability,key))&&capability.schemaVersion===1&&capability.operation===operation,'CAPABILITY_SCHEMA','Declare schema 1 media.analyze with media, question and output, and optionally a range {start,end} of number inputs for a video. Payment belongs to the host.');
|
|
205
|
+
assert(only(capability,['schemaVersion','operation','media','question','range','detail','output'])&&['schemaVersion','operation','media','question','output'].every(key=>own(capability,key))&&capability.schemaVersion===1&&capability.operation===operation,'CAPABILITY_SCHEMA','Declare schema 1 media.analyze with media, question and output, and optionally a range {start,end} of number inputs for a video. Payment belongs to the host.');
|
|
206
|
+
if(own(capability,'detail'))assert(MEDIA_ANALYZE_DETAILS.includes(capability.detail)&&['image','image[]'].includes(manifest.inputs[capability.media?.$input]?.type)&&!own(capability,'range'),'CAPABILITY_SCHEMA','detail:"check" is a short photo check: it takes one image or image[] input (never a video) and no range.');
|
|
198
207
|
assert(exact(capability.media,['$input'])&&['image','image[]','video'].includes(manifest.inputs[capability.media.$input]?.type),'CAPABILITY_BINDING','media must reference the declared video, image or image[] input');
|
|
199
208
|
assert(typeof capability.question==='string'&&capability.question.trim().length>0&&capability.question.length<=MEDIA_ANALYZE_LIMITS.question,'CAPABILITY_LIMIT','The analysis question must contain 1 to 2,000 characters');
|
|
200
209
|
const numbers=Object.keys(manifest.inputs).filter(name=>manifest.inputs[name].type==='number');
|
|
@@ -318,7 +327,7 @@ export function prepareCapabilityInvocation(pkg,input){
|
|
|
318
327
|
const start=values[pkg.capability.range.start.$input],end=values[pkg.capability.range.end.$input];
|
|
319
328
|
if(start!==undefined||end!==undefined){assert(Number.isFinite(start)&&Number.isFinite(end)&&start>=0&&end>start&&end-start<=MEDIA_ANALYZE_LIMITS.range,'CAPABILITY_INPUT','A video range needs a start and a later end, at most 12 seconds apart');range={range:{start,end}};}
|
|
320
329
|
}
|
|
321
|
-
return {operation:'media.analyze',media,question:pkg.capability.question,...range};
|
|
330
|
+
return {operation:'media.analyze',media,question:pkg.capability.question,...range,...(own(pkg.capability,'detail')?{detail:pkg.capability.detail}:{})};
|
|
322
331
|
}
|
|
323
332
|
if(pkg.capability.operation==='web.fetch'){
|
|
324
333
|
const values=validateValues(pkg.manifest.inputs,input),url=values[pkg.capability.url.$input];
|
|
@@ -390,12 +399,16 @@ function conversationHistory(value){
|
|
|
390
399
|
function generationExtras(capability){
|
|
391
400
|
return {...(capability.negativePrompt?{negativePrompt:capability.negativePrompt}:{}),...(own(capability,'seed')?{seed:capability.seed}:{})};
|
|
392
401
|
}
|
|
402
|
+
function transcriptShape(transcript){
|
|
403
|
+
const words=transcript?.words;
|
|
404
|
+
assert(plain(transcript)&&exact(transcript,['schemaVersion','text','words'])&&transcript.schemaVersion===1&&typeof transcript.text==='string'&&transcript.text.length<=1000000&&Array.isArray(words)&&words.length<=100000,'CAPABILITY_OUTPUT','Transcription must return schema 1 text and timed words only');
|
|
405
|
+
assert(words.every(word=>plain(word)&&exact(word,['text','start','end'])&&typeof word.text==='string'&&word.text.trim()&&word.text.length<=5000&&Number.isFinite(word.start)&&Number.isFinite(word.end)&&word.start>=0&&word.end>=word.start&&word.end<=86400),'CAPABILITY_OUTPUT','Transcript words require bounded text and ordered second timestamps');
|
|
406
|
+
return transcript;
|
|
407
|
+
}
|
|
393
408
|
export function capabilityOutputs(pkg,text){
|
|
394
409
|
packageContract(pkg);
|
|
395
410
|
if(pkg.capability.operation==='audio.transcribe'){
|
|
396
|
-
const transcript=structuredClone(text)
|
|
397
|
-
assert(plain(transcript)&&exact(transcript,['schemaVersion','text','words'])&&transcript.schemaVersion===1&&typeof transcript.text==='string'&&transcript.text.length<=1000000&&Array.isArray(words)&&words.length<=100000,'CAPABILITY_OUTPUT','Transcription must return schema 1 text and timed words only');
|
|
398
|
-
assert(words.every(word=>plain(word)&&exact(word,['text','start','end'])&&typeof word.text==='string'&&word.text.trim()&&word.text.length<=5000&&Number.isFinite(word.start)&&Number.isFinite(word.end)&&word.start>=0&&word.end>=word.start&&word.end<=86400),'CAPABILITY_OUTPUT','Transcript words require bounded text and ordered second timestamps');
|
|
411
|
+
const transcript=transcriptShape(structuredClone(text));
|
|
399
412
|
const outputs={[pkg.capability.output]:transcript};validateValues(pkg.manifest.outputs,outputs,'output');return outputs;
|
|
400
413
|
}
|
|
401
414
|
if(['timeline.propose','workspace.propose'].includes(pkg.capability.operation)){
|
|
@@ -621,11 +634,11 @@ export function validateCapabilityExpectations(manifest,expectations){
|
|
|
621
634
|
|
|
622
635
|
export function testCapabilityOutputs(manifest,outputs,expectations){
|
|
623
636
|
if(manifest.permissions?.capabilities?.[0]==='connection.execute')return testConnectedOutputs(manifest,outputs,expectations);
|
|
624
|
-
validateCapabilityExpectations(manifest,expectations);inert(outputs);
|
|
637
|
+
validateCapabilityExpectations(manifest,expectations);inert(outputs,...resultBudget(manifest.permissions.capabilities[0]));
|
|
625
638
|
const values=validateValues(manifest.outputs,outputs,'output'),name=Object.keys(manifest.outputs)[0];
|
|
626
639
|
assert(own(values,name)&&own(outputs,name),'CAPABILITY_OUTPUT','The host must return the declared generated output');
|
|
627
640
|
if(manifest.permissions.capabilities[0]==='audio.transcribe'){
|
|
628
|
-
const transcript=values[name],expected=expectations[name];
|
|
641
|
+
const transcript=transcriptShape(values[name]),expected=expectations[name];
|
|
629
642
|
assert(transcript.words.length>=expected.minWords&&transcript.words.length<=expected.maxWords,'CAPABILITY_EXPECTATION',`${name} did not match the expected transcript word count`);
|
|
630
643
|
return {passed:true,checks:[{output:name,kind:'transcript',words:transcript.words.length}]};
|
|
631
644
|
}
|
|
@@ -680,8 +693,8 @@ export function testCapabilityOutputs(manifest,outputs,expectations){
|
|
|
680
693
|
// Called only after an explicitly injected trusted host executor. Metadata is
|
|
681
694
|
// bounded data; it is not a signed receipt or proof of provider execution.
|
|
682
695
|
export function validateCapabilityResult(manifest,result){
|
|
683
|
-
inert(result,manifestContract(manifest)==='connection.execute'?2097152:CAPABILITY_LIMITS.packageBytes);
|
|
684
696
|
const operation=manifestContract(manifest),media=['audio.speech','image.generate','video.generate',...AUDIO_OPERATIONS].includes(operation);
|
|
697
|
+
inert(result,...resultBudget(operation));
|
|
685
698
|
assert(only(result,['outputs','metadata',...(media?['mediaReceipts']:[])])&&own(result,'outputs'),'CAPABILITY_OUTPUT','The host must return declared outputs and optional host metadata or media receipts');
|
|
686
699
|
const expectations=defaultCapabilityExpectations(manifest);
|
|
687
700
|
testCapabilityOutputs(manifest,result.outputs,expectations);
|
|
@@ -49,6 +49,9 @@ export function reviewCollaborationContract(view){
|
|
|
49
49
|
}
|
|
50
50
|
const hostBindings=annotated.bindings.length>0,receives=hostBindings||calls.has('onShared')||calls.has('connectDocument'),publishes=hostBindings||calls.has('updateShared')||calls.has('bindShared')||calls.has('connectDocument');
|
|
51
51
|
const missing=[];
|
|
52
|
+
// November 2026: a React view not built yet (no src/view.js) failed with the share message below, which sent
|
|
53
|
+
// outside agents to rewrite controls instead of running the build.
|
|
54
|
+
if(!receives&&frame&&!(view.javascript||'').trim())return {...base,passed:false,code:'COLLABORATION_CONTRACT',message:'The interface has no script: src/view.js is missing or empty. If it is written in React or another framework (src/view.jsx), build it first (npm run build) so the bundle is src/view.js. A view with no script must mark its controls with data-stillmade-share.'};
|
|
52
55
|
if(!receives)missing.push('mark ordinary controls with data-stillmade-share, receive teammate state/results with StillMade.onShared, or connect a content store with StillMade.connectDocument');
|
|
53
56
|
if(interactive&&!publishes)missing.push('publish editable controls through data-stillmade-share, StillMade.connectDocument, StillMade.bindShared or StillMade.updateShared');
|
|
54
57
|
const uncovered=annotated.controls.filter(control=>!control.shared&&!control.local&&!(typeof control.id==='string'&&boundIds.has(control.id)));
|
|
@@ -59,7 +59,7 @@ export const HOSTED_CAPABILITY_OPERATIONS = Object.freeze({
|
|
|
59
59
|
'image.generate': hostedOperation('capability-run', 'sync', true, 'One prompt and optional reference images to a stored PNG with any catalog image model and its settings; StillMade credits or account key, and actual image review'),
|
|
60
60
|
'video.generate': hostedOperation('capability-run', 'async', true, 'One prompt and optional first/last frames to a measured stored video clip with any catalog video model and its settings; StillMade credits and actual video review'),
|
|
61
61
|
'image.describe': hostedOperation('capability-run', 'sync', true, 'One to four owned images to bounded production notes; host-selected model/payment and actual description review'),
|
|
62
|
-
'media.analyze': hostedOperation('capability-run', 'sync', false, 'One owned video (optionally one time range), image or up to four images to structured visual evidence: summary, timeline, observations, on-screen text and issues, with StillMade credits'),
|
|
62
|
+
'media.analyze': hostedOperation('capability-run', 'sync', false, 'One owned video (optionally one time range), image or up to four images to structured visual evidence: summary, timeline, observations, on-screen text and issues, with StillMade credits; detail "check" gives one to four photos a short answer each at one price per call'),
|
|
63
63
|
'web.fetch': hostedOperation('capability-run', 'sync', false, 'One public web page read by the host and answered against the Block\'s instruction, with StillMade credits; no cookies, keys or private addresses'),
|
|
64
64
|
'web.research': hostedOperation('capability-run', 'sync', false, 'One topic searched on the web and summarized with cited sources, with StillMade credits'),
|
|
65
65
|
'timeline.propose': hostedOperation('capability-run', 'sync', true, 'Bounded typed context to strict timeline-edit JSON; host-selected model/payment, stale-before checks, preview and Apply'),
|
|
@@ -85,6 +85,18 @@ export function safeDocument(value, depth = 0, budget = { nodes: 0 }) {
|
|
|
85
85
|
safeDocument(child, depth + 1, budget);
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
+
// StillMade keeps packages in Postgres jsonb, which refuses the NUL character (\u0000): a package that carried one
|
|
89
|
+
// imported, then failed every project save (November 2026: a test photo mocked as text). Checked where a package
|
|
90
|
+
// enters StillMade (import review, the CLI's validate and pack), never in hashing or comparison, so content saved
|
|
91
|
+
// before this check still opens. Names where the NUL is, for example tests.1.mocks.files.tiny-1.text.
|
|
92
|
+
export function assertStorableText(value, path = [], depth = 0) {
|
|
93
|
+
if (typeof value === 'string') { assert(!value.includes('\u0000'), 'SCHEMA', `Text at ${path.join('.') || 'the top level'} cannot contain the NUL character (\\u0000), which StillMade cannot store.`); return; }
|
|
94
|
+
if (depth > 40 || !value || typeof value !== 'object') return;
|
|
95
|
+
for (const [key, child] of Object.entries(value)) {
|
|
96
|
+
assert(!key.includes('\u0000'), 'SCHEMA', `A field name in ${path.join('.') || 'the top level'} contains the NUL character (\\u0000), which StillMade cannot store.`);
|
|
97
|
+
assertStorableText(child, [...path, key], depth + 1);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
88
100
|
export function stableStringify(value) {
|
|
89
101
|
safeDocument(value);
|
|
90
102
|
const visit = v => Array.isArray(v) ? v.map(visit) : plain(v) ? Object.fromEntries(Object.keys(v).sort().map(k => [k, visit(v[k])])) : v;
|