@supersuit/transcript-md 0.1.0
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/CHANGELOG.md +7 -0
- package/FORMAT.md +23 -0
- package/LICENSE +45 -0
- package/NOTICE +43 -0
- package/README.md +88 -0
- package/index.mjs +4 -0
- package/lib/freedom-conversation-casefold.mjs +1659 -0
- package/lib/freedom-conversation-citations.mjs +88 -0
- package/lib/freedom-conversation-legacy.mjs +167 -0
- package/lib/freedom-conversation-lock.mjs +41 -0
- package/lib/freedom-conversation-time.mjs +36 -0
- package/lib/freedom-conversation-write.mjs +546 -0
- package/lib/freedom-conversations-cli.mjs +87 -0
- package/lib/freedom-conversations.mjs +358 -0
- package/lib/freedom-is-main.mjs +65 -0
- package/lib/freedom-migrate-common.mjs +15 -0
- package/lib/freedom-people.mjs +8 -0
- package/lib/freedom-run-lock.mjs +171 -0
- package/lib/freedom-self.mjs +5 -0
- package/lib/freedom-workspace.mjs +19 -0
- package/lib/freedom_conversations.py +219 -0
- package/lib/frontmatter/conversation.mjs +284 -0
- package/lib/frontmatter/conversation.schema.json +1651 -0
- package/lib/frontmatter/yaml.mjs +248 -0
- package/package.json +44 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
// Guarded synchronous mutations. Exact entry bytes and full protection pre/postimages are
|
|
2
|
+
// durable before the first replacement; per-file atomicity is never called a transaction.
|
|
3
|
+
import {readFileSync,readdirSync,lstatSync,realpathSync,existsSync,mkdirSync,openSync,writeFileSync,fsyncSync,closeSync,renameSync,unlinkSync,rmdirSync,chmodSync,constants} from 'node:fs';
|
|
4
|
+
import {resolve,relative,join,dirname,basename,isAbsolute,sep} from 'node:path';
|
|
5
|
+
import {createHash,randomUUID} from 'node:crypto';
|
|
6
|
+
import {enclosingWorkspace} from './freedom-workspace.mjs';
|
|
7
|
+
import {peopleDir,recordPath} from './freedom-people.mjs';
|
|
8
|
+
import {workspaceFile} from './freedom-self.mjs';
|
|
9
|
+
import {parseConversationHeader,serializeConversationHeader,validateConversation} from './frontmatter/conversation.mjs';
|
|
10
|
+
import {readConversation,parseConversation,listConversationDirectory,indexConversations,canProjectConversation} from './freedom-conversations.mjs';
|
|
11
|
+
import {resolveCitation} from './freedom-conversation-citations.mjs';
|
|
12
|
+
import {conversationIdCollisionKey} from './freedom-conversation-casefold.mjs';
|
|
13
|
+
import {withConversationLock,conversationLockContext} from './freedom-conversation-lock.mjs';
|
|
14
|
+
|
|
15
|
+
const hash=b=>createHash('sha256').update(b).digest('hex');
|
|
16
|
+
const sha=s=>typeof s==='string'&&/^[a-f0-9]{64}$/.test(s);
|
|
17
|
+
const token=s=>typeof s==='string'&&/^[a-zA-Z0-9_-]{8,128}$/.test(s);
|
|
18
|
+
const text=s=>typeof s==='string'&&!!s.trim();
|
|
19
|
+
const object=v=>v!==null&&typeof v==='object'&&!Array.isArray(v);
|
|
20
|
+
const exact=(v,keys)=>object(v)&&Object.keys(v).sort().join(',')===[...keys].sort().join(',');
|
|
21
|
+
const clone=v=>structuredClone(v);
|
|
22
|
+
const stable=v=>JSON.stringify(normalize(v));
|
|
23
|
+
function normalize(v){return Array.isArray(v)?v.map(normalize):object(v)?Object.fromEntries(Object.keys(v).sort().map(k=>[k,normalize(v[k])])):v;}
|
|
24
|
+
const equal=(a,b)=>stable(a)===stable(b);
|
|
25
|
+
const refuse=(code,extra={})=>{throw Object.assign(new Error(`${code}: Conversation mutation refused.`),{code,...extra});};
|
|
26
|
+
const errors=diagnostics=>diagnostics.filter(d=>d.severity==='error');
|
|
27
|
+
const inside=(root,path)=>{const r=relative(root,path);return !isAbsolute(r)&&r!=='..'&&!r.startsWith('..'+sep);};
|
|
28
|
+
const relPath=s=>text(s)&&!isAbsolute(s)&&!/[\\\x00-\x1f\x7f]/.test(s)&&!s.split('/').some(p=>!p||p==='.'||p==='..'||p.startsWith('.'));
|
|
29
|
+
const fileBytes=path=>{try{const s=lstatSync(path);if(s.isSymbolicLink())refuse('SYMLINK_REFUSED');if(!s.isFile())refuse('FILE_REQUIRED');return readFileSync(path);}catch(e){if(e.code==='ENOENT')return null;throw e;}};
|
|
30
|
+
const digest=b=>b===null?null:hash(b);
|
|
31
|
+
function noLinks(root,path){
|
|
32
|
+
if(!inside(root,path))refuse('TARGET_MISMATCH');
|
|
33
|
+
let current=root;
|
|
34
|
+
for(const part of ['',...relative(root,path).split(sep).filter(Boolean)]){
|
|
35
|
+
if(part)current=join(current,part);
|
|
36
|
+
try{if(lstatSync(current).isSymbolicLink())refuse('SYMLINK_REFUSED');}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function anchor(path){let p=resolve(path);while(!existsSync(p)){const parent=dirname(p);if(parent===p)refuse('TARGET_MISMATCH');p=parent;}return realpathSync(p);}
|
|
40
|
+
function contextForFile(file,root){
|
|
41
|
+
if(typeof file!=='string'||!file)refuse('TARGET_MISMATCH');
|
|
42
|
+
const target=resolve(file),workspace=root??enclosingWorkspace(dirname(target));
|
|
43
|
+
if(!workspace)refuse('WORKSPACE_REQUIRED');
|
|
44
|
+
const c=conversationLockContext(workspace),rel=relative(c.transcripts,target).split(sep).join('/');
|
|
45
|
+
if(!/^[^/]+\/transcript\.md$/.test(rel)||!inside(c.transcripts,target))refuse('TARGET_MISMATCH');
|
|
46
|
+
noLinks(anchor(c.transcripts),c.transcripts);noLinks(c.transcripts,target);
|
|
47
|
+
return {...c,file:target,folder:dirname(target)};
|
|
48
|
+
}
|
|
49
|
+
function recheckContext(c){
|
|
50
|
+
const now=contextForFile(c.file,c.root);
|
|
51
|
+
if(now.transcripts!==c.transcripts||now.gitCommonDir!==c.gitCommonDir||now.root!==c.root)refuse('TARGET_MISMATCH');
|
|
52
|
+
}
|
|
53
|
+
function validRecord(bytes,file,newRecord=false,allowLegacy=false){
|
|
54
|
+
const record=parseConversation(bytes,{file,layout:'canonical'});
|
|
55
|
+
const bad=errors([...record.diagnostics,...validateConversation(record.header,{folderId:basename(dirname(file)),newRecord})]);
|
|
56
|
+
if(bad.length)refuse(bad[0].code,{diagnostics:bad.map(d=>({...d,path:null}))});
|
|
57
|
+
if(record.header.kind!=='conversation'||!allowLegacy&&record.header.body_format!=='turns-v1')refuse('CANONICAL_TURNS_REQUIRED');
|
|
58
|
+
return record;
|
|
59
|
+
}
|
|
60
|
+
// Validate only newly asserted identities. Historical pointers are preserved even
|
|
61
|
+
// when their record is later unavailable; existence never upgrades identity status.
|
|
62
|
+
function proveParticipants(c,h,before=null){
|
|
63
|
+
for(const [i,p] of h.participants.entries()){
|
|
64
|
+
const prior=before?.participants.find(old=>old.id===p.id);
|
|
65
|
+
if(p.person===null||prior?.person===p.person&&(p.status!=='confirmed'||prior.status==='confirmed'))continue;
|
|
66
|
+
try{
|
|
67
|
+
const path=p.person==='self'?workspaceFile(c.root,'self.md'):recordPath(peopleDir(c.root),p.person);
|
|
68
|
+
if(path===null||fileBytes(path)===null)throw new Error('unavailable');
|
|
69
|
+
}catch{refuse('PERSON_REFERENCE_UNRESOLVED',{diagnostics:[{code:'PERSON_REFERENCE_UNRESOLVED',severity:'error',path:null,field:`participants[${i}].person`,message:'New participant reference requires an existing readable record in the referring workspace.'}]});}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function evidenceValid(e,h){
|
|
73
|
+
const bad=errors(validateConversation({...h,keep:{scope:'home',group:null,decision:e}}));
|
|
74
|
+
if(!object(e)||bad.length||!text(e.note)||/[\r\n]/.test(e.note))refuse('REVISION_SPEC_INVALID');
|
|
75
|
+
}
|
|
76
|
+
function evidences(entries,h){if(!Array.isArray(entries)||!entries.length)refuse('REVISION_SPEC_INVALID');entries.forEach(e=>evidenceValid(e,h));}
|
|
77
|
+
function validLog(log,h){
|
|
78
|
+
if(!exact(log,['date','text','evidence'])||!text(log.text)||/[\r\n]/.test(log.text))refuse('REVISION_SPEC_INVALID');
|
|
79
|
+
const d=errors(validateConversation({...h,as_of:log.date}));if(d.length||!text(log.date))refuse('REVISION_SPEC_INVALID');evidences(log.evidence,h);
|
|
80
|
+
}
|
|
81
|
+
function sectionRange(record,name){
|
|
82
|
+
const section=record.sections.find(s=>s.name===name);if(!section)refuse('CANONICAL_SECTIONS_INVALID');
|
|
83
|
+
const start=section.bytes.byteOffset-record.body.byteOffset;
|
|
84
|
+
return {start,end:start+section.bytes.length,bytes:section.bytes};
|
|
85
|
+
}
|
|
86
|
+
// Creation inserts only derived completeness facts; supplied speech/prose stay exact.
|
|
87
|
+
function renderGaps(record){
|
|
88
|
+
const gaps=[...new Set(record.gaps.map(d=>d.code+(d.field?': '+d.field:'')))].sort();
|
|
89
|
+
if(!gaps.length)return record.body;
|
|
90
|
+
let body=record.body;
|
|
91
|
+
for(const name of ['Now','Open questions']){
|
|
92
|
+
const current=parseConversation(serializeConversationHeader(record.header,body),{file:record.file});
|
|
93
|
+
const section=sectionRange(current,name).bytes;
|
|
94
|
+
const addition=Buffer.from('\n<!-- conversation-gaps -->\n'+gaps.map(g=>'- '+(name==='Open questions'?'Resolve ':'')+g).join('\n')+'\n');
|
|
95
|
+
body=replaceSection(current,name,Buffer.concat([section,addition]));
|
|
96
|
+
}
|
|
97
|
+
return body;
|
|
98
|
+
}
|
|
99
|
+
function replaceSection(record,name,bytes){const r=sectionRange(record,name);return Buffer.concat([record.body.subarray(0,r.start),bytes,record.body.subarray(r.end)]);}
|
|
100
|
+
function appendLog(record,line){const s=sectionRange(record,'Log').bytes;const prefix=s.length&&s.at(-1)!==10?Buffer.from('\n'):Buffer.alloc(0);return replaceSection(record,'Log',Buffer.concat([s,prefix,Buffer.from(line+'\n')]));}
|
|
101
|
+
const logLine=(id,log,ids,review,state)=>`- ${log.date} - revision ${id}: ${log.text} <!-- revision ${stable({revision_id:id,evidence:log.evidence,turn_ids:ids,prior_review:review,protected_turn_ids:state.protected_turns.map(p=>p.turn_id).sort()})} -->`;
|
|
102
|
+
const inputTurn=t=>({id:t.id,speaker:t.speaker,sources:clone(t.sources),timing:clone(t.timing),source_gap:t.source_gap,text:t.text.toString('utf8')});
|
|
103
|
+
function renderTranscript(record,turns){
|
|
104
|
+
const original=sectionRange(record,'Transcript').bytes,first=record.turns[0];let prefix=original;
|
|
105
|
+
if(first){const marker=Buffer.from(`### ${first.id}`);const i=original.indexOf(marker);prefix=original.subarray(0,i);}
|
|
106
|
+
const parts=[prefix];
|
|
107
|
+
for(const t of turns){
|
|
108
|
+
if(!exact(t,['id','speaker','sources','timing','source_gap','text'])||!/^t-\d{6,}$/.test(t.id)||typeof t.text!=='string')refuse('REVISION_SPEC_INVALID');
|
|
109
|
+
const meta={speaker:t.speaker,sources:t.sources,timing:t.timing,source_gap:t.source_gap};
|
|
110
|
+
parts.push(Buffer.from(`### ${t.id}\n<!-- turn ${JSON.stringify(meta)} -->\n`),Buffer.from(t.text));
|
|
111
|
+
// A boundary needs a line separator; do not silently add bytes to utterance text.
|
|
112
|
+
if(t.text.length&&!t.text.endsWith('\n'))refuse('TURN_TEXT_BOUNDARY_INVALID');
|
|
113
|
+
}
|
|
114
|
+
return replaceSection(record,'Transcript',Buffer.concat(parts));
|
|
115
|
+
}
|
|
116
|
+
function retitleBody(record,h,body){
|
|
117
|
+
if(h.title===record.header.title)return body;
|
|
118
|
+
// Section offsets belong to this body, including any same-spec turn changes.
|
|
119
|
+
const current=validRecord(serializeConversationHeader({...h,title:record.header.title},body),record.file);
|
|
120
|
+
const title=current.sections.find(s=>s.kind==='other'&&s.name===record.header.title&&/^ {0,3}#[ \t]+/.test(s.bytes.toString('utf8')));
|
|
121
|
+
if(!title)return body;
|
|
122
|
+
const line=/^([ ]{0,3}#[ \t]+)(.+?)([ \t]+#+)?([ \t]*)(\r?\n|$)/.exec(title.bytes.toString('utf8'));
|
|
123
|
+
if(!line)refuse('TITLE_MISMATCH');
|
|
124
|
+
const offset=title.bytes.byteOffset-current.body.byteOffset;
|
|
125
|
+
const replacement=Buffer.from(line[1]+h.title+(line[3]??'')+line[4]+line[5]);
|
|
126
|
+
return Buffer.concat([body.subarray(0,offset),replacement,body.subarray(offset+Buffer.byteLength(line[0]))]);
|
|
127
|
+
}
|
|
128
|
+
function semantic(record,t){
|
|
129
|
+
const participant=t.speaker===null?null:record.header.participants.find(p=>p.id===t.speaker)??null;
|
|
130
|
+
const sources=t.sources.map(ref=>{const c=record.header.sources.find(c=>c.id===ref.capture);return {ref,capture:c?{id:c.id,type:c.type,service:c.service,recording_id:c.recording_id,vendor_id:c.vendor_id}:null,locator:ref.locator===null?null:c?.locators[ref.locator]??null};});
|
|
131
|
+
return hash(Buffer.from(stable({turn_id:t.id,text_base64:Buffer.isBuffer(t.text)?t.text.toString('base64'):Buffer.from(t.text).toString('base64'),speaker:t.speaker,participant,sources,timing:t.timing,source_gap:t.source_gap})));
|
|
132
|
+
}
|
|
133
|
+
function protection(record,c){
|
|
134
|
+
const existing=record.header.extensions.conversation_revision;
|
|
135
|
+
const state=existing?clone(existing):{next_turn_number:Math.max(0,...record.turns.map(t=>Number(t.id.slice(2))))+1,retired_turn_ids:[],protected_turns:[]};
|
|
136
|
+
if(!Number.isSafeInteger(state.next_turn_number)||record.turns.some(t=>Number(t.id.slice(2))>=state.next_turn_number||state.retired_turn_ids.includes(t.id)))refuse('TURN_ALLOCATION_INVALID');
|
|
137
|
+
if(record.header.body_format==='legacy'&&['transcription','attribution'].some(d=>record.header.review[d]==='human-reviewed'))refuse('REVIEW_PROTECTION_UNRESOLVED');
|
|
138
|
+
for(const p of state.protected_turns){const turn=record.turns.find(t=>t.id===p.turn_id);if(p.retired?!!turn||!state.retired_turn_ids.includes(p.turn_id):!turn||semantic(record,turn)!==p.baseline_sha256)refuse('REVIEW_BASELINE_MISMATCH');}
|
|
139
|
+
const dimensions=['transcription','attribution'].filter(d=>record.header.review[d]==='human-reviewed');
|
|
140
|
+
if(dimensions.length){
|
|
141
|
+
const evidence=record.header.review.evidence.filter(e=>e.kind==='operator-decision');if(!evidence.length)refuse('REVIEW_PROTECTION_UNRESOLVED');
|
|
142
|
+
for(const t of record.turns)if(!state.protected_turns.some(p=>p.turn_id===t.id)){
|
|
143
|
+
const baseline=semantic(record,t);
|
|
144
|
+
state.protected_turns.push({turn_id:t.id,baseline_sha256:baseline,reviewed_versions:[{turn_id:t.id,entry_revision:{kind:'sha256',value:record.fileSha256},entry_path:relative(c.transcripts,c.file).split(sep).join('/'),snapshot_sha256:baseline,dimensions,evidence:clone(evidence)}],authorized_changes:[],retired:false});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return state;
|
|
148
|
+
}
|
|
149
|
+
function captureIdentity(c){
|
|
150
|
+
if(c.type==='cloud_recording')return stable([c.type,c.service,c.recording_id]);
|
|
151
|
+
if(c.type&&c.vendor_id)return stable([c.type,c.vendor_id]);
|
|
152
|
+
const l=c.locators?.[0];return c.type&&l?stable([c.type,{kind:l.kind,path:l.path,url:l.url,service_id:l.service_id}]):null;
|
|
153
|
+
}
|
|
154
|
+
function noCaptureCollisions(c,h){
|
|
155
|
+
const listed=listConversationDirectory(c.transcripts);if(errors(listed.diagnostics).length)refuse('READ_INCOMPLETE');
|
|
156
|
+
for(const record of listed.records){
|
|
157
|
+
if(record.file===c.file)continue;
|
|
158
|
+
const ids=[record.id,...record.header.aliases.filter(a=>a.kind==='record-id').map(a=>a.value)];
|
|
159
|
+
if(ids.some(id=>conversationIdCollisionKey(id)===conversationIdCollisionKey(h.id)))refuse('ID_COLLISION');
|
|
160
|
+
for(const capture of h.sources){const key=captureIdentity(capture);const prior=record.header.sources.find(other=>key!==null&&captureIdentity(other)===key);if(prior)refuse('CAPTURE_IDENTITY_COLLISION',{existingId:record.id,captureId:prior.id});}
|
|
161
|
+
}
|
|
162
|
+
const aliasErrors=errors(indexConversations([...listed.records.filter(r=>r.file!==c.file),{id:h.id,file:c.file,header:h}]).diagnostics);if(aliasErrors.length)refuse(aliasErrors[0].code);
|
|
163
|
+
const keys=new Map();for(const capture of h.sources){const key=captureIdentity(capture);if(key&&keys.has(key))refuse('CAPTURE_IDENTITY_COLLISION',{captureId:keys.get(key)});if(key)keys.set(key,capture.id);}
|
|
164
|
+
}
|
|
165
|
+
function assetsFor(c,h,assets){
|
|
166
|
+
if(!Array.isArray(assets))refuse('ASSET_SPEC_INVALID');const out=[],seen=new Set();
|
|
167
|
+
for(const asset of assets){
|
|
168
|
+
if(!exact(asset,['relativePath','fromPath','sha256'])||!relPath(asset.relativePath)||!sha(asset.sha256)||!text(asset.fromPath))refuse('ASSET_SPEC_INVALID');
|
|
169
|
+
const parts=asset.relativePath.split('/');if(parts.length<3||!['sources','derived'].includes(parts[0])||!h.sources.some(s=>s.id===parts[1]))refuse('ASSET_SPEC_INVALID');
|
|
170
|
+
const to=resolve(c.folder,asset.relativePath);noLinks(c.folder,to);
|
|
171
|
+
const from=resolve(asset.fromPath);noLinks(realpathSync(dirname(from)),from);const bytes=fileBytes(from);
|
|
172
|
+
if(bytes===null||hash(bytes)!==asset.sha256)refuse('ASSET_HASH_MISMATCH');
|
|
173
|
+
if(seen.has(to))refuse('ASSET_PATH_COLLISION');seen.add(to);
|
|
174
|
+
const before=fileBytes(to);if(before!==null&&!before.equals(bytes))refuse('ASSET_PATH_COLLISION');
|
|
175
|
+
out.push({path:to,bytes,from,sha256:asset.sha256});
|
|
176
|
+
}
|
|
177
|
+
for(const cap of h.sources)for(const locator of cap.locators)if(locator.kind==='bundled'){
|
|
178
|
+
if(!relPath(locator.path))refuse('ASSET_SPEC_INVALID');const to=resolve(c.folder,locator.path);noLinks(c.folder,to);
|
|
179
|
+
const supplied=out.find(a=>a.path===to),bytes=supplied?.bytes??fileBytes(to);
|
|
180
|
+
if(bytes===null)refuse('BUNDLED_ASSET_MISSING');
|
|
181
|
+
if(locator.sha256!==null&&hash(bytes)!==locator.sha256||locator.bytes!==null&&bytes.length!==locator.bytes)refuse('ASSET_HASH_MISMATCH');
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
function backupProof(c,h,before,options){
|
|
186
|
+
const proof=[];
|
|
187
|
+
for(const cap of h.sources)for(const l of cap.locators)if(l.backup.status==='verified-owned'){
|
|
188
|
+
const prior=before?.header.sources.find(p=>p.id===cap.id)?.locators.find(old=>equal(old,l));if(prior)continue;
|
|
189
|
+
const b=l.backup;let fact;
|
|
190
|
+
try{fact=options.verifyOwnedBackup?.({locator:b.locator,sha256:b.sha256,verified_at:b.verified_at});}catch{refuse('BACKUP_UNPROVEN');}
|
|
191
|
+
if(!exact(fact,['owned','evidence'])||fact.owned!==true||!['operator-decision','machine-check'].includes(fact.evidence?.kind))refuse('BACKUP_UNPROVEN');
|
|
192
|
+
try{evidenceValid(fact.evidence,h);}catch{refuse('BACKUP_UNPROVEN');}
|
|
193
|
+
if(!isAbsolute(b.locator)||l.kind==='service')refuse('BACKUP_UNPROVEN');
|
|
194
|
+
if(l.kind==='external-local'&&!isAbsolute(l.path))refuse('BACKUP_UNPROVEN');
|
|
195
|
+
const bundled=l.kind==='bundled'?options.assets.find(a=>a.path===resolve(c.folder,l.path)):null;
|
|
196
|
+
const originalPath=l.kind==='bundled'?bundled?.from??resolve(c.folder,l.path):l.path;
|
|
197
|
+
let original;try{noLinks(realpathSync(dirname(originalPath)),originalPath);original=fileBytes(originalPath);}catch{refuse('BACKUP_UNPROVEN');}
|
|
198
|
+
let backup;try{noLinks(realpathSync(dirname(b.locator)),b.locator);backup=fileBytes(b.locator);}catch{refuse('BACKUP_UNPROVEN');}
|
|
199
|
+
if(original===null||backup===null||hash(original)!==b.sha256||hash(backup)!==b.sha256)refuse('BACKUP_UNPROVEN');
|
|
200
|
+
const originalStat=lstatSync(originalPath),backupStat=lstatSync(b.locator);
|
|
201
|
+
if(originalStat.dev===backupStat.dev&&originalStat.ino===backupStat.ino)refuse('BACKUP_UNPROVEN');
|
|
202
|
+
proof.push({original_path:originalPath,locator:b.locator,sha256:b.sha256,verified_at:b.verified_at,evidence:clone(fact.evidence)});
|
|
203
|
+
}
|
|
204
|
+
return proof;
|
|
205
|
+
}
|
|
206
|
+
function syncDir(dir){const fd=openSync(dir,'r');try{fsyncSync(fd);}finally{closeSync(fd);}}
|
|
207
|
+
function mkdirPinned(c,path){
|
|
208
|
+
noLinks(anchor(path),path);const missing=[];let at=path;while(!existsSync(at)){missing.push(at);at=dirname(at);}
|
|
209
|
+
for(const dir of missing.reverse()){mkdirSync(dir);syncDir(dirname(dir));c?.createdDirectories.push(dir);}
|
|
210
|
+
}
|
|
211
|
+
function atomic(path,bytes,mode=0o600,expected=undefined){
|
|
212
|
+
const tmp=join(dirname(path),`.conversation-${randomUUID()}.tmp`);let fd;
|
|
213
|
+
try{fd=openSync(tmp,constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|constants.O_NOFOLLOW,mode);writeFileSync(fd,bytes);fsyncSync(fd);closeSync(fd);fd=undefined;
|
|
214
|
+
if(hash(readFileSync(tmp))!==hash(bytes))refuse('ASSET_HASH_MISMATCH');if(expected!==undefined&&digest(fileBytes(path))!==expected)refuse('RECOVERY_THIRD_STATE');renameSync(tmp,path);syncDir(dirname(path));
|
|
215
|
+
}finally{if(fd!==undefined)closeSync(fd);try{unlinkSync(tmp);}catch(e){if(e.code!=='ENOENT')throw e;}}
|
|
216
|
+
}
|
|
217
|
+
function journalPath(c,id){return join(c.journalDir,hash(c.gitCommonDir??c.transcripts)+'-'+id+'.json');}
|
|
218
|
+
const journalDigest=j=>hash(Buffer.from(stable({api_version:j.api_version,sequence:j.sequence,revision_id:j.revision_id,kind:j.kind,root:j.root,transcripts:j.transcripts,git_common_dir:j.git_common_dir,file:j.file,spec:j.spec,spec_sha256:j.spec_sha256,operations:j.operations,source_pins:j.source_pins,snapshots:j.snapshots,protection_before:j.protection_before,protection_after:j.protection_after,reviewed_input_base64:j.reviewed_input_base64,created_directories:j.planned_directories,backup_proofs:j.backup_proofs,turn_ids:j.turn_ids,review_before:j.review_before,review_after:j.review_after,appended_log:j.appended_log})));
|
|
219
|
+
function saveJournal(j){atomic(j.journal_path,Buffer.from(JSON.stringify(j)+'\n'));chmodSync(j.journal_path,0o600);}
|
|
220
|
+
function readJournal(c,id){
|
|
221
|
+
const path=journalPath(c,id),bytes=fileBytes(path);if(bytes===null)return null;let j;try{j=JSON.parse(bytes);}catch{refuse('JOURNAL_INVALID');}
|
|
222
|
+
if(j.api_version!==1||j.revision_id!==id||j.journal_path!==path||j.root!==c.root||j.transcripts!==c.transcripts||j.git_common_dir!==c.gitCommonDir||j.file!==c.file||j.integrity_sha256!==journalDigest(j)||j.spec_sha256!==hash(Buffer.from(stable(j.spec))))refuse('JOURNAL_IDENTITY_MISMATCH');
|
|
223
|
+
if(!Number.isSafeInteger(j.sequence)||j.sequence<1||!['create','annotate','revise'].includes(j.kind)||!['applying','complete','reversed'].includes(j.state)||!Array.isArray(j.operations)||!Array.isArray(j.source_pins)||!Array.isArray(j.snapshots)||!Array.isArray(j.planned_directories)||!Array.isArray(j.createdDirectories)||j.createdDirectories.some(p=>!j.planned_directories.includes(p))||j.planned_directories.some(p=>!inside(c.transcripts,p)))refuse('JOURNAL_INVALID');
|
|
224
|
+
for(const op of [...j.operations,...j.snapshots]){if(!inside(c.folder,op.path)||op.path===c.folder)refuse('JOURNAL_INVALID');noLinks(c.folder,op.path);for(const which of ['before','after'])if(digest(op[which+'_base64']===null?null:Buffer.from(op[which+'_base64'],'base64'))!==op[which+'_sha256'])refuse('JOURNAL_INVALID');}
|
|
225
|
+
return j;
|
|
226
|
+
}
|
|
227
|
+
function journals(c){
|
|
228
|
+
if(!existsSync(c.journalDir))return [];noLinks(anchor(c.journalDir),c.journalDir);const prefix=hash(c.gitCommonDir??c.transcripts)+'-';const out=[];
|
|
229
|
+
for(const name of readdirSync(c.journalDir).filter(n=>n.startsWith(prefix)&&n.endsWith('.json'))){let raw;try{raw=JSON.parse(fileBytes(join(c.journalDir,name)));}catch{refuse('JOURNAL_INVALID');}if(raw.file===c.file)out.push(readJournal(c,raw.revision_id));}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
function noIncomplete(c,except=null){if(journals(c).some(j=>j.revision_id!==except&&j.state==='applying'))refuse('REVISION_INCOMPLETE');}
|
|
233
|
+
const turnIdSet=ids=>Array.isArray(ids)&&ids.every(id=>typeof id==='string'&&/^t-\d{6,}$/.test(id))&&equal(ids,[...new Set(ids)].sort());
|
|
234
|
+
function historyReferences(record){
|
|
235
|
+
const refs=[],section=record.sections.find(s=>s.name==='Log');if(!section)return refs;
|
|
236
|
+
let fence=null;
|
|
237
|
+
for(const line of section.bytes.toString('utf8').split(/\r?\n/)){
|
|
238
|
+
if(fence){if(new RegExp(`^ {0,3}${fence.char}{${fence.length},}\\s*$`).test(line))fence=null;continue;}
|
|
239
|
+
// Quoted and indented code bytes are examples, never actual Log evidence.
|
|
240
|
+
if(/^(?: {0,3}>| {4}| {0,3}\t)/.test(line))continue;
|
|
241
|
+
const opening=/^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
242
|
+
if(opening&&!(opening[1][0]==='`'&&opening[2].includes('`'))){fence={char:opening[1][0],length:opening[1].length};continue;}
|
|
243
|
+
const marker=line.indexOf('<!-- revision ');if(marker<0)continue;
|
|
244
|
+
// Ordinary prose/inline-code markers are not structured revision entries.
|
|
245
|
+
// Bare comments and revision-entry candidates still fail closed if malformed.
|
|
246
|
+
// A dated revision label is a candidate regardless of its separator spelling.
|
|
247
|
+
// Inspect only the lead before metadata; its own "revision" is not a label.
|
|
248
|
+
const lead=line.slice(0,marker);
|
|
249
|
+
if(!/^ {0,3}<!-- revision /.test(line)&&!/^ {0,3}(?:[-+*]|\d+[.)])[ \t]+(?:\d{4}-\d{2}-\d{2}[^`]*?revision\b|[^`]*?-[ \t]+revision\b)/.test(lead))continue;
|
|
250
|
+
const entry=/^- (\d{4}-\d{2}-\d{2}) - revision ([A-Za-z0-9_-]{8,128}): (.*?) <!-- revision (\{.*\}) -->[ \t]*$/.exec(line);
|
|
251
|
+
if(!entry)refuse('REVIEW_PROTECTION_UNRESOLVED');
|
|
252
|
+
// The existing scoped codec rejects duplicate/prototype keys and lossy JSON.
|
|
253
|
+
const decoded=parseConversationHeader(Buffer.from('---\nextensions: '+entry[4]+'\n---\n')),ref=decoded.header?.extensions;
|
|
254
|
+
const keys=['revision_id','evidence','turn_ids','prior_review'];if(ref&&Object.hasOwn(ref,'protected_turn_ids'))keys.push('protected_turn_ids');
|
|
255
|
+
// As in turn decoding, the syntax-only envelope omits required core fields;
|
|
256
|
+
// a refused syntax decode has no header, and this object's own shape follows.
|
|
257
|
+
if(!decoded.header||!exact(ref,keys)||ref.revision_id!==entry[2]||!turnIdSet(ref.turn_ids)||Object.hasOwn(ref,'protected_turn_ids')&&!turnIdSet(ref.protected_turn_ids))refuse('REVIEW_PROTECTION_UNRESOLVED');
|
|
258
|
+
const log={date:entry[1],text:entry[3],evidence:ref.evidence};
|
|
259
|
+
try{validLog(log,record.header);if(errors(validateConversation({...record.header,review:ref.prior_review})).length)throw new Error('invalid review');}catch{refuse('REVIEW_PROTECTION_UNRESOLVED');}
|
|
260
|
+
refs.push({ref,log});
|
|
261
|
+
}
|
|
262
|
+
return refs;
|
|
263
|
+
}
|
|
264
|
+
function historyProtection(record,c){
|
|
265
|
+
const state=record.header.extensions.conversation_revision,local=journals(c);
|
|
266
|
+
const covered=id=>state?.protected_turns.some(p=>p.turn_id===id&&p.reviewed_versions.length>0);
|
|
267
|
+
for(const j of local){
|
|
268
|
+
const historical=j.protection_after?.protected_turns??[];
|
|
269
|
+
if(j.state==='reversed')continue;
|
|
270
|
+
if(historical.some(p=>!state?.protected_turns.some(now=>now.turn_id===p.turn_id&&p.reviewed_versions.every(v=>now.reviewed_versions.some(n=>equal(n,v))))))refuse('REVIEW_PROTECTION_UNRESOLVED');
|
|
271
|
+
}
|
|
272
|
+
for(const {ref,log} of historyReferences(record)){
|
|
273
|
+
if(Object.hasOwn(ref,'protected_turn_ids')){
|
|
274
|
+
if(ref.protected_turn_ids.some(id=>!covered(id)))refuse('REVIEW_PROTECTION_UNRESOLVED');
|
|
275
|
+
}else if(['transcription','attribution'].some(d=>ref.prior_review[d]==='human-reviewed')){
|
|
276
|
+
// Affected ids cannot prove an old complete set: require its owned exact journal.
|
|
277
|
+
const owned=local.find(j=>j.state!=='reversed'&&j.revision_id===ref.revision_id&&equal(j.review_before,ref.prior_review)&&equal(j.turn_ids,ref.turn_ids)&&equal(j.spec.log,log));
|
|
278
|
+
if(!owned)refuse('REVIEW_PROTECTION_UNRESOLVED');
|
|
279
|
+
const entry=owned.operations.find(o=>o.path===c.file);if(!entry?.before_base64)refuse('REVIEW_PROTECTION_UNRESOLVED');
|
|
280
|
+
const prior=validRecord(Buffer.from(entry.before_base64,'base64'),c.file);
|
|
281
|
+
if(prior.turns.some(t=>!covered(t.id)))refuse('REVIEW_PROTECTION_UNRESOLVED');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function pinFile(path,before,after){return {path,before_base64:before===null?null:before.toString('base64'),before_sha256:digest(before),after_base64:after===null?null:after.toString('base64'),after_sha256:digest(after),mode:existsSync(path)?lstatSync(path).mode&0o777:0o600};}
|
|
286
|
+
function sidecars(c){
|
|
287
|
+
const out=[];const visit=dir=>{if(!existsSync(dir))return;noLinks(c.folder,dir);for(const name of readdirSync(dir)){const path=join(dir,name);noLinks(c.folder,path);if(lstatSync(path).isDirectory())visit(path);else out.push(path);}};
|
|
288
|
+
const ann=join(c.folder,'annotations.md');if(existsSync(ann))out.push(ann);visit(join(c.folder,'derived'));visit(join(c.folder,'sources'));return out;
|
|
289
|
+
}
|
|
290
|
+
function pinSources(j){for(const pin of j.source_pins){noLinks(realpathSync(dirname(pin.path)),pin.path);const bytes=fileBytes(pin.path);if(digest(bytes)!==pin.sha256)refuse('ASSET_HASH_MISMATCH');}}
|
|
291
|
+
function checkStates(c,j){
|
|
292
|
+
recheckContext(c);for(const op of [...j.operations,...j.snapshots]){noLinks(c.folder,op.path);const d=digest(fileBytes(op.path));if(d!==op.before_sha256&&d!==op.after_sha256)refuse('RECOVERY_THIRD_STATE');}
|
|
293
|
+
pinSources(j);
|
|
294
|
+
}
|
|
295
|
+
function result(j,changed,recovered=null){const entry=j.operations.find(o=>o.path===j.file);return {path:j.file,revision_id:j.revision_id,changed,before_sha256:entry.before_sha256??hash(Buffer.alloc(0)),after_sha256:recovered==='reverse'?(entry.before_sha256??hash(Buffer.alloc(0))):entry.after_sha256,turn_ids:j.turn_ids,journal_path:j.journal_path,recovered};}
|
|
296
|
+
function runJournal(c,j,action,afterOperation){
|
|
297
|
+
checkStates(c,j);const ops=action==='reverse'?[...j.operations].reverse():j.operations;
|
|
298
|
+
for(const op of ops){
|
|
299
|
+
checkStates(c,j);noLinks(c.folder,op.path);const d=digest(fileBytes(op.path));if(d!==op.before_sha256&&d!==op.after_sha256)refuse('RECOVERY_THIRD_STATE');
|
|
300
|
+
const which=action==='reverse'?'before':'after';if(d===op[which+'_sha256'])continue;
|
|
301
|
+
const bytes=op[which+'_base64']===null?null:Buffer.from(op[which+'_base64'],'base64');
|
|
302
|
+
if(bytes===null){unlinkSync(op.path);syncDir(dirname(op.path));}else{mkdirPinned(j,dirname(op.path));atomic(op.path,bytes,op.mode,d);}
|
|
303
|
+
j.completed++;saveJournal(j);afterOperation?.(clone(op),clone(j));
|
|
304
|
+
}
|
|
305
|
+
for(const op of [...j.operations,...j.snapshots])if(digest(fileBytes(op.path))!==op[(action==='reverse'?'before':'after')+'_sha256'])refuse('RECOVERY_THIRD_STATE');
|
|
306
|
+
if(action==='reverse')for(const dir of [...j.planned_directories].sort((a,b)=>b.length-a.length)){try{rmdirSync(dir);}catch(e){if(!['ENOENT','ENOTEMPTY','EEXIST'].includes(e.code))throw e;}}
|
|
307
|
+
j.state=action==='reverse'?'reversed':'complete';saveJournal(j);return result(j,true,action);
|
|
308
|
+
}
|
|
309
|
+
function transaction(c,{id,kind,spec,before,after,writes=[],assets=[],protectionBefore=null,protectionAfter=null,reviewedInput=null,turnIds=[],line=null,proof=[]},afterOperation){
|
|
310
|
+
recheckContext(c);noIncomplete(c);const priorJournals=journals(c);const existing=readJournal(c,id);if(existing)refuse('REVISION_ID_COLLISION');
|
|
311
|
+
const operations=[...writes.map(w=>pinFile(w.path,fileBytes(w.path),w.bytes)),pinFile(c.file,before,after)].filter((o,i,a)=>a.findIndex(p=>p.path===o.path)===i);
|
|
312
|
+
const changedPaths=new Set(operations.map(o=>o.path));
|
|
313
|
+
const snapshots=sidecars(c).filter(path=>!changedPaths.has(path)).map(path=>{const b=fileBytes(path);return pinFile(path,b,b);});
|
|
314
|
+
const sequence=Math.max(0,...priorJournals.map(j=>j.sequence))+1;
|
|
315
|
+
const plannedDirectories=new Set();for(const op of operations){let at=dirname(op.path);while(!existsSync(at)){plannedDirectories.add(at);at=dirname(at);}}
|
|
316
|
+
const sourcePins=[...assets.map(a=>({path:a.from,sha256:a.sha256})),...proof.flatMap(p=>[{path:p.original_path,sha256:p.sha256},{path:p.locator,sha256:p.sha256}])];
|
|
317
|
+
const j={api_version:1,sequence,planned_directories:[...plannedDirectories],revision_id:id,kind,root:c.root,transcripts:c.transcripts,git_common_dir:c.gitCommonDir,lock_scope:c.scope,file:c.file,spec:clone(spec),spec_sha256:hash(Buffer.from(stable(spec))),operations,source_pins:sourcePins,snapshots,protection_before:clone(protectionBefore),protection_after:clone(protectionAfter),reviewed_input_base64:reviewedInput?.toString('base64')??null,backup_proofs:proof,turn_ids:turnIds,review_before:before===null?null:parseConversation(before,{file:c.file}).header.review,review_after:parseConversation(after,{file:c.file}).header.review,appended_log:line,state:'applying',completed:0,createdDirectories:[],journal_path:journalPath(c,id)};
|
|
318
|
+
j.integrity_sha256=journalDigest(j);mkdirPinned(null,c.journalDir);saveJournal(j);
|
|
319
|
+
try{if(kind==='create'){mkdirSync(c.folder);j.createdDirectories.push(c.folder);syncDir(dirname(c.folder));saveJournal(j);}
|
|
320
|
+
runJournal(c,j,'resume',afterOperation);return result(j,operations.some(o=>o.before_sha256!==o.after_sha256));}
|
|
321
|
+
catch(e){if(['RECOVERY_THIRD_STATE','TARGET_MISMATCH','SYMLINK_REFUSED'].includes(e.code))throw e;refuse('REVISION_INCOMPLETE',{revisionId:id,journalPath:j.journal_path});}
|
|
322
|
+
}
|
|
323
|
+
/** The parent Conversation's policy is authoritative for its entry, captures and annotations. */
|
|
324
|
+
export function assertConversationProjection(file,{target,resolveGroup,read}={}){
|
|
325
|
+
const record=readConversation(file,read?{read}:{}),decision=canProjectConversation(record,target,{resolveGroup});
|
|
326
|
+
if(!decision.allowed)refuse('PRIVACY_REFUSED',{decision});return decision;
|
|
327
|
+
}
|
|
328
|
+
export function createConversation(options){
|
|
329
|
+
const {root,spec,pid,by}=options;
|
|
330
|
+
if(!exact(spec,['header','body','assets','annotations'])||typeof spec.body!=='string'||spec.annotations!==null&&typeof spec.annotations!=='string')refuse('CREATE_SPEC_INVALID');
|
|
331
|
+
const c0=conversationLockContext(root);const file=join(c0.transcripts,String(spec.header?.id),'transcript.md');
|
|
332
|
+
let bytes=serializeConversationHeader(spec.header,Buffer.from(spec.body));validRecord(bytes,file,true,true);
|
|
333
|
+
const c=contextForFile(file,root);const pre=validRecord(bytes,file,true,true);let state=protection(pre,c);
|
|
334
|
+
if(state.protected_turns.length){const h=clone(pre.header);h.extensions.conversation_revision=state;bytes=serializeConversationHeader(h,pre.body);validRecord(bytes,file,true,true);}
|
|
335
|
+
if(pre.header.body_format==='turns-v1'){const h=clone(pre.header);if(state.protected_turns.length)h.extensions.conversation_revision=state;bytes=serializeConversationHeader(h,renderGaps(pre));validRecord(bytes,file,true);}
|
|
336
|
+
return withConversationLock(c.root,{pid,by},()=>{
|
|
337
|
+
recheckContext(c);noIncomplete(c);const current=fileBytes(file),assets=assetsFor(c,pre.header,spec.assets);
|
|
338
|
+
const proof=backupProof(c,pre.header,current===null?null:validRecord(current,c.file,false,true),{...options,assets});
|
|
339
|
+
if(current!==null){
|
|
340
|
+
const annotation=fileBytes(join(c.folder,'annotations.md'));
|
|
341
|
+
const named=new Set([c.file,...assets.map(a=>a.path),...(spec.annotations===null?[]:[join(c.folder,'annotations.md')])]);
|
|
342
|
+
if(sidecars(c).some(path=>!named.has(path)))refuse('CREATE_COLLISION');
|
|
343
|
+
if(!current.equals(bytes)||digest(annotation)!==digest(spec.annotations===null?null:Buffer.from(spec.annotations))||assets.some(a=>digest(fileBytes(a.path))!==hash(a.bytes)))refuse('CREATE_COLLISION');
|
|
344
|
+
return {path:file,created:false,fileSha256:hash(current)};
|
|
345
|
+
}
|
|
346
|
+
if(existsSync(c.folder))refuse('CREATE_COLLISION');historyProtection(pre,c);proveParticipants(c,pre.header);noCaptureCollisions(c,pre.header);
|
|
347
|
+
const writes=assets.map(a=>({path:a.path,bytes:a.bytes}));if(spec.annotations!==null)writes.push({path:join(c.folder,'annotations.md'),bytes:Buffer.from(spec.annotations)});
|
|
348
|
+
const initialized=state.protected_turns.length>0;
|
|
349
|
+
transaction(c,{id:'create-'+hash(Buffer.from(stable(spec))).slice(0,32),kind:'create',spec,before:null,after:bytes,writes,assets,protectionAfter:initialized?state:null,reviewedInput:initialized?serializeConversationHeader(pre.header,pre.body):null,proof},options.afterOperation);
|
|
350
|
+
return {path:file,created:true,fileSha256:hash(bytes)};
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
function defaultVersionLoader(c){return (_repository,path,revision)=>{
|
|
354
|
+
if(revision.kind!=='sha256')throw new Error('unavailable');
|
|
355
|
+
const bytes=fileBytes(resolve(path));if(bytes!==null&&hash(bytes)===revision.value)return bytes;
|
|
356
|
+
for(const j of journals(c))for(const o of [...j.operations,...j.snapshots])if(o.path===resolve(path))for(const which of ['before','after'])if(o[which+'_sha256']===revision.value&&o[which+'_base64']!==null)return Buffer.from(o[which+'_base64'],'base64');
|
|
357
|
+
for(const j of journals(c))if(j.file===resolve(path)&&j.reviewed_input_base64!==null){const b=Buffer.from(j.reviewed_input_base64,'base64');if(hash(b)===revision.value)return b;}
|
|
358
|
+
throw new Error('unavailable');
|
|
359
|
+
};}
|
|
360
|
+
/** Resolve SHA-256 history through this workspace's existing validated native journals.
|
|
361
|
+
* Explicit bounds only; no locks, directory allocation, outputs or mutation. Git history is
|
|
362
|
+
* unsupported by this loader and never falls back to HEAD or the current entry.
|
|
363
|
+
*/
|
|
364
|
+
export function resolveConversationCitation({file,root,citation}={}) {
|
|
365
|
+
const fail=code=>({text:Buffer.alloc(0),diagnostics:[{code,severity:'error',path:null,field:null,message:'Bounded conversation citation could not be resolved.'}]});
|
|
366
|
+
if(!text(root)||!text(file)||citation?.repository!==null)return fail('CITATION_BOUNDS_INVALID');
|
|
367
|
+
if(typeof citation.path!=='string'||resolve(citation.path)!==resolve(file))return fail('CITATION_PATH_MISMATCH');
|
|
368
|
+
if(citation.revision?.kind!=='sha256')return fail('CITATION_VERSION_UNAVAILABLE');
|
|
369
|
+
let c;try{c=contextForFile(file,root);}catch(e){return fail(e.code||'CITATION_BOUNDS_INVALID');}
|
|
370
|
+
const current=readConversation(c.file);
|
|
371
|
+
if(errors(current.diagnostics).length||current.header?.kind!=='conversation')return fail('CITATION_RECORD_INVALID');
|
|
372
|
+
if(current.id!==citation.record_id)return fail('CITATION_RECORD_MISMATCH');
|
|
373
|
+
return resolveCitation(citation,{readVersion:defaultVersionLoader(c)});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function annotateConversation(options){
|
|
377
|
+
const {file,spec,expectedSha,pid,by}=options,c=contextForFile(file,options.root);
|
|
378
|
+
return withConversationLock(c.root,{pid,by},()=>{
|
|
379
|
+
recheckContext(c);noIncomplete(c);const before=fileBytes(c.file);if(before===null)refuse('NOT_FOUND');if(!sha(expectedSha)||hash(before)!==expectedSha)refuse('STALE_PIN');const record=validRecord(before,c.file);
|
|
380
|
+
const pinned=object(spec)&&Object.hasOwn(spec,'expected_annotations_sha256');
|
|
381
|
+
if(!exact(spec,['markdown','citations','processing','log',...(pinned?['expected_annotations_sha256']:[])])||!text(spec.markdown)||!Array.isArray(spec.citations)||!['processed',null].includes(spec.processing)||pinned&&spec.expected_annotations_sha256!==null&&!sha(spec.expected_annotations_sha256))refuse('ANNOTATE_SPEC_INVALID');validLog(spec.log,record.header);
|
|
382
|
+
const annotations=join(c.folder,'annotations.md'),old=fileBytes(annotations);
|
|
383
|
+
// A historical citation proves old selected words, not the current action
|
|
384
|
+
// snapshot. Validate the independent live pin under the same writer lock,
|
|
385
|
+
// before citation loading, protection initialization or journal creation.
|
|
386
|
+
if(pinned&&digest(old)!==spec.expected_annotations_sha256)refuse('STALE_PIN');
|
|
387
|
+
for(const citation of spec.citations){const resolved=resolveCitation(citation,{readVersion:options.readVersion??defaultVersionLoader(c)});if(errors(resolved.diagnostics).length)refuse(resolved.diagnostics[0].code);}
|
|
388
|
+
historyProtection(record,c);const state=protection(record,c),h=clone(record.header);h.extensions.conversation_revision=state;if(spec.processing!==null)h.review.processing=spec.processing;
|
|
389
|
+
const id='annotate-'+hash(Buffer.from(stable({expectedSha,spec}))).slice(0,32),line=logLine(id,spec.log,[],record.header.review,state);
|
|
390
|
+
const body=appendLog(record,line),after=serializeConversationHeader(h,body);validRecord(after,c.file);
|
|
391
|
+
const addition=Buffer.from(`\n## Generated interpretation\n\n${spec.markdown}${spec.markdown.endsWith('\n')?'':'\n'}\n<!-- citations ${stable(spec.citations)} -->\n`);
|
|
392
|
+
const j=transaction(c,{id,kind:'annotate',spec,before,after,writes:[{path:annotations,bytes:Buffer.concat([old??Buffer.alloc(0),addition])}],protectionBefore:record.header.extensions.conversation_revision??null,protectionAfter:state,reviewedInput:record.header.review.transcription==='human-reviewed'||record.header.review.attribution==='human-reviewed'?before:null,line},options.afterOperation);
|
|
393
|
+
return {path:c.file,fileSha256:j.after_sha256};
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const operationKeys={
|
|
398
|
+
'retitle':['title'],'map-participant':['participant_id','person','status','evidence'],
|
|
399
|
+
'add-participant':['participant'],'add-capture':['capture','assets'],
|
|
400
|
+
'add-capture-locator':['capture_id','locator','asset','evidence'],
|
|
401
|
+
'append-turns':['after_turn_id','turns'],'revise-turns':['changes'],
|
|
402
|
+
'retire-turns':['turn_ids','replacement_turn_ids','evidence']
|
|
403
|
+
};
|
|
404
|
+
function validRevision(spec,expected,h){
|
|
405
|
+
if(!exact(spec,['schema_version','revision_id','base_sha256','mode','authorization','operations','log'])||spec.schema_version!==1||!token(spec.revision_id)||!sha(spec.base_sha256)||!['routine','deliberate-transcript-revision'].includes(spec.mode)||!Array.isArray(spec.operations)||!spec.operations.length)refuse('REVISION_SPEC_INVALID');
|
|
406
|
+
if(expected!==spec.base_sha256)refuse('STALE_PIN');validLog(spec.log,h);
|
|
407
|
+
for(const op of spec.operations){if(!object(op)||!Object.hasOwn(operationKeys,op.op)||!exact(op,['op',...operationKeys[op.op]]))refuse('REVISION_SPEC_INVALID');}
|
|
408
|
+
if(spec.mode==='routine'&&spec.authorization!==null)refuse('REVISION_AUTHORIZATION_MISMATCH');
|
|
409
|
+
if(spec.mode==='deliberate-transcript-revision'){
|
|
410
|
+
const a=spec.authorization;if(!exact(a,['decision','base_sha256','turn_ids'])||a.decision?.kind!=='operator-decision'||a.base_sha256!==expected||!Array.isArray(a.turn_ids)||a.turn_ids.some(id=>typeof id!=='string'||!/^t-\d{6,}$/.test(id))||!equal(a.turn_ids,[...new Set(a.turn_ids)].sort()))refuse('REVISION_AUTHORIZATION_MISMATCH');
|
|
411
|
+
evidenceValid(a.decision,h);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function buildRevision(c,record,spec){
|
|
415
|
+
historyProtection(record,c);const originalState=record.header.extensions.conversation_revision??null;
|
|
416
|
+
const state=protection(record,c),h=clone(record.header),turns=record.turns.map(inputTurn),assets=[],retirements=[],changedIds=new Set(),protectedAffected=new Set();
|
|
417
|
+
let changed=false,transcription=false,attribution=false,processing=false;
|
|
418
|
+
const protectedOriginal=new Map(state.protected_turns.filter(p=>!p.retired).map(p=>[p.turn_id,p]));
|
|
419
|
+
const touchProtected=()=>{
|
|
420
|
+
for(const [id,p] of protectedOriginal){const t=turns.find(t=>t.id===id);if(!t||semantic({header:h},t)!==p.baseline_sha256)protectedAffected.add(id);}
|
|
421
|
+
};
|
|
422
|
+
const find=(list,id,code)=>{const value=list.find(v=>v.id===id);if(!value)refuse(code);return value;};
|
|
423
|
+
for(const op of spec.operations){
|
|
424
|
+
switch(op.op){
|
|
425
|
+
case 'retitle':{
|
|
426
|
+
if(!text(op.title)||/[\r\n]/.test(op.title))refuse('REVISION_SPEC_INVALID');if(h.title!==op.title){h.title=op.title;changed=true;}break;
|
|
427
|
+
}
|
|
428
|
+
case 'map-participant':{
|
|
429
|
+
evidences(op.evidence,h);const p=find(h.participants,op.participant_id,'PARTICIPANT_NOT_FOUND');
|
|
430
|
+
if(!['unknown','proposed','confirmed'].includes(op.status)||op.person!==null&&!text(op.person))refuse('REVISION_SPEC_INVALID');
|
|
431
|
+
if(p.person!==op.person||p.status!==op.status||!equal(p.evidence,op.evidence)){
|
|
432
|
+
const identityChanged=p.person!==op.person||p.status!==op.status;
|
|
433
|
+
p.person=op.person;p.status=op.status;p.evidence=clone(op.evidence);changed=true;processing=true;if(identityChanged)attribution=true;
|
|
434
|
+
for(const t of turns.filter(t=>t.speaker===p.id))changedIds.add(t.id);
|
|
435
|
+
}break;
|
|
436
|
+
}
|
|
437
|
+
case 'add-participant':{
|
|
438
|
+
if(!object(op.participant))refuse('REVISION_SPEC_INVALID');const prior=h.participants.find(p=>p.id===op.participant.id);
|
|
439
|
+
if(prior){if(!equal(prior,op.participant))refuse('PARTICIPANT_ID_COLLISION');}
|
|
440
|
+
else{h.participants.push(clone(op.participant));changed=true;processing=true;}break;
|
|
441
|
+
}
|
|
442
|
+
case 'add-capture':{
|
|
443
|
+
if(!object(op.capture)||!Array.isArray(op.assets))refuse('REVISION_SPEC_INVALID');const prior=h.sources.find(s=>s.id===op.capture.id);
|
|
444
|
+
if(prior){if(!equal(prior,op.capture))refuse('CAPTURE_ID_COLLISION');}
|
|
445
|
+
else{const identity=captureIdentity(op.capture),collision=h.sources.find(s=>identity!==null&&captureIdentity(s)===identity);if(collision)refuse('CAPTURE_IDENTITY_COLLISION',{captureId:collision.id});h.sources.push(clone(op.capture));changed=true;processing=true;}
|
|
446
|
+
assets.push(...op.assets);break;
|
|
447
|
+
}
|
|
448
|
+
case 'add-capture-locator':{
|
|
449
|
+
evidences(op.evidence,h);const cap=find(h.sources,op.capture_id,'CAPTURE_NOT_FOUND');if(!object(op.locator))refuse('REVISION_SPEC_INVALID');
|
|
450
|
+
const path=op.locator.kind==='bundled'?op.locator.path:null;
|
|
451
|
+
if(path&&cap.locators.some(l=>l.kind==='bundled'&&l.path===path&&!equal(l,op.locator)))refuse('ASSET_PATH_COLLISION');
|
|
452
|
+
if(!cap.locators.some(l=>equal(l,op.locator))){cap.locators.push(clone(op.locator));cap.evidence.push(...clone(op.evidence));changed=true;}
|
|
453
|
+
if(op.asset!==null)assets.push(op.asset);break;
|
|
454
|
+
}
|
|
455
|
+
case 'append-turns':{
|
|
456
|
+
if(!Array.isArray(op.turns)||!op.turns.length||op.after_turn_id!==null&&!turns.some(t=>t.id===op.after_turn_id))refuse('REVISION_SPEC_INVALID');
|
|
457
|
+
let position=op.after_turn_id===null?turns.length:turns.findIndex(t=>t.id===op.after_turn_id)+1;
|
|
458
|
+
for(const t of op.turns){
|
|
459
|
+
if(!exact(t,['id','speaker','sources','timing','source_gap','text'])||typeof t.id!=='string'||!/^t-\d{6,}$/.test(t.id)||typeof t.text!=='string')refuse('REVISION_SPEC_INVALID');
|
|
460
|
+
if(state.retired_turn_ids.includes(t.id))refuse('TURN_ID_RETIRED');const prior=turns.find(p=>p.id===t.id);
|
|
461
|
+
if(prior){if(!equal(prior,t))refuse('TURN_ID_COLLISION');continue;}
|
|
462
|
+
const n=Number(t.id.slice(2));if(!Number.isSafeInteger(n)||n<state.next_turn_number)refuse('TURN_ALLOCATION_INVALID');
|
|
463
|
+
state.next_turn_number=n+1;turns.splice(position++,0,clone(t));changedIds.add(t.id);changed=true;transcription=true;attribution=true;processing=true;
|
|
464
|
+
}break;
|
|
465
|
+
}
|
|
466
|
+
case 'revise-turns':{
|
|
467
|
+
if(!Array.isArray(op.changes)||!op.changes.length||new Set(op.changes.map(v=>v.turn_id)).size!==op.changes.length)refuse('REVISION_SPEC_INVALID');
|
|
468
|
+
for(const ch of op.changes){
|
|
469
|
+
if(!exact(ch,['turn_id','before_text_sha256','speaker','sources','timing','source_gap','text','evidence'])||!sha(ch.before_text_sha256)||typeof ch.text!=='string')refuse('REVISION_SPEC_INVALID');evidences(ch.evidence,h);
|
|
470
|
+
const t=find(turns,ch.turn_id,'TURN_NOT_FOUND');if(hash(Buffer.from(t.text))!==ch.before_text_sha256)refuse('STALE_TURN_PIN');
|
|
471
|
+
const input={id:t.id,speaker:ch.speaker,sources:ch.sources,timing:ch.timing,source_gap:ch.source_gap,text:ch.text};
|
|
472
|
+
if(!equal(t,input)){
|
|
473
|
+
if(t.text!==ch.text)transcription=true;if(t.speaker!==ch.speaker||!equal(t.sources,ch.sources)||!equal(t.timing,ch.timing)||t.source_gap!==ch.source_gap)attribution=true;
|
|
474
|
+
Object.assign(t,clone(input));changedIds.add(t.id);changed=true;processing=true;
|
|
475
|
+
}
|
|
476
|
+
}break;
|
|
477
|
+
}
|
|
478
|
+
case 'retire-turns':{
|
|
479
|
+
if(spec.mode!=='deliberate-transcript-revision')refuse('REVIEWED_TRANSCRIPT_REVISION_REQUIRED');evidences(op.evidence,h);
|
|
480
|
+
if(!Array.isArray(op.turn_ids)||!op.turn_ids.length||!Array.isArray(op.replacement_turn_ids)||!op.replacement_turn_ids.length||new Set(op.turn_ids).size!==op.turn_ids.length||new Set(op.replacement_turn_ids).size!==op.replacement_turn_ids.length||op.replacement_turn_ids.some(id=>op.turn_ids.includes(id)))refuse('REVISION_SPEC_INVALID');
|
|
481
|
+
for(const id of op.turn_ids){find(turns,id,'TURN_NOT_FOUND');state.retired_turn_ids.push(id);changedIds.add(id);turns.splice(turns.findIndex(t=>t.id===id),1);}
|
|
482
|
+
retirements.push(clone(op));op.replacement_turn_ids.forEach(id=>changedIds.add(id));changed=true;transcription=true;attribution=true;processing=true;break;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
touchProtected(); // Evaluate before any review downgrade; also catches indirect mapping changes.
|
|
486
|
+
}
|
|
487
|
+
const affected=[...protectedAffected].sort();
|
|
488
|
+
if(affected.length&&spec.mode!=='deliberate-transcript-revision')refuse('REVIEWED_TRANSCRIPT_REVISION_REQUIRED');
|
|
489
|
+
if(spec.mode==='deliberate-transcript-revision'&&!equal(spec.authorization.turn_ids,affected))refuse('REVISION_AUTHORIZATION_MISMATCH');
|
|
490
|
+
if(transcription&&h.review.transcription!=='disputed')h.review.transcription='unreviewed';
|
|
491
|
+
if(attribution&&h.review.attribution!=='disputed')h.review.attribution='unreviewed';if(processing)h.review.processing='unprocessed';
|
|
492
|
+
for(const id of affected){
|
|
493
|
+
const p=state.protected_turns.find(p=>p.turn_id===id),t=turns.find(t=>t.id===id),next=t?semantic({header:h},t):null;
|
|
494
|
+
p.authorized_changes.push({revision_id:spec.revision_id,from_sha256:p.baseline_sha256,to_sha256:next,decision:clone(spec.authorization.decision)});
|
|
495
|
+
if(next===null)p.retired=true;else p.baseline_sha256=next;
|
|
496
|
+
}
|
|
497
|
+
for(const op of retirements)for(const id of op.replacement_turn_ids){
|
|
498
|
+
const t=find(turns,id,'TURN_NOT_FOUND'),origins=op.turn_ids.map(id=>state.protected_turns.find(p=>p.turn_id===id)).filter(Boolean);if(!origins.length)continue;
|
|
499
|
+
const baseline=semantic({header:h},t);let p=state.protected_turns.find(p=>p.turn_id===id);
|
|
500
|
+
if(!p){p={turn_id:id,baseline_sha256:baseline,reviewed_versions:[],authorized_changes:[],retired:false};state.protected_turns.push(p);}
|
|
501
|
+
for(const origin of origins){for(const v of origin.reviewed_versions)if(!p.reviewed_versions.some(old=>equal(old,v)))p.reviewed_versions.push(clone(v));p.authorized_changes.push({revision_id:spec.revision_id,from_sha256:origin.baseline_sha256,to_sha256:baseline,decision:clone(spec.authorization.decision)});}
|
|
502
|
+
}
|
|
503
|
+
const headerErrors=errors(validateConversation(h));if(headerErrors.length)refuse(headerErrors[0].code);
|
|
504
|
+
proveParticipants(c,h,record.header);
|
|
505
|
+
const copied=assetsFor(c,h,assets),proof=backupProof(c,h,record,{...spec._options,assets:copied});
|
|
506
|
+
if(copied.some(a=>digest(fileBytes(a.path))!==a.sha256))changed=true;
|
|
507
|
+
if(!changed)return {after:readFileSync(c.file),assets:copied,state:originalState,originalState,changedIds:[],line:null,proof};
|
|
508
|
+
h.extensions.conversation_revision=state;
|
|
509
|
+
let body=equal(turns,record.turns.map(inputTurn))?record.body:renderTranscript(record,turns);
|
|
510
|
+
body=retitleBody(record,h,body);
|
|
511
|
+
let proposed=validRecord(serializeConversationHeader(h,body),c.file);
|
|
512
|
+
const line=logLine(spec.revision_id,spec.log,[...changedIds].sort(),record.header.review,state);body=appendLog(proposed,line);
|
|
513
|
+
const after=serializeConversationHeader(h,body);proposed=validRecord(after,c.file);noCaptureCollisions(c,h);
|
|
514
|
+
// Reparse the emitted bytes: all semantic baselines must match actual reader slices.
|
|
515
|
+
protection(proposed,c);
|
|
516
|
+
return {after,assets:copied,state,originalState,changedIds:[...changedIds].sort(),line,proof};
|
|
517
|
+
}
|
|
518
|
+
export function reviseConversation(options){
|
|
519
|
+
const {file,spec,expectedSha,pid,by}=options,c=contextForFile(file,options.root);
|
|
520
|
+
return withConversationLock(c.root,{pid,by},()=>{
|
|
521
|
+
recheckContext(c);const before=fileBytes(c.file);if(before===null)refuse('NOT_FOUND');const record=validRecord(before,c.file);
|
|
522
|
+
validRevision(spec,expectedSha,record.header);
|
|
523
|
+
const prior=readJournal(c,spec.revision_id);
|
|
524
|
+
if(prior){
|
|
525
|
+
if(prior.kind!=='revise'||prior.spec_sha256!==hash(Buffer.from(stable(spec))))refuse('REVISION_ID_COLLISION');
|
|
526
|
+
noIncomplete(c,spec.revision_id);
|
|
527
|
+
if(prior.state==='reversed'||journals(c).some(j=>j.sequence>prior.sequence&&j.state!=='reversed'))refuse('STALE_REVISION_RETRY');
|
|
528
|
+
if(prior.state==='applying')return runJournal(c,prior,'resume',options.afterOperation);
|
|
529
|
+
if([...prior.operations,...prior.snapshots].some(o=>digest(fileBytes(o.path))!==o.after_sha256))refuse('STALE_REVISION_RETRY');
|
|
530
|
+
return result(prior,false);
|
|
531
|
+
}
|
|
532
|
+
noIncomplete(c);if(record.fileSha256!==expectedSha)refuse('STALE_PIN');
|
|
533
|
+
// Capabilities stay outside the serialized canonical spec; no trust flag is accepted.
|
|
534
|
+
const working=clone(spec);Object.defineProperty(working,'_options',{value:options,enumerable:false});
|
|
535
|
+
const built=buildRevision(c,record,working);
|
|
536
|
+
return transaction(c,{id:spec.revision_id,kind:'revise',spec,before,after:built.after,writes:built.assets.map(a=>({path:a.path,bytes:a.bytes})),assets:built.assets,protectionBefore:built.originalState,protectionAfter:built.state,reviewedInput:['transcription','attribution'].some(d=>record.header.review[d]==='human-reviewed')?before:null,turnIds:built.changedIds,line:built.line,proof:built.proof},options.afterOperation);
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
export function recoverConversationRevision(options){
|
|
540
|
+
const {file,revisionId,action,pid,by}=options;if(!token(revisionId)||!['resume','reverse'].includes(action))refuse('RECOVERY_SPEC_INVALID');const c=contextForFile(file,options.root);
|
|
541
|
+
return withConversationLock(c.root,{pid,by},()=>{
|
|
542
|
+
recheckContext(c);const j=readJournal(c,revisionId);if(!j)refuse('NOT_FOUND');noIncomplete(c,revisionId);
|
|
543
|
+
if(j.state==='reversed'&&action==='resume')refuse('STALE_REVISION_RETRY');
|
|
544
|
+
return runJournal(c,j,action,options.afterOperation);
|
|
545
|
+
});
|
|
546
|
+
}
|