@audienti/cli 0.1.145 → 0.2.0-exora.10
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/README.md +47 -895
- package/bin/audienti-mcp.mjs +2 -0
- package/cli/account-intake-operations.mjs +28 -0
- package/cli/account-intake.mjs +33 -0
- package/cli/account-replies.mjs +35 -0
- package/cli/account-reply-operations.mjs +55 -0
- package/cli/agents.mjs +160 -0
- package/cli/assignment-operations.mjs +218 -0
- package/cli/assignment.mjs +116 -0
- package/cli/attachment-operations.mjs +9 -0
- package/cli/attachments.mjs +28 -0
- package/cli/audience-operations.mjs +21 -0
- package/cli/audiences.mjs +29 -0
- package/cli/audienti.mjs +217 -0
- package/cli/audit-operations.mjs +17 -0
- package/cli/audit.mjs +39 -0
- package/cli/budget-operations.mjs +8 -0
- package/cli/budgets.mjs +34 -0
- package/cli/campaign-find.mjs +155 -0
- package/cli/campaign-operations.mjs +30 -0
- package/cli/campaigns.mjs +61 -0
- package/cli/cohort-operations.mjs +22 -0
- package/cli/cohorts.mjs +45 -0
- package/cli/connection-registry-operations.mjs +8 -0
- package/cli/content.mjs +29 -0
- package/cli/discovery-operations.mjs +28 -0
- package/cli/discovery.mjs +15 -0
- package/cli/file-operations.mjs +29 -0
- package/cli/files.mjs +14 -0
- package/cli/guardrails-operations.mjs +12 -0
- package/cli/guardrails.mjs +37 -0
- package/cli/json-file.mjs +13 -0
- package/cli/kv-operations.mjs +17 -0
- package/cli/kv.mjs +58 -0
- package/cli/mcp.mjs +47 -0
- package/cli/notification-operations.mjs +8 -0
- package/cli/offer-operations.mjs +12 -0
- package/cli/offers.mjs +24 -0
- package/cli/outbound-operations.mjs +16 -0
- package/cli/outbound.mjs +54 -0
- package/cli/outcome-operations.mjs +26 -0
- package/cli/outcomes.mjs +16 -0
- package/cli/owner-account-client.mjs +18 -0
- package/cli/playbook-help.mjs +79 -0
- package/cli/playbook-operations.mjs +36 -0
- package/cli/profiles.mjs +29 -0
- package/cli/progress.mjs +26 -0
- package/cli/prospecting-settings-operations.mjs +16 -0
- package/cli/prospecting-settings.mjs +15 -0
- package/cli/question-operations.mjs +12 -0
- package/cli/questions.mjs +32 -0
- package/cli/queue-operations.mjs +12 -0
- package/cli/queue.mjs +25 -0
- package/cli/recipient-operations.mjs +13 -0
- package/cli/recipients.mjs +34 -0
- package/cli/rooms-operations.mjs +48 -0
- package/cli/rooms.mjs +160 -0
- package/cli/search-operations.mjs +9 -0
- package/cli/secret-operations.mjs +7 -0
- package/cli/secrets.mjs +30 -0
- package/cli/sender-account-operations.mjs +28 -0
- package/cli/sender-accounts.mjs +33 -0
- package/cli/skills-mcp.mjs +31 -0
- package/cli/skills.mjs +113 -0
- package/cli/suppression-operations.mjs +8 -0
- package/cli/suppression.mjs +12 -0
- package/cli/tools-operations.mjs +78 -0
- package/cli/tools.mjs +100 -0
- package/cli/usage-operations.mjs +8 -0
- package/cli/usage.mjs +41 -0
- package/cli/work-execution-operations.mjs +42 -0
- package/cli/work-execution.mjs +29 -0
- package/cli/work-feedback-operations.mjs +17 -0
- package/cli/work-feedback.mjs +18 -0
- package/cli/work-operations.mjs +30 -0
- package/cli/work.mjs +32 -0
- package/cli/workspaces.mjs +255 -0
- package/package.json +15 -26
- package/skills/agent-rooms/SKILL.md +140 -0
- package/skills/agent-rooms/references/commands.md +78 -0
- package/skills/audienti/SKILL.md +161 -317
- package/skills/audienti/references/helpers.md +162 -0
- package/skills/audienti/references/profile-workflows.md +214 -0
- package/skills/audienti/references/workflow-receipt.md +49 -0
- package/skills/audienti/scripts/contracts.mjs +72 -0
- package/skills/audienti/scripts/profile-helpers.mjs +138 -0
- package/skills/audienti/scripts/run.mjs +40 -0
- package/skills/getting-started/SKILL.md +227 -0
- package/skills/getting-started/references/audience.md +143 -0
- package/skills/getting-started/references/campaign-draft.md +80 -0
- package/skills/getting-started/references/capabilities.md +237 -0
- package/skills/getting-started/references/mailbox-check.md +64 -0
- package/skills/getting-started/references/outcome-checks.md +114 -0
- package/skills/getting-started/references/replies.md +186 -0
- package/skills/getting-started/references/reply-checks.md +54 -0
- package/skills/playbook-design/SKILL.md +136 -0
- package/skills/playbook-design/references/fields.md +130 -0
- package/source-manifest.json +104 -0
- package/.mcp.json +0 -13
- package/CHANGELOG.md +0 -1018
- package/bin/audienti-mcp.js +0 -5
- package/bin/audienti.js +0 -6
- package/install +0 -83
- package/skills/audienti/references/methodology.md +0 -111
- package/skills/audienti-gift-research/SKILL.md +0 -47
- package/src/agent-skills.js +0 -60
- package/src/api-client.js +0 -1639
- package/src/cli.js +0 -16992
- package/src/config.js +0 -130
- package/src/mcp.js +0 -103
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import {HostedResponseUnknown} from './workspaces.mjs';
|
|
3
|
+
// #257: find N prospects for a Campaign. Searches LinkedIn with the Audience filters, then asks the server
|
|
4
|
+
// to check each new person. The server fetches, runs the rules and records the result; this file only
|
|
5
|
+
// drives the loop and adds up the spend. Amounts are pico USD (1 credit = 10,000,000,000).
|
|
6
|
+
export const PICO_PER_CREDIT=10_000_000_000n;
|
|
7
|
+
// Waits before each retry of a check whose outcome is unknown, in milliseconds.
|
|
8
|
+
const RECOVERY_WAITS=[2000,5000,15000];
|
|
9
|
+
const OUTCOMES=['fit','held','not_fit','already_found'];
|
|
10
|
+
// Only a definite answer lets the run go on. A check that is still open, a lost response or a server
|
|
11
|
+
// error may still be paying, so the same request ID is retried; anything else unexpected stops the run.
|
|
12
|
+
const pending=error=>error instanceof HostedResponseUnknown||error?.code==='check_running'||error?.status>=500&&!['tool_unavailable','history_policy_unavailable'].includes(error?.code);
|
|
13
|
+
// A refusal about this one person (a bad URL, no LinkedIn profile) is final, and the run moves on.
|
|
14
|
+
const skippable=error=>[400,404,422].includes(error?.status);
|
|
15
|
+
const MAX_SEARCHES=12,MAX_PAGES=10;
|
|
16
|
+
const positive=list=>(list??[]).filter(item=>!item.negative);
|
|
17
|
+
const numeric=value=>typeof value==='string'&&/^\d{1,20}$/.test(value);
|
|
18
|
+
// A charge the server reported for one request. Charges are cumulative per request ID, so the largest one
|
|
19
|
+
// seen across attempts is the request's charge so far.
|
|
20
|
+
const reportedCharge=value=>numeric(value)?BigInt(value):0n;
|
|
21
|
+
|
|
22
|
+
/** One profiles.search input per title, location and keyword combination the Audience and Campaign name. */
|
|
23
|
+
export function planSearches(audience,signals=[]){
|
|
24
|
+
const titles=positive(audience.person?.currentTitles).map(item=>item.name).filter(Boolean).slice(0,5);
|
|
25
|
+
// #281: the people search has no company region filter, so with no person location the company's region is
|
|
26
|
+
// searched as where the person is. Without it the search pays to check people anywhere in the world.
|
|
27
|
+
const places=positive(audience.person?.locations).length?positive(audience.person?.locations):positive(audience.company?.locations);
|
|
28
|
+
const locations=places.slice(0,3).map(item=>numeric(item.providerId)?{geoId:item.providerId}:{location:item.name});
|
|
29
|
+
// #322: the search takes 20 industry ids at a time, so a longer list is split across searches, never cut.
|
|
30
|
+
const industryIds=[...new Set(positive(audience.company?.industries).map(item=>item.providerId).filter(numeric))];
|
|
31
|
+
const industryChunks=[];for(let i=0;i<industryIds.length;i+=20)industryChunks.push(industryIds.slice(i,i+20));
|
|
32
|
+
// A topic signal is a keyword people use; research and profile signals are judged after the fetch, not searched.
|
|
33
|
+
const queries=[...new Set([audience.discoveryKeyword,...signals.filter(signal=>signal.kind==='topic').map(signal=>signal.topic)].map(value=>value?.trim()).filter(Boolean))].slice(0,3);
|
|
34
|
+
// At most MAX_SEARCHES title, place and keyword combinations. Each one is searched across every industry chunk, so
|
|
35
|
+
// a long industry list never pushes a title out of the plan. The credit cap still bounds what the searches cost.
|
|
36
|
+
const combos=[];
|
|
37
|
+
for(const title of titles.length?titles:[undefined])for(const place of locations.length?locations:[{}])for(const query of queries.length?queries:[undefined])combos.push({kind:'person',...(title?{title}:{}),...place,...(query?{query}:{})});
|
|
38
|
+
const searches=[];
|
|
39
|
+
for(const combo of combos.slice(0,MAX_SEARCHES))for(const ids of industryChunks.length?industryChunks:[undefined]){
|
|
40
|
+
const input={...combo,...(ids?{industryIds:ids}:{})};
|
|
41
|
+
if(input.title||input.query||input.geoId||input.location||input.industryIds)searches.push(input);
|
|
42
|
+
}
|
|
43
|
+
return searches;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseCredits(value){
|
|
47
|
+
if(typeof value!=='string'||!/^\d{1,7}(\.\d{1,4})?$/.test(value))throw new Error('Set --max-credits to a number of credits, like 50 or 12.5.');
|
|
48
|
+
const [whole,fraction='']=value.split('.');
|
|
49
|
+
return BigInt(whole)*PICO_PER_CREDIT+BigInt(fraction.padEnd(4,'0'))*(PICO_PER_CREDIT/10_000n);
|
|
50
|
+
}
|
|
51
|
+
export const credits=pico=>{const whole=pico/PICO_PER_CREDIT,rest=(pico%PICO_PER_CREDIT)*10_000n/PICO_PER_CREDIT;return rest?`${whole}.${rest.toString().padStart(4,'0').replace(/0+$/,'')}`:`${whole}`;};
|
|
52
|
+
const charged=data=>data?.billing?.status==='charged'&&/^\d+$/.test(data.billing.amount??'')?BigInt(data.billing.amount):0n;
|
|
53
|
+
// A search is final once its outcome is known and its charge has settled.
|
|
54
|
+
const searchSettled=data=>['succeeded','failed','rejected'].includes(data?.status)&&(data.billing?.status==='not_charged'||charged(data)>0n||data.billing?.status==='charged'&&data.billing.amount==='0');
|
|
55
|
+
const personUrl=url=>typeof url==='string'&&/^https:\/\/([a-z]{2,3}\.)?(www\.)?linkedin\.com\/in\//i.test(url);
|
|
56
|
+
|
|
57
|
+
async function settled(call,workspaceId,data,wait){
|
|
58
|
+
for(let tries=0;data?.operationId&&!searchSettled(data)&&data.status!=='uncertain'&&tries<30;tries++){await wait(2000);data=(await call('tools_runs_get',{workspaceId,operationId:data.operationId})).data;}
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Searches and checks until `count` new fits are found, the searches run out, or the next paid call could pass
|
|
64
|
+
* `maxCredits`. A search runs only when its published price fits what is left. Each check sends what is left as
|
|
65
|
+
* `maxCharge`, and the server refuses a check whose worst case (both profile fetches paid) could pass it.
|
|
66
|
+
*/
|
|
67
|
+
export async function findCampaignProspects(call,{workspaceId,campaignId,count,maxCredits},{wait=ms=>new Promise(resolve=>setTimeout(resolve,ms)),progress=()=>{}}={}){
|
|
68
|
+
const {data:campaign}=await call('campaign_get',{workspaceId,campaignId});
|
|
69
|
+
const fields=campaign.revision.fields;
|
|
70
|
+
if(!['Draft','Preparing','Active'].includes(campaign.campaign.state))throw new Error('This campaign is not finding prospects. Pick a Draft, Preparing or Active campaign.');
|
|
71
|
+
const {data:audience}=await call('audience_get',{workspaceId,audienceId:fields.audienceId,revision:fields.audienceRevision});
|
|
72
|
+
// #322: the search cannot apply an older audience's industry group, so it would pay to check people in any
|
|
73
|
+
// industry. Refuse before any paid call; named industries are searched.
|
|
74
|
+
const company=audience.revision.fields.company;
|
|
75
|
+
if(company?.industryGroups?.length&&!positive(company.industries).length)throw new Error("This campaign's audience uses an older industry group, which the LinkedIn search cannot apply. Name the industries in the audience instead (audienti audience options --kind industries --q TEXT), then try again.");
|
|
76
|
+
const searches=planSearches(audience.revision.fields,fields.signals);
|
|
77
|
+
// #323: rules that will reject people (a named title below the seniority floor) are shown before the first paid call.
|
|
78
|
+
const warnings=audience.revision.warnings??[];
|
|
79
|
+
for(const warning of warnings)progress({warning});
|
|
80
|
+
if(!searches.length)throw new Error("This campaign's audience has no title, location, industry or keyword to search by. Add one, then try again.");
|
|
81
|
+
const {data:tool}=await call('tools_get',{workspaceId,tool:'profiles.search'});
|
|
82
|
+
if(!/^\d{1,20}$/.test(tool?.price?.picoUsd??''))throw new Error('The LinkedIn search has no fixed price right now, so the credit cap cannot be kept. Try again later.');
|
|
83
|
+
const searchPrice=BigInt(tool.price.picoUsd);
|
|
84
|
+
const report={campaignId,state:campaign.campaign.state,target:count,maxCredits:credits(maxCredits),spentCredits:'0',spent:'0',searches:0,checked:0,fit:0,held:0,notFit:0,alreadyFound:0,failed:0,stoppedBecause:'searches_exhausted',fits:[],heldPeople:[],...(warnings.length?{audienceWarnings:warnings}:{})};
|
|
85
|
+
let spent=0n;const seen=new Set();
|
|
86
|
+
const finish=reason=>{report.stoppedBecause=reason;report.spent=spent.toString();report.spentCredits=credits(spent);return report;};
|
|
87
|
+
// The check may have paid more than it reported; the rest is recovered with the same request ID.
|
|
88
|
+
const unknown=(url,requestId,error)=>{report.unknownCheck={url,requestId,code:error?.code??'response_unknown'};return finish('outcome_unknown');};
|
|
89
|
+
for(const input of searches){
|
|
90
|
+
for(let page=1;page&&page<=MAX_PAGES;){
|
|
91
|
+
if(report.fit>=count)return finish('count_reached');
|
|
92
|
+
if(spent+searchPrice>maxCredits)return finish('credit_cap');
|
|
93
|
+
// Search results are not saved as People; only a person the check fetches joins People.
|
|
94
|
+
// Nothing else pays until this search has settled: a search still running, uncertain or with a charge not
|
|
95
|
+
// yet final is retried with the same request ID, and if it never settles the run stops and names it.
|
|
96
|
+
const search={workspaceId,requestId:randomUUID(),input:{...input,...(page>1?{page}:{}),saveProfiles:false}};
|
|
97
|
+
let data=null,failure=null,paid=0n;
|
|
98
|
+
for(let attempt=0;;attempt++){
|
|
99
|
+
try{data=await settled(call,workspaceId,(await call('tools_profiles_search',search)).data,wait);failure=null;}
|
|
100
|
+
catch(error){if(!pending(error))throw error;failure=error;}
|
|
101
|
+
if(!failure&&charged(data)>paid)paid=charged(data);
|
|
102
|
+
if(!failure&&searchSettled(data))break;
|
|
103
|
+
if(attempt>=RECOVERY_WAITS.length){
|
|
104
|
+
spent+=paid;report.searches++;
|
|
105
|
+
report.unknownSearch={requestId:search.requestId,operationId:data?.operationId??null,code:failure?.code??data?.error?.code??data?.status??'response_unknown'};
|
|
106
|
+
return finish('outcome_unknown');
|
|
107
|
+
}
|
|
108
|
+
await wait(RECOVERY_WAITS[attempt]);
|
|
109
|
+
}
|
|
110
|
+
spent+=paid;report.searches++;
|
|
111
|
+
if(data?.status!=='succeeded'){if(data?.error?.code==='insufficient_credits')return finish('no_credits');break;}
|
|
112
|
+
const items=(data.result?.items??[]).filter(item=>personUrl(item.url)&&!seen.has(item.url));
|
|
113
|
+
for(const item of items){
|
|
114
|
+
seen.add(item.url);
|
|
115
|
+
if(report.fit>=count)return finish('count_reached');
|
|
116
|
+
if(spent>=maxCredits)return finish('credit_cap');
|
|
117
|
+
// Nothing else pays until this check has a definite answer. The same request ID replays the stored
|
|
118
|
+
// outcome and charge; if no definite answer comes, the run stops and names the check to recover.
|
|
119
|
+
const args={workspaceId,campaignId,url:item.url,requestId:randomUUID(),maxCharge:(maxCredits-spent).toString()};
|
|
120
|
+
// Every attempt's reported charge is kept, and the largest is counted once on every way out.
|
|
121
|
+
let result,failure,paid=0n;
|
|
122
|
+
for(let attempt=0;;attempt++){
|
|
123
|
+
try{result=(await call('campaign_prospect_check',args)).data;failure=null;}
|
|
124
|
+
catch(error){result=null;failure=error;}
|
|
125
|
+
const reported=reportedCharge(failure?failure.charged:result?.charged);
|
|
126
|
+
if(reported>paid)paid=reported;
|
|
127
|
+
if(!failure&&!OUTCOMES.includes(result?.outcome)){failure={code:'invalid_response'};break;}
|
|
128
|
+
if(!failure||!pending(failure)||attempt>=RECOVERY_WAITS.length)break;
|
|
129
|
+
await wait(RECOVERY_WAITS[attempt]);
|
|
130
|
+
}
|
|
131
|
+
spent+=paid;
|
|
132
|
+
if(failure){
|
|
133
|
+
const error=failure;
|
|
134
|
+
if(pending(error)||error.code==='invalid_response')return unknown(item.url,args.requestId,error);
|
|
135
|
+
if(error.code==='check_over_budget')return finish('credit_cap');
|
|
136
|
+
if(error.status===402)return finish('no_credits');
|
|
137
|
+
if(error.code==='campaign_not_active')return finish('campaign_not_active');
|
|
138
|
+
if(['tool_unavailable','history_policy_unavailable'].includes(error.code))return finish('tool_unavailable');
|
|
139
|
+
if(!skippable(error))return unknown(item.url,args.requestId,error);
|
|
140
|
+
report.failed++;progress({url:item.url,outcome:'failed',code:error.code,spent:credits(spent)});continue;
|
|
141
|
+
}
|
|
142
|
+
report.checked++;
|
|
143
|
+
const who={prospectId:result.prospectId,url:item.url,name:item.name??null,reason:result.assessment?.fit?.reason??null};
|
|
144
|
+
if(result.outcome==='fit'){report.fit++;report.fits.push({...who,admission:result.assessment?.admission});}
|
|
145
|
+
else if(result.outcome==='held'){report.held++;report.heldPeople.push({...who,reason:result.assessment?.reason??null});}
|
|
146
|
+
else if(result.outcome==='not_fit')report.notFit++;
|
|
147
|
+
else report.alreadyFound++;
|
|
148
|
+
progress({url:item.url,outcome:result.outcome,spent:credits(spent)});
|
|
149
|
+
}
|
|
150
|
+
// Follow the next page even when this one had only people already seen; MAX_PAGES still bounds it.
|
|
151
|
+
page=data.result?.nextPage>page?data.result.nextPage:0;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return finish(report.fit>=count?'count_reached':'searches_exhausted');
|
|
155
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {z} from 'zod';
|
|
2
|
+
const uuid=z.string().uuid(),workspace={workspaceId:uuid.optional()},locator={...workspace,campaignId:uuid};
|
|
3
|
+
const state=z.enum(['Draft','Preparing','Active','Paused','Closing','Archived']);
|
|
4
|
+
const signalBase={id:uuid,scope:z.enum(['company','person']),details:z.string().optional()};
|
|
5
|
+
const signal=z.discriminatedUnion('kind',[
|
|
6
|
+
z.object({...signalBase,kind:z.literal('research'),question:z.string(),expected:z.boolean().optional()}).strict(),
|
|
7
|
+
z.object({...signalBase,kind:z.literal('topic'),topic:z.string()}).strict(),
|
|
8
|
+
z.object({...signalBase,kind:z.literal('profile'),url:z.string(),track:z.enum(['audience','activity']).optional(),interactions:z.array(z.enum(['comments','reactions'])).optional()}).strict(),
|
|
9
|
+
]);
|
|
10
|
+
const fields=z.object({name:z.string(),audienceId:uuid,audienceRevision:z.number().int(),offerId:uuid,offerRevision:z.number().int(),premise:z.string().optional(),playbookPin:z.object({id:uuid,revision:z.number().int().positive()}).strict().optional(),notes:z.string().optional(),ownerUserId:uuid.nullable().optional(),tags:z.array(z.string()).optional(),signalMatch:z.enum(['any','all']).optional(),signals:z.array(signal).optional(),method:z.enum(['signals','listen','track-audience','track-engagement','custom']).optional(),findingInstructions:z.string().optional(),targets:z.discriminatedUnion('kind',[
|
|
11
|
+
z.object({kind:z.literal('none'),values:z.array(z.never())}).strict(),
|
|
12
|
+
z.object({kind:z.literal('companies'),values:z.array(z.object({type:z.enum(['domain','linkedin-company']),value:z.string()}).strict())}).strict(),
|
|
13
|
+
]).optional(),channels:z.array(z.enum(['email','linkedin'])).optional(),approvalMode:z.literal('manual').optional(),contentOpsEnabled:z.boolean().optional(),schedule:z.object({startAt:z.string().nullable().optional(),endAt:z.string().nullable().optional(),maxCompanies:z.number().int().nullable().optional()}).strict().optional()}).strict();
|
|
14
|
+
const path=a=>`/workspaces/${a.workspaceId}/campaigns`,detail=a=>path(a)+`/${a.campaignId}`;
|
|
15
|
+
const qs=a=>new URLSearchParams(Object.fromEntries(Object.entries(a).filter(([k,v])=>!['workspaceId','campaignId'].includes(k)&&v!==undefined).map(([k,v])=>[k,String(v)])));
|
|
16
|
+
export const campaignOperations={
|
|
17
|
+
campaign_summary:{schema:z.object({...workspace}).strict(),method:'GET',path:a=>`/workspaces/${a.workspaceId}/campaign-summary`,scoped:true,description:'Counts of Campaigns per state, and per Campaign its Audience, offer, playbook, goal stage, methods, people, engaged and at-goal counts since start.'},
|
|
18
|
+
campaign_list:{schema:z.object({...workspace,state:state.optional(),archived:z.boolean().optional(),q:z.string().optional(),limit:z.number().int().optional(),after:uuid.optional()}).strict(),method:'GET',path:a=>path(a)+'?'+qs(a),scoped:true},
|
|
19
|
+
campaign_get:{schema:z.object({...locator,revision:z.number().int().optional()}).strict(),method:'GET',path:a=>detail(a)+'?'+qs(a),scoped:true},
|
|
20
|
+
campaign_events:{schema:z.object({...locator,limit:z.number().int().optional(),after:uuid.optional()}).strict(),method:'GET',path:a=>detail(a)+'/events?'+qs(a),scoped:true},
|
|
21
|
+
campaign_create:{schema:z.object({...workspace,requestId:uuid,fields}).strict(),method:'POST',path,body:a=>({requestId:a.requestId,fields:a.fields}),scoped:true},
|
|
22
|
+
campaign_update:{schema:z.object({...locator,expectedRevision:z.number().int(),fields}).strict(),method:'POST',path:a=>detail(a)+'/revisions',body:a=>({expectedRevision:a.expectedRevision,fields:a.fields}),scoped:true},
|
|
23
|
+
campaign_transition:{schema:z.object({...locator,expectedState:state,operation:z.enum(['prepare','activate','pause','resume','close','archive','restore']),reason:z.string().optional()}).strict(),method:'POST',path:a=>detail(a)+'/transitions',body:a=>({expectedState:a.expectedState,operation:a.operation,...(a.reason===undefined?{}:{reason:a.reason})}),scoped:true,description:'Move a Campaign through its lifecycle. prepare: Draft to Preparing (signal tracking and prospect finding run, no engagement). activate: Draft or Preparing to Active (finding and engagement run). pause: stop automation. resume: back to the paused state. close: Active to Closing (finding stops, engagement continues). archive: stop everything and hide it. restore: Archived to Paused, or Draft for a draft. Agents do not start or activate campaigns; the user does. A campaign that cannot run is refused with campaign_not_ready and its missing items in blockers.'},
|
|
24
|
+
campaign_pace_get:{schema:z.object({...locator}).strict(),method:'GET',path:a=>detail(a)+'/pace',scoped:true,description:'Read a Campaign pace: new people a day. Derived is 25 a day per confirmed LinkedIn account, each capped by its reported connection-request limit; an email-only Campaign uses its own number, 50 by default, within mailbox limits. The reply names the LinkedIn accounts, the weekly figure and the expected daily credits for hosted lookups.'},
|
|
25
|
+
campaign_pace_set:{schema:z.object({...locator,perDay:z.union([z.number().int().min(1).max(1000),z.literal('derived')]).optional(),maxCredits:z.number().int().min(1).max(100000).nullable().optional()}).strict().refine(a=>a.perDay!==undefined||a.maxCredits!==undefined,{message:'Send perDay, maxCredits or both.'}),method:'POST',path:a=>detail(a)+'/pace',body:a=>({...(a.perDay===undefined?{}:{perDay:a.perDay}),...(a.maxCredits===undefined?{}:{maxCredits:a.maxCredits})}),scoped:true,description:'Save a Campaign pace. Pass the number the user gave, or "derived" to follow the confirmed sending accounts. An owner number stays until changed and is still capped by LinkedIn account and mailbox limits. maxCredits saves the credits a day the user approved for finding (null removes it); each day\'s find task carries it.'},
|
|
26
|
+
campaign_targets_get:{schema:z.object({...locator,limit:z.number().int().min(1).max(500).optional(),after:z.string().regex(/^\d{1,7}$/).optional()}).strict(),method:'GET',path:a=>detail(a)+'/targets?'+qs(a),scoped:true,description:'Read a Campaign\'s target companies in pages of up to 500: domains and LinkedIn company URLs. Only people whose current company is one of them can join.'},
|
|
27
|
+
campaign_targets_change:{schema:z.object({...locator,expectedRevision:z.number().int().positive(),add:z.array(z.string()).optional(),remove:z.array(z.string()).optional(),set:z.array(z.string()).optional()}).strict().refine(a=>(a.set===undefined)!==(a.add===undefined&&a.remove===undefined),{message:'Send set, or add and or remove.'}),method:'POST',path:a=>detail(a)+'/targets',body:a=>({expectedRevision:a.expectedRevision,...Object.fromEntries(['add','remove','set'].filter(k=>a[k]!==undefined).map(k=>[k,a[k]]))}),scoped:true,description:'Change a Campaign\'s target companies: add or remove domains (acme.com) and LinkedIn company URLs, or replace them all with set. Saves a new Campaign revision; an empty set removes the targets.'},
|
|
28
|
+
campaign_prospect_check:{schema:z.object({...locator,url:z.string().max(1024),requestId:uuid,maxCharge:z.string().regex(/^\d{1,20}$/).optional()}).strict(),method:'POST',path:a=>detail(a)+'/prospect-checks',body:a=>({url:a.url,requestId:a.requestId,...(a.maxCharge!==undefined?{maxCharge:a.maxCharge}:{})}),scoped:true,recoverable:true,timeoutMs:90000,description:'Check one LinkedIn person URL for a Campaign and record the result. Fetches the person and current company (saved profiles are reused free; otherwise each fetch spends hosted credits), runs the pinned Audience rules and records a qualification with the profiles as evidence. outcome fit: admitted, or shortlisted while the Campaign is a Draft. held: an agent must judge a smart rule, a signal or an undecided rule. not_fit: a rule failed. already_found: already in or shortlisted for this Campaign; nothing written. charged and balance are pico USD (1 credit = 10,000,000,000). Generate requestId once and reuse it after an interruption so a retry never pays twice.'},
|
|
29
|
+
campaign_successor:{schema:z.object({...locator,requestId:uuid,expectedRevision:z.number().int(),fields}).strict(),method:'POST',path:a=>detail(a)+'/successors',body:a=>({requestId:a.requestId,expectedRevision:a.expectedRevision,fields:a.fields}),scoped:true},
|
|
30
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {callWorkspace,configuration} from './workspaces.mjs';
|
|
2
|
+
import {readJsonFile} from './json-file.mjs';
|
|
3
|
+
export async function campaignCli(args){
|
|
4
|
+
const position=[],flags={};for(let i=0;i<args.length;i++){
|
|
5
|
+
if(!args[i].startsWith('--')){position.push(args[i]);continue}
|
|
6
|
+
const key=args[i].slice(2);if(!['profile','workspace','file','request-id','expected-revision','revision','archived','limit','after','q','state','operation','expected-state','reason','per-day','count','max-credits','url','add','remove','set'].includes(key)||!args[i+1]||args[i+1].startsWith('--')||Object.hasOwn(flags,key))throw new Error('Unknown or incomplete campaign option.');
|
|
7
|
+
flags[key]=args[++i];
|
|
8
|
+
}
|
|
9
|
+
const command=position[1];
|
|
10
|
+
if(command==='find'){
|
|
11
|
+
if(position.length!==3)throw new Error('Use campaign find ID --count N --max-credits C.');
|
|
12
|
+
if(!/^\d{1,3}$/.test(flags.count??'')||Number(flags.count)<1||Number(flags.count)>500)throw new Error('Set --count to how many fits to find, from 1 to 500.');
|
|
13
|
+
if(flags['max-credits']===undefined)throw new Error('Set --max-credits to the most credits this run may spend.');
|
|
14
|
+
const {findCampaignProspects,parseCredits}=await import('./campaign-find.mjs');
|
|
15
|
+
const config=configuration(flags.profile);
|
|
16
|
+
const report=await findCampaignProspects((operation,input)=>callWorkspace(operation,input,config),{workspaceId:flags.workspace,campaignId:position[2],count:Number(flags.count),maxCredits:parseCredits(flags['max-credits'])},{progress:step=>console.error(JSON.stringify(step))});
|
|
17
|
+
console.log(JSON.stringify({data:report}));return;
|
|
18
|
+
}
|
|
19
|
+
if(command==='check'){
|
|
20
|
+
if(position.length!==3||!flags.url)throw new Error('Use campaign check ID --url LINKEDIN_URL [--request-id UUID].');
|
|
21
|
+
const {randomUUID}=await import('node:crypto');
|
|
22
|
+
console.log(JSON.stringify(await callWorkspace('campaign_prospect_check',{workspaceId:flags.workspace,campaignId:position[2],url:flags.url,requestId:flags['request-id']??randomUUID()},configuration(flags.profile))));return;
|
|
23
|
+
}
|
|
24
|
+
if(command==='targets'){
|
|
25
|
+
if(position.length!==3)throw new Error('Use campaign targets ID [--add a.com,b.com] [--remove a.com] [--set FILE|-] [--expected-revision N].');
|
|
26
|
+
const config=configuration(flags.profile),input={workspaceId:flags.workspace,campaignId:position[2]};
|
|
27
|
+
const change=['add','remove','set'].some(k=>flags[k]!==undefined);
|
|
28
|
+
if(!change){if(flags.limit!==undefined)input.limit=Number(flags.limit);if(flags.after)input.after=flags.after;console.log(JSON.stringify(await callWorkspace('campaign_targets_get',input,config)));return;}
|
|
29
|
+
if(flags.set!==undefined&&(flags.add!==undefined||flags.remove!==undefined))throw new Error('Use --set on its own, or --add and --remove.');
|
|
30
|
+
const split=v=>v.split(',').map(x=>x.trim()).filter(Boolean);
|
|
31
|
+
if(flags.add!==undefined)input.add=split(flags.add);
|
|
32
|
+
if(flags.remove!==undefined)input.remove=split(flags.remove);
|
|
33
|
+
if(flags.set!==undefined){const {readFileSync}=await import('node:fs');input.set=readFileSync(flags.set==='-'?0:flags.set,'utf8').split(/\r?\n/).map(x=>x.trim()).filter(x=>x&&!x.startsWith('#'));}
|
|
34
|
+
input.expectedRevision=flags['expected-revision']!==undefined?Number(flags['expected-revision']):(await callWorkspace('campaign_targets_get',{workspaceId:flags.workspace,campaignId:position[2],limit:1},config)).data.revision;
|
|
35
|
+
console.log(JSON.stringify(await callWorkspace('campaign_targets_change',input,config)));return;
|
|
36
|
+
}
|
|
37
|
+
if(command==='summary'){
|
|
38
|
+
if(position.length!==2)throw new Error('Use campaign summary [--workspace ID].');
|
|
39
|
+
console.log(JSON.stringify(await callWorkspace('campaign_summary',{workspaceId:flags.workspace},configuration(flags.profile))));return;
|
|
40
|
+
}
|
|
41
|
+
const name={list:'campaign_list',show:'campaign_get',create:'campaign_create',update:'campaign_update',transition:'campaign_transition',successor:'campaign_successor',events:'campaign_events',pace:flags['per-day']===undefined&&flags['max-credits']===undefined?'campaign_pace_get':'campaign_pace_set'}[command];
|
|
42
|
+
if(!name||position.length!==(['list','create'].includes(command)?2:3))throw new Error('Use campaign list|summary|show ID|create|update ID|transition ID|successor ID|events ID|pace ID [--per-day N|derived] [--max-credits C|none]|find ID --count N --max-credits C|check ID --url URL|targets ID [--add|--remove|--set].');
|
|
43
|
+
if(flags['per-day']!==undefined&&command!=='pace')throw new Error('Set the pace with campaign pace ID --per-day N|derived, after the campaign is saved.');
|
|
44
|
+
const input={workspaceId:flags.workspace};
|
|
45
|
+
if(position[2])input.campaignId=position[2];
|
|
46
|
+
if(['list','events'].includes(command)){
|
|
47
|
+
if(flags.archived!==undefined&&!['true','false'].includes(flags.archived))throw new Error('Archived must be true or false.');
|
|
48
|
+
if(flags.archived!==undefined)input.archived=flags.archived==='true';
|
|
49
|
+
if(flags.limit!==undefined)input.limit=Number(flags.limit);
|
|
50
|
+
if(flags.after)input.after=flags.after;
|
|
51
|
+
if(flags.q!==undefined)input.q=flags.q;if(flags.state!==undefined)input.state=flags.state;
|
|
52
|
+
}
|
|
53
|
+
if(command==='show'&&flags.revision!==undefined)input.revision=Number(flags.revision);
|
|
54
|
+
if(['create','update','successor'].includes(command)){if(!flags.file)throw new Error('Supply --file PATH containing Campaign fields.');input.fields=readJsonFile(flags.file,65536,'Campaign file');}
|
|
55
|
+
if(['create','successor'].includes(command))input.requestId=flags['request-id'];
|
|
56
|
+
if(['update','successor'].includes(command))input.expectedRevision=Number(flags['expected-revision']);
|
|
57
|
+
if(command==='transition'){input.operation=flags.operation;input.expectedState=flags['expected-state'];if(flags.reason!==undefined)input.reason=flags.reason;}
|
|
58
|
+
if(command==='pace'&&flags['per-day']!==undefined){const v=flags['per-day'];if(v!=='derived'&&!/^\d+$/.test(v))throw new Error('Pace must be a whole number of people a day, or derived.');input.perDay=v==='derived'?v:Number(v);}
|
|
59
|
+
if(command==='pace'&&flags['max-credits']!==undefined){const v=flags['max-credits'];if(v!=='none'&&!/^\d+$/.test(v))throw new Error('The credit cap must be a whole number of credits a day, or none.');input.maxCredits=v==='none'?null:Number(v);}
|
|
60
|
+
console.log(JSON.stringify(await callWorkspace(name,input,configuration(flags.profile))));
|
|
61
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const uuid = z.string().uuid();
|
|
3
|
+
const filters = { workspaceId: uuid.optional(), campaignId: uuid.optional(), ownerUserId: uuid.optional(), offerId: uuid.optional(), audienceId: uuid.optional(), tag: z.string().min(1).max(64).optional() };
|
|
4
|
+
const FILTER_TEXT = 'Filters (all optional, combined with AND): campaignId, ownerUserId (the campaign owner), offerId and audienceId (as pinned by the campaign), tag (a campaign tag). List the values with workspace_analytics_filters.';
|
|
5
|
+
const path = name => a => {
|
|
6
|
+
const query = new URLSearchParams(Object.entries(a).filter(([key, value]) => key !== 'workspaceId' && value !== undefined).map(([key, value]) => [key, String(value)]));
|
|
7
|
+
return `/workspaces/${a.workspaceId}/analytics/${name}${query.size ? `?${query}` : ''}`;
|
|
8
|
+
};
|
|
9
|
+
export const cohortOperations = {
|
|
10
|
+
workspace_analytics_cohorts: {
|
|
11
|
+
schema: z.object({ ...filters, stage: z.string().min(1).max(100).optional(), cohorts: z.number().int().min(1).max(52).optional(), horizon: z.number().int().min(1).max(26).optional() }).strict(), method: 'GET', path: path('cohorts'), scoped: true,
|
|
12
|
+
description: `Weekly entry cohorts: people grouped by the UTC week (Monday start) they were admitted to a campaign, and for each week since admission the share that reached a stage. stage is "goal" (default, each playbook's goal) or a stage key from the stages list in the response. cohorts is the number of weeks (default 12, at most 52); horizon is the number of weeks since entry shown after week 0 (default 8, at most 26). A cell is empty (null) until every member of the cohort has had that long. ${FILTER_TEXT} Same figures as Analytics > Cohorts.`,
|
|
13
|
+
},
|
|
14
|
+
workspace_stage_aging: {
|
|
15
|
+
schema: z.object({ ...filters, thresholdDays: z.number().int().min(1).max(365).optional() }).strict(), method: 'GET', path: path('stage-aging'), scoped: true,
|
|
16
|
+
description: `Stage aging: how long people still active in a campaign have been in their current stage, by stage: count, median days, 90th percentile days, oldest, and how many are over thresholdDays (default 7). ${FILTER_TEXT} Same figures as Analytics > Stage aging.`,
|
|
17
|
+
},
|
|
18
|
+
workspace_analytics_filters: {
|
|
19
|
+
schema: z.object({ workspaceId: uuid.optional() }).strict(), method: 'GET', path: path('filters'), scoped: true,
|
|
20
|
+
description: 'List the values the analytics filters can take in this workspace: campaigns, campaign owners, offers, audiences and campaign tags, with ids and names. Use the ids with workspace_analytics_get, workspace_analytics_cohorts and workspace_stage_aging.',
|
|
21
|
+
},
|
|
22
|
+
};
|
package/cli/cohorts.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { callWorkspace, configuration } from './workspaces.mjs';
|
|
2
|
+
|
|
3
|
+
const cell = value => { const text = String(value ?? ''); return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; };
|
|
4
|
+
const csv = rows => rows.map(row => row.map(cell).join(',')).join('\n') + '\n';
|
|
5
|
+
const share = value => value === null || value === undefined ? '' : `${(value * 100).toFixed(1)}%`;
|
|
6
|
+
|
|
7
|
+
/** One row per entry week: cohort size, then the share that reached the stage by each week since entry. */
|
|
8
|
+
export function cohortsCsv(data) {
|
|
9
|
+
const weeks = Array.from({ length: (data.horizon ?? 0) + 1 }, (_, i) => `week_${i}`);
|
|
10
|
+
return csv([['cohort_week', 'people', ...weeks], ...(data.weeks ?? []).map(row => [row.weekStart, row.size, ...row.share.map(share)])]);
|
|
11
|
+
}
|
|
12
|
+
export function agingCsv(data) {
|
|
13
|
+
return csv([['stage', 'people', 'median_days', 'p90_days', 'oldest_days', `over_${data.thresholdDays}_days`], ...(data.stages ?? []).map(row => [row.name, row.count, row.medianDays ?? '', row.p90Days ?? '', row.oldestDays ?? '', row.overThreshold])]);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const FLAGS = { workspace: null, profile: null, format: null, campaign: 'campaignId', owner: 'ownerUserId', offer: 'offerId', audience: 'audienceId', tag: 'tag', stage: 'stage', cohorts: 'cohorts', horizon: 'horizon', 'threshold-days': 'thresholdDays' };
|
|
17
|
+
const NUMBERS = new Set(['cohorts', 'horizon', 'threshold-days']);
|
|
18
|
+
const ALLOWED = { cohorts: ['campaign', 'owner', 'offer', 'audience', 'tag', 'stage', 'cohorts', 'horizon'], aging: ['campaign', 'owner', 'offer', 'audience', 'tag', 'threshold-days'], filters: [] };
|
|
19
|
+
const TOOLS = { cohorts: 'workspace_analytics_cohorts', aging: 'workspace_stage_aging', filters: 'workspace_analytics_filters' };
|
|
20
|
+
const USAGE = 'Use analytics cohorts|aging|filters [--workspace ID] [--campaign ID] [--owner ID] [--offer ID] [--audience ID] [--tag TAG] [cohorts: --stage goal|KEY --cohorts N --horizon N] [aging: --threshold-days N] [--format json|csv].';
|
|
21
|
+
|
|
22
|
+
export async function cohortCli(args) {
|
|
23
|
+
const flags = {}, positions = [];
|
|
24
|
+
for (let i = 0; i < args.length; i++) {
|
|
25
|
+
const arg = args[i];
|
|
26
|
+
if (arg === '--json') continue;
|
|
27
|
+
if (!arg.startsWith('--')) { positions.push(arg); continue; }
|
|
28
|
+
const key = arg.slice(2);
|
|
29
|
+
if (!Object.hasOwn(FLAGS, key) || Object.hasOwn(flags, key) || i + 1 >= args.length) throw Error(`Invalid flag --${key}.`);
|
|
30
|
+
flags[key] = args[++i];
|
|
31
|
+
}
|
|
32
|
+
const kind = positions[1];
|
|
33
|
+
if (positions.length !== 2 || !TOOLS[kind]) throw Error(USAGE);
|
|
34
|
+
const input = { workspaceId: flags.workspace };
|
|
35
|
+
for (const [key, value] of Object.entries(flags)) {
|
|
36
|
+
if (['workspace', 'profile', 'format'].includes(key)) continue;
|
|
37
|
+
if (!ALLOWED[kind].includes(key)) throw Error(`--${key} does not apply to analytics ${kind}.`);
|
|
38
|
+
if (NUMBERS.has(key)) { if (!/^\d+$/.test(value)) throw Error(`--${key} must be a whole number.`); input[FLAGS[key]] = Number(value); } else input[FLAGS[key]] = value;
|
|
39
|
+
}
|
|
40
|
+
if (flags.format && !['json', 'csv'].includes(flags.format)) throw Error('--format must be json or csv.');
|
|
41
|
+
if (flags.format === 'csv' && kind === 'filters') throw Error('--format csv applies to analytics cohorts and aging.');
|
|
42
|
+
const result = await callWorkspace(TOOLS[kind], input, configuration(flags.profile));
|
|
43
|
+
if (flags.format === 'csv') { const data = result?.data ?? result; process.stdout.write(kind === 'cohorts' ? cohortsCsv(data) : agingCsv(data)); return; }
|
|
44
|
+
console.log(JSON.stringify(result));
|
|
45
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import {z} from 'zod';
|
|
2
|
+
const uuid=z.string().uuid(),scope={workspaceId:uuid.optional()},kind=z.enum(['crm','data','calendar']),base=a=>`/workspaces/${a.workspaceId}/connections`;
|
|
3
|
+
export const connectionRegistryOperations={
|
|
4
|
+
workspace_connections_list:{schema:z.object({...scope,kind:kind.optional(),userId:uuid.optional(),cursor:uuid.optional()}).strict(),method:'GET',path:a=>`${base(a)}?${new URLSearchParams(Object.entries(a).filter(([k,v])=>k!=='workspaceId'&&v!==undefined).map(([k,v])=>[k,String(v)]))}`,scoped:true,description:'Read agent-managed registrations, current authorization and report freshness. Does not inspect provider credentials or access remote accounts.'},
|
|
5
|
+
workspace_connection_register:{schema:z.object({...scope,requestId:uuid,agentId:uuid,kind,provider:z.string(),name:z.string().trim().min(1).max(120)}).strict(),method:'POST',path:base,body:({workspaceId,...a})=>a,scoped:true,description:'Register your own agent’s existing workspace system. Current Admin required for CRM/data; members may register own calendars. Starts pending; no OAuth, credentials or external calls.'},
|
|
6
|
+
workspace_connection_report:{schema:z.object({...scope,id:uuid,requestId:uuid,expectedVersion:z.number().int().positive(),kind,provider:z.string(),status:z.enum(['reported','unavailable']),capabilities:z.array(z.enum(['read_customers','read_deals','read_contacts','write_contacts','write_meetings','lookup_people','lookup_companies','read_booked_meetings'])).max(8)}).strict(),method:'POST',path:a=>`${base(a)}/${a.id}/reports`,body:({workspaceId,id,...a})=>a,scoped:true,description:'Designated active agent only: report existing access, not a sync claim. Fixed 24h freshness. Calendar is booked-meeting access only; no send authorization. Same request replays historical response; list for current status.'},
|
|
7
|
+
workspace_connection_disconnect:{schema:z.object({...scope,id:uuid,requestId:uuid,expectedVersion:z.number().int().positive()}).strict(),method:'POST',path:a=>`${base(a)}/${a.id}/disconnect`,body:({workspaceId,id,...a})=>a,scoped:true,description:'Stop sharing a connection with the workspace. People only. Current Admin required, except members may disconnect their own calendar. Send expectedVersion from the last list.'},
|
|
8
|
+
};
|
package/cli/content.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { callWorkspace, configuration } from './workspaces.mjs';
|
|
3
|
+
import { readJsonFile } from './json-file.mjs';
|
|
4
|
+
const help = `Usage:
|
|
5
|
+
audienti topics create --input FILE --request-id UUID [--workspace UUID]
|
|
6
|
+
audienti topics list [--cursor UUID]
|
|
7
|
+
audienti topics get UUID
|
|
8
|
+
audienti posts list [--topic-id UUID] [--cursor UUID]
|
|
9
|
+
audienti posts get UUID
|
|
10
|
+
|
|
11
|
+
Topic input: {"name":"Topic name","slug":"optional-slug","description":"optional"}.
|
|
12
|
+
These commands read saved records or create a topic; they do not call a paid provider.
|
|
13
|
+
Retrieve fresh data with audienti tools posts.fetch|posts.search|posts.comments|posts.reactions|profiles.posts.
|
|
14
|
+
Use audienti tools describe NAME --json for the exact contract.
|
|
15
|
+
All commands accept --profile PATH, --workspace UUID and --json.`;
|
|
16
|
+
export async function contentCli(argv) {
|
|
17
|
+
const [kind] = argv;
|
|
18
|
+
const { positionals, values } = parseArgs({ args: argv.slice(1), allowPositionals: true, strict: true, options: { help: { type: 'boolean', short: 'h' }, json: { type: 'boolean' }, input: { type: 'string' }, 'request-id': { type: 'string' }, workspace: { type: 'string' }, profile: { type: 'string' }, cursor: { type: 'string' }, 'topic-id': { type: 'string' } } });
|
|
19
|
+
if (values.help || !positionals.length) { console.log(help); return; }
|
|
20
|
+
const [verb, id] = positionals;
|
|
21
|
+
let args = values.workspace ? { workspaceId: values.workspace } : {};
|
|
22
|
+
if (verb === 'list' && positionals.length === 1) args = { ...args, cursor: values.cursor, ...(kind === 'posts' ? { topicId: values['topic-id'] } : {}) };
|
|
23
|
+
else if (verb === 'get' && positionals.length === 2) args.id = id;
|
|
24
|
+
else if (kind === 'topics' && verb === 'create' && positionals.length === 1) {
|
|
25
|
+
if (!values.input || !values['request-id']) throw new Error('Topic create requires --input FILE and --request-id UUID.');
|
|
26
|
+
args = { ...readJsonFile(values.input, 32768, 'Topic input'), ...args, requestId: values['request-id'] };
|
|
27
|
+
} else throw new Error('Unknown content command. Use --help.');
|
|
28
|
+
console.log(JSON.stringify(await callWorkspace(`${kind}_${verb}`, args, configuration(values.profile))));
|
|
29
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const uuid=z.string().uuid(),workspace={workspaceId:uuid.optional()},request={...workspace,requestId:uuid};
|
|
3
|
+
const skill=z.object({id:z.string(),version:z.string()}).strict();
|
|
4
|
+
const page=z.object({cursor:uuid.optional(),limit:z.number().int().min(1).max(100).optional()}).strict();
|
|
5
|
+
const endpoint=z.object({kind:z.enum(['prospect','profile']),id:uuid}).strict();
|
|
6
|
+
const subject=z.object({kind:z.enum(['person','company']),prospectId:uuid}).strict();
|
|
7
|
+
const text=z.string().nullable().optional();
|
|
8
|
+
const fields=z.object({name:text,title:text,companyName:text,location:text,bio:text,domain:text,description:text,imageUrl:text}).strict();
|
|
9
|
+
const status=(values)=>z.object({status:z.enum(values),evidenceIds:z.array(uuid)}).strict();
|
|
10
|
+
const root=a=>`/workspaces/${a.workspaceId}/prospects`;
|
|
11
|
+
const body=a=>Object.fromEntries(Object.entries(a).filter(([k])=>k!=='workspaceId'));
|
|
12
|
+
function qs(a){const values={};for(const[k,v]of Object.entries(a)){if(['workspaceId','prospectId'].includes(k)||v===undefined)continue;if(v&&typeof v==='object')for(const[s,x]of Object.entries(v))values[k+s[0].toUpperCase()+s.slice(1)]=String(x);else values[k]=String(v);}return new URLSearchParams(values);}
|
|
13
|
+
export const discoveryOperations={
|
|
14
|
+
profile_match_evaluate:{description:'Evaluate saved Profiles using current server-verified identity evidence. Free, deterministic; no supplier/AI call. Caller observations, names and citations cannot authorize a global merge.',schema:z.object({...workspace,sourceProfileId:uuid,candidateProfileId:uuid}).strict(),method:'POST',path:a=>`/workspaces/${a.workspaceId}/profiles/matches/evaluate`,body,scoped:true},
|
|
15
|
+
profile_match_apply:{description:'Apply a freshly evaluated identity match under server locks. Free; no supplier/AI call. Requires workspace write access and evaluation digest. Retry an uncertain response with the same request ID and unchanged input. Global staff correction is separate and unavailable to agents.',schema:z.object({...request,sourceProfileId:uuid,candidateProfileId:uuid,evaluationDigest:z.string().regex(/^[a-f0-9]{64}$/)}).strict(),method:'POST',path:a=>`/workspaces/${a.workspaceId}/profiles/matches/apply`,body,scoped:true},
|
|
16
|
+
profile_identity_history:{description:'Read identity movement lineage belonging to this workspace. Free; excludes global staff notes and other workspaces.',schema:z.object({...workspace,resolutionId:uuid}).strict(),method:'GET',path:a=>`/workspaces/${a.workspaceId}/profiles/identity-history/${a.resolutionId}`,scoped:true},
|
|
17
|
+
|
|
18
|
+
profile_list:{description:'Search saved workspace Profiles. Free CRM read; no provider call. Use profiles.discover for explicit paid external discovery.',schema:z.object({...workspace,prospectId:uuid.optional(),kind:z.enum(['person','company']).optional(),provider:z.string().trim().min(1).max(80).optional(),search:z.string().max(120).optional(),cursor:z.string().max(1024).optional(),limit:z.number().int().min(1).max(100).optional()}).strict(),method:'GET',path:a=>`/workspaces/${a.workspaceId}/profiles?`+new URLSearchParams(Object.fromEntries(Object.entries(a).filter(([key,value])=>key!=='workspaceId'&&value!==undefined).map(([key,value])=>[key,String(value)]))),scoped:true},
|
|
19
|
+
profile_get:{description:'Read a saved workspace Profile and current canonical Prospect. Free CRM read; no provider call.',schema:z.object({...workspace,profileId:uuid}).strict(),method:'GET',path:a=>`/workspaces/${a.workspaceId}/profiles/${a.profileId}`,scoped:true},
|
|
20
|
+
|
|
21
|
+
prospect_list:{schema:z.object({...workspace,kind:z.enum(['person','company']).optional(),search:z.string().optional(),cursor:uuid.optional(),limit:z.number().int().min(1).max(100).optional()}).strict(),method:'GET',path:a=>root(a)+'?'+qs(a),scoped:true},
|
|
22
|
+
prospect_get:{schema:z.object({...workspace,prospectId:uuid,profiles:page.optional(),observations:page.optional(),citations:page.optional(),qualifications:page.optional()}).strict(),method:'GET',path:a=>root(a)+`/${a.prospectId}?`+qs(a),scoped:true},
|
|
23
|
+
prospect_timeline:{description:'Read what happened to a person, newest first: profile reads with the fields each set, changed or confirmed; signal evidence; qualification checks; campaign stage events; your conversations with agents about the person. Free CRM read; no provider call.',schema:z.object({...workspace,prospectId:uuid,limit:z.number().int().min(1).max(100).optional()}).strict(),method:'GET',path:a=>root(a)+`/${a.prospectId}/timeline?`+qs(a),scoped:true},
|
|
24
|
+
prospect_record_observation:{schema:z.object({...request,kind:z.enum(['person','company']),prospectId:uuid.optional(),source:z.object({provider:z.string(),providerId:z.string().optional(),url:z.string().optional()}).strict(),fields,provenance:z.object({url:z.string(),excerpt:z.string().optional()}).strict().optional(),observedAt:z.string().nullable().optional(),skill:skill.optional()}).strict(),method:'POST',path:a=>root(a)+'/observations',body,scoped:true},
|
|
25
|
+
prospect_record_citation:{schema:z.object({...request,source:endpoint,target:endpoint,edgeType:z.enum(['works_for','works_at','worked_at','references']),roles:z.array(z.object({roleId:z.string(),title:z.string(),current:z.boolean(),startDate:text,endDate:text,employmentType:text,observedAt:text}).strict()).optional(),evidenceIds:z.array(uuid).optional(),employmentSelection:z.object({action:z.enum(['set_primary','clear']),roleId:z.string().optional(),evidenceHash:z.string().optional()}).strict().optional()}).strict(),method:'POST',path:a=>root(a)+'/citations',body,scoped:true},
|
|
26
|
+
prospect_record_evidence:{schema:z.object({...request,subject,campaignId:uuid.optional(),campaignRevision:z.number().int().positive().optional(),kind:z.enum(['signal','interpretation']),data:z.record(z.string(),z.unknown()),sourceUrl:text,observedAt:text,skill}).strict(),method:'POST',path:a=>root(a)+'/evidence',body,scoped:true},
|
|
27
|
+
campaign_submit_qualification:{schema:z.object({...request,campaignId:uuid,campaignRevision:z.number().int().positive(),prospectId:uuid,companyProspectId:uuid.optional(),companyIcp:status(['pass','fail','unverified']),personIcp:status(['pass','fail','unverified']),signal:status(['match','no_match','unverified','not_required']),fit:z.object({status:z.enum(['high','moderate','low','no_fit','unscored']),evidenceIds:z.array(uuid),reason:z.string().optional()}).strict(),skill}).strict(),method:'POST',path:a=>`/workspaces/${a.workspaceId}/campaigns/${a.campaignId}/qualifications`,body:a=>Object.fromEntries(Object.entries(body(a)).filter(([k])=>k!=='campaignId')),scoped:true},
|
|
28
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { callWorkspace, configuration } from './workspaces.mjs';
|
|
2
|
+
import { readJsonFile } from './json-file.mjs';
|
|
3
|
+
export async function discoveryCli(args){
|
|
4
|
+
const flags={},position=[];
|
|
5
|
+
const children=['profiles','observations','citations','qualifications'];
|
|
6
|
+
const allowed=['profile','workspace','file','request-id','kind','search','cursor','limit',...children.flatMap(k=>[k+'-cursor',k+'-limit'])];
|
|
7
|
+
for(let i=0;i<args.length;i++){if(args[i]==='--json')continue;if(!args[i].startsWith('--')){position.push(args[i]);continue;}const key=args[i].slice(2);if(!allowed.includes(key)||!args[i+1]||args[i+1].startsWith('--')||Object.hasOwn(flags,key))throw new Error('Unknown or incomplete discovery option.');flags[key]=args[++i];}
|
|
8
|
+
const command=position.slice(0,3).join(' ');
|
|
9
|
+
let name,input={workspaceId:flags.workspace};
|
|
10
|
+
if(position[0]==='prospects'&&position[1]==='list'&&position.length===2){name='prospect_list';for(const k of ['kind','search','cursor'])if(flags[k]!==undefined)input[k]=flags[k];if(flags.limit!==undefined)input.limit=Number(flags.limit);}
|
|
11
|
+
else if(position[0]==='prospects'&&position[1]==='get'&&position.length===3){name='prospect_get';input.prospectId=position[2];for(const k of children){const cursor=flags[k+'-cursor'],limit=flags[k+'-limit'];if(cursor!==undefined||limit!==undefined)input[k]={...(cursor===undefined?{}:{cursor}),...(limit===undefined?{}:{limit:Number(limit)})};}}
|
|
12
|
+
else if(position[0]==='prospects'&&position[1]==='timeline'&&position.length===3){name='prospect_timeline';input.prospectId=position[2];if(flags.limit!==undefined)input.limit=Number(flags.limit);}
|
|
13
|
+
else{ name={'prospects observation create':'prospect_record_observation','prospects citation create':'prospect_record_citation','prospects evidence create':'prospect_record_evidence','campaigns qualification submit':'campaign_submit_qualification'}[command];if(!name||position.length!==3||!flags.file)throw new Error('Use prospects list|get ID|timeline ID|observation create|citation create|evidence create, or campaigns qualification submit --file INPUT.json.');input={...readJsonFile(flags.file,65536,'Discovery file'),...(flags.workspace?{workspaceId:flags.workspace}:{}),...(flags['request-id']?{requestId:flags['request-id']}:{})}; }
|
|
14
|
+
console.log(JSON.stringify(await callWorkspace(name,input,configuration(flags.profile))));
|
|
15
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {z} from 'zod';
|
|
2
|
+
const fileType=z.string().max(127).refine(t=>['image/jpeg','image/png','application/pdf','video/quicktime','video/mp4'].includes(t)||/^text\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i.test(t));
|
|
3
|
+
const fields={fileType,fileName:z.string().min(1).max(255)},key=z.string().min(1).max(1024);
|
|
4
|
+
const authority='Personal account files only. Verified human workspace:access credential required; agent credentials are rejected. No workspace selection.';
|
|
5
|
+
export const fileOperations={
|
|
6
|
+
file_list:{schema:z.object({}).strict(),method:'GET',path:()=>'/me/files',description:`List owned file records. ${authority}`},
|
|
7
|
+
file_upload_prepare:{schema:z.object(fields).strict(),method:'POST',path:()=>'/me/files/uploads',body:a=>a,description:`Prepare upload; POST returned fields and file bytes to s3UploadUrl, then register. Max5MiB, exact concrete MIME. Sensitive signed fields expire in one hour; do not persist. Signing does not upload. ${authority}`},
|
|
8
|
+
file_register:{schema:z.object({...fields,s3Key:key}).strict(),method:'POST',path:()=>'/me/files',body:a=>a,description:`Register an uploaded owner-prefix object. Reuse key and metadata after an unknown response; same input replays, changed metadata conflicts. ${authority}`},
|
|
9
|
+
file_download:{schema:z.object({s3Key:key}).strict(),method:'POST',path:()=>'/me/files/download',body:a=>a,description:`Get one-hour signed URL for owned file. Sensitive bearer URL: do not persist or share. ${authority}`},
|
|
10
|
+
file_delete:{schema:z.object({id:z.string().uuid()}).strict(),method:'POST',path:()=>'/me/files/delete',body:a=>a,description:`Remove owned record; storage cleanup is best effort. A success does not guarantee physical deletion or revoke outstanding signed URLs. ${authority}`},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const maxBytes=5*1024*1024;
|
|
14
|
+
fileOperations.file_upload={localUpload:true,schema:z.object({...fields,contentBase64:z.string().min(4).max(4*Math.ceil(maxBytes/3)).refine(value=>/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)&&Buffer.from(value,'base64').length<=maxBytes)}).strict(),description:`Upload small files and register them, composing the same signed POST protocol as the UI. Supply base64 content, at most5MiB; prefer the CLI for large files. Trust only your configured API server: its signed response chooses the storage host. On an uncertain response use the returned recovery key with file_register, never blindly repeat file_upload. ${authority}`};
|
|
15
|
+
export async function uploadFileContent(input,call){
|
|
16
|
+
const bytes=Buffer.from(input.contentBase64,'base64');
|
|
17
|
+
const prepared=(await call('file_upload_prepare',{fileName:input.fileName,fileType:input.fileType})).data;
|
|
18
|
+
const recovery={fileName:input.fileName,fileType:input.fileType,s3Key:prepared.s3Key};
|
|
19
|
+
try{
|
|
20
|
+
const target=new URL(prepared.s3UploadUrl);if(target.protocol!=='https:'&&!(target.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(target.hostname)))throw Error('Unsafe storage URL');
|
|
21
|
+
if(target.username||target.password)throw Error('Unsafe storage URL');
|
|
22
|
+
const form=new FormData();for(const [key,value] of Object.entries(prepared.s3UploadFields))form.append(key,String(value));form.append('file',new Blob([bytes],{type:input.fileType}),input.fileName);
|
|
23
|
+
const response=await fetch(target,{method:'POST',body:form,redirect:'error',signal:AbortSignal.timeout(60000)});if(!response.ok)throw Error('Storage rejected upload');
|
|
24
|
+
return await call('file_register',recovery);
|
|
25
|
+
}catch{
|
|
26
|
+
const error=new Error('Upload completion is unconfirmed. Inspect owned files or retry file_register with the recovery fields. Do not repeat file_upload blindly.');
|
|
27
|
+
error.toJSON=()=>({code:'file_upload_unconfirmed',message:error.message,recovery});throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
package/cli/files.mjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {readFileSync,statSync} from 'node:fs';
|
|
2
|
+
import {basename} from 'node:path';
|
|
3
|
+
import {callWorkspace,configuration} from './workspaces.mjs';
|
|
4
|
+
export async function fileCli(args){
|
|
5
|
+
const position=[],flags={};for(let i=0;i<args.length;i++){if(!args[i].startsWith('--')){position.push(args[i]);continue;}const key=args[i].slice(2);if(key==='json')continue;if(!['profile','name','type','key'].includes(key)||!args[i+1]||args[i+1].startsWith('--')||Object.hasOwn(flags,key))throw Error('Unknown or incomplete file option.');flags[key]=args[++i];}
|
|
6
|
+
const command=position[1],name={list:'file_list',upload:'file_upload',prepare:'file_upload_prepare',register:'file_register',download:'file_download',delete:'file_delete'}[command];
|
|
7
|
+
if(!name||position.length!==(['delete','upload'].includes(command)?3:2))throw Error('Use file upload PATH --type MIME|list|prepare --name NAME --type MIME|register --key KEY --name NAME --type MIME|download --key KEY|delete ID.');
|
|
8
|
+
if(command==='upload'){
|
|
9
|
+
const path=position[2],stat=statSync(path);if(!stat.isFile()||stat.size===0||stat.size>5*1024*1024)throw Error('Select a non-empty file up to5MiB.');
|
|
10
|
+
const contentBase64=readFileSync(path).toString('base64');console.log(JSON.stringify(await callWorkspace(name,{fileName:flags.name??basename(path),fileType:flags.type,contentBase64},configuration(flags.profile))));return;
|
|
11
|
+
}
|
|
12
|
+
const input=command==='list'?{}:command==='delete'?{id:position[2]}:command==='download'?{s3Key:flags.key}:{fileName:flags.name,fileType:flags.type,...(command==='register'?{s3Key:flags.key}:{})};
|
|
13
|
+
console.log(JSON.stringify(await callWorkspace(name,input,configuration(flags.profile))));
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const workspace = { workspaceId: z.string().uuid().optional() };
|
|
3
|
+
const base = a => `/workspaces/${a.workspaceId}/guardrails`;
|
|
4
|
+
const values = z.array(z.string().trim().min(1).max(500)).max(200);
|
|
5
|
+
export const guardrailsOperations = {
|
|
6
|
+
workspace_guardrails_get: { schema: z.object(workspace).strict(), method: 'GET', path: base, scoped: true, description: 'Read workspace guardrails: whether automation is paused, who paused it, and the do-not-contact list.' },
|
|
7
|
+
workspace_guardrails_pause: { schema: z.object(workspace).strict(), method: 'POST', path: a => `${base(a)}/pause`, body: () => ({}), scoped: true, description: 'Pause all automation in the workspace. Sends, connects and hosted tools are refused with workspace_paused until a person resumes. Admin only.' },
|
|
8
|
+
workspace_guardrails_resume: { schema: z.object(workspace).strict(), method: 'POST', path: a => `${base(a)}/resume`, body: () => ({}), scoped: true, description: 'Resume automation. Only a person can resume; agent credentials are refused.' },
|
|
9
|
+
workspace_do_not_contact_update: { schema: z.object({ ...workspace, add: values.optional(), remove: values.optional() }).strict(), method: 'POST', path: a => `${base(a)}/do-not-contact`, body: a => ({ ...(a.add ? { add: a.add } : {}), ...(a.remove ? { remove: a.remove } : {}) }), scoped: true, description: 'Add or remove domains, email addresses and LinkedIn URLs on the do-not-contact list. Agents may add but not remove.' },
|
|
10
|
+
workspace_do_not_contact_import: { schema: z.object({ ...workspace, csv: z.string().min(1).max(400000) }).strict(), method: 'POST', path: a => `${base(a)}/do-not-contact/import`, body: a => ({ csv: a.csv }), scoped: true, description: 'Import do-not-contact entries from CSV text (domains, emails, LinkedIn URLs; at most 5,000 rows; header optional, value column named value/domain/email/linkedin/url or the first). Duplicates are skipped and invalid rows reported with row numbers. Matching people are blocked retroactively. Agents may import.' },
|
|
11
|
+
workspace_do_not_contact_export: { schema: z.object(workspace).strict(), method: 'GET', path: a => `${base(a)}/do-not-contact/export`, scoped: true, description: 'Export the do-not-contact list as CSV text (type, value, added_at, added_by, added_via).' },
|
|
12
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { callWorkspace, configuration } from './workspaces.mjs';
|
|
3
|
+
export async function guardrailsCli(args) {
|
|
4
|
+
const flags = {}, positions = [];
|
|
5
|
+
for (let i = 0; i < args.length; i++) {
|
|
6
|
+
const arg = args[i];
|
|
7
|
+
if (arg === '--json') continue;
|
|
8
|
+
if (!arg.startsWith('--')) { positions.push(arg); continue; }
|
|
9
|
+
const key = arg.slice(2);
|
|
10
|
+
if (!['workspace', 'profile', 'file', 'output'].includes(key) || Object.hasOwn(flags, key) || i + 1 >= args.length) throw Error(`Invalid flag --${key}.`);
|
|
11
|
+
flags[key] = args[++i];
|
|
12
|
+
}
|
|
13
|
+
const [group, verb, value] = positions;
|
|
14
|
+
const input = { workspaceId: flags.workspace };
|
|
15
|
+
let name, expected = 2;
|
|
16
|
+
if (group === 'guardrails' && verb === 'show') name = 'workspace_guardrails_get';
|
|
17
|
+
else if (group === 'guardrails' && verb === 'pause') name = 'workspace_guardrails_pause';
|
|
18
|
+
else if (group === 'guardrails' && verb === 'resume') name = 'workspace_guardrails_resume';
|
|
19
|
+
else if (group === 'dnc' && verb === 'list') name = 'workspace_guardrails_get';
|
|
20
|
+
else if (group === 'dnc' && (verb === 'add' || verb === 'remove')) { name = 'workspace_do_not_contact_update'; expected = 3; input[verb] = [value]; }
|
|
21
|
+
else if (group === 'dnc' && verb === 'import') {
|
|
22
|
+
name = 'workspace_do_not_contact_import'; expected = 2;
|
|
23
|
+
if (!flags.file) throw Error('dnc import needs --file LIST.csv.');
|
|
24
|
+
if (statSync(flags.file).size > 400000) throw Error('The CSV is larger than 400,000 characters. Split it into files of at most 5,000 rows.');
|
|
25
|
+
input.csv = readFileSync(flags.file, 'utf8');
|
|
26
|
+
}
|
|
27
|
+
else if (group === 'dnc' && verb === 'export') name = 'workspace_do_not_contact_export';
|
|
28
|
+
else throw Error('Use guardrails show|pause|resume or dnc list|add|remove VALUE|import --file LIST.csv|export [--output FILE.csv] --workspace ID.');
|
|
29
|
+
if (positions.length !== expected) throw Error('Wrong number of arguments. Use audienti help.');
|
|
30
|
+
const result = await callWorkspace(name, input, configuration(flags.profile));
|
|
31
|
+
if (group === 'dnc' && verb === 'export') {
|
|
32
|
+
const csv = (result?.data ?? result)?.csv ?? '';
|
|
33
|
+
if (flags.output) { writeFileSync(flags.output, csv); console.log(JSON.stringify({ data: { file: flags.output, count: (result?.data ?? result)?.count ?? 0 } })); } else process.stdout.write(csv);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
console.log(JSON.stringify(group === 'dnc' && verb === 'list' ? { data: result.data?.doNotContact ?? result.doNotContact ?? result } : result));
|
|
37
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {openSync,closeSync,fstatSync,readSync,constants} from 'node:fs';
|
|
2
|
+
export function readJsonFile(path,limit,label){
|
|
3
|
+
let fd;
|
|
4
|
+
try{
|
|
5
|
+
fd=openSync(path,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
|
|
6
|
+
const stat=fstatSync(fd);if(!stat.isFile()||stat.size>limit)throw new Error();
|
|
7
|
+
const buffer=Buffer.alloc(limit+1);let length=0,read=0;
|
|
8
|
+
do{read=readSync(fd,buffer,length,buffer.length-length,null);length+=read;}while(read&&length<buffer.length);
|
|
9
|
+
if(length>limit)throw new Error();
|
|
10
|
+
return JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(buffer.subarray(0,length)));
|
|
11
|
+
}catch(error){if(error?.code==='ENOENT')throw new Error(`${label} not found: ${path}. Paths are relative to the current directory.`);throw new Error(`${label} must be regular, non-symlink JSON no larger than ${limit/1024} KiB.`);}
|
|
12
|
+
finally{if(fd!==undefined)closeSync(fd)}
|
|
13
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {z} from 'zod';
|
|
2
|
+
// Agent state store (ADR 017). One map feeds the CLI, the MCP server and the HTTP client alike.
|
|
3
|
+
const uuid=z.string().uuid(),workspaceId=uuid.optional();
|
|
4
|
+
const key=z.string().min(1).max(200).regex(/^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/,'A key is 1 to 200 letters, digits, dots, colons, @, slashes, underscores or hyphens, and starts with a letter or digit.');
|
|
5
|
+
const text=z.string().min(1).max(200);
|
|
6
|
+
const paging={after:key.optional(),limit:z.number().int().min(1).max(50).optional()};
|
|
7
|
+
const base=a=>`/workspaces/${a.workspaceId}/agent-kv`;
|
|
8
|
+
const query=(a,keys)=>{const q=new URLSearchParams(keys.filter(k=>a[k]!==undefined).map(k=>[k,String(a[k])])).toString();return q?`?${q}`:''};
|
|
9
|
+
const body=keys=>a=>Object.fromEntries(keys.filter(k=>a[k]!==undefined).map(k=>[k,a[k]]));
|
|
10
|
+
const description="Agent state store: small values your user's agents share in this workspace between runs (cursors, change tokens, hashes, ids, timestamps). Every entry expires, by default 30 days after its last write. Never store credentials here; the Vault holds secrets and you store the Vault key name.";
|
|
11
|
+
export const kvOperations={
|
|
12
|
+
agent_kv_get:{schema:z.object({workspaceId,key}).strict(),method:'GET',path:a=>`${base(a)}/get${query(a,['key'])}`,scoped:true,description:`Read one entry: value, version, expiresAt and which agent wrote it. 404 not_found when missing or expired. ${description}`},
|
|
13
|
+
agent_kv_set:{schema:z.object({workspaceId,key,value:z.any(),ifVersion:z.number().int().min(1).optional(),nx:z.boolean().optional(),ttlSeconds:z.number().int().min(60).max(31536000).optional(),expiresAt:z.string().datetime({offset:true}).optional(),requestId:uuid.optional()}).strict(),method:'POST',path:a=>`${base(a)}/set`,body:body(['key','value','ifVersion','nx','ttlSeconds','expiresAt','requestId']),scoped:true,description:`Write one entry (a string, number, boolean, array or object up to 8 KB). A write refreshes expiry; ttlSeconds or expiresAt sets it, at most 365 days. ifVersion is compare-and-set: 409 version_conflict when the live version differs. nx writes only when no live entry exists. Reuse requestId on a retry: the same body replays for 30 days and answers replayed: true with the current entry; a different body under the same requestId is 409 request_conflict. Limits: 60 writes per 10 minutes per agent, 2,000 keys and 4 MB per user per workspace (429 with retryAt). Values shaped like credentials are refused (400 secret_rejected). ${description}`},
|
|
14
|
+
agent_kv_list:{schema:z.object({workspaceId,prefix:z.string().max(200).optional(),...paging}).strict(),method:'GET',path:a=>`${base(a)}/list${query(a,['prefix','after','limit'])}`,scoped:true,description:`List live entries in key order, 50 at a time, optionally under a key prefix; follow nextAfter for more. ${description}`},
|
|
15
|
+
agent_kv_search:{schema:z.object({workspaceId,key:text.optional(),value:text.optional(),...paging}).strict(),method:'GET',path:a=>`${base(a)}/search${query(a,['key','value','after','limit'])}`,scoped:true,description:`Find live entries whose key contains key text or whose value contains value text, case-insensitive, 50 at a time. Give at least one. ${description}`},
|
|
16
|
+
agent_kv_delete:{schema:z.object({workspaceId,key}).strict(),method:'POST',path:a=>`${base(a)}/delete`,body:body(['key']),scoped:true,description:'Delete one entry of the store. A missing key is not an error.'},
|
|
17
|
+
};
|