@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.
@@ -0,0 +1,88 @@
1
+ // Immutable selection and explicit timing only. No repository discovery, media IO or clock guessing.
2
+ import {createHash} from 'node:crypto';
3
+ import {resolve} from 'node:path';
4
+ import {isDeepStrictEqual} from 'node:util';
5
+ import {parseConversation} from './freedom-conversations.mjs';
6
+ import {parseConversationInstant,conversationCaptureOffsetMs} from './freedom-conversation-time.mjs';
7
+
8
+ const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
9
+ const sha=value=>typeof value==='string'&&/^[a-f0-9]{64}$/.test(value);
10
+ const nonempty=value=>typeof value==='string'&&!!value.trim();
11
+ const turnIds=ids=>Array.isArray(ids)&&ids.length>0&&ids.every(id=>typeof id==='string'&&/^t-\d{6,}$/.test(id));
12
+ const revisionValid=(r,repository)=>r&&Object.keys(r).sort().join(',')==='kind,value'&&(r.kind==='sha256'&&sha(r.value)||r.kind==='git'&&nonempty(repository)&&/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(r.value));
13
+ const diagnostic=code=>({code,severity:'error',path:null,field:null,message:'Citation or timing could not be resolved from the supplied version and explicit mapping.'});
14
+ const refuse=code=>{throw Object.assign(new Error('Conversation citation could not be created.'),{code});};
15
+ const blocked=record=>!record?.header||record.diagnostics.some(d=>d.severity==='error');
16
+ const selectedTurns=(record,ids)=>ids.map(id=>record.turns.find(t=>t.id===id));
17
+
18
+ export function citeConversation(record,{turnIds:ids,repository=null,revision={kind:'sha256',value:record.fileSha256}}={}) {
19
+ if(blocked(record)||record.header.body_format!=='turns-v1')refuse('CITATION_RECORD_INVALID');
20
+ if(!turnIds(ids))refuse('CITATION_SELECTOR_INVALID');
21
+ if(repository!==null&&!nonempty(repository)||!revisionValid(revision,repository))refuse('CITATION_REVISION_INVALID');
22
+ if(revision.kind==='sha256'&&revision.value!==record.fileSha256)refuse('CITATION_REVISION_MISMATCH');
23
+ const turns=selectedTurns(record,ids);if(turns.some(t=>!t))refuse('NOT_FOUND');
24
+ return {repository,path:record.file,record_id:record.id,revision:{...revision},selector:{kind:'turn',ids:[...ids],text_sha256:hash(Buffer.concat(turns.map(t=>t.text)))},sources:structuredClone(turns.flatMap(t=>t.sources))};
25
+ }
26
+
27
+ export function resolveCitation(citation,{readVersion}={}) {
28
+ const fail=code=>({text:Buffer.alloc(0),diagnostics:[diagnostic(code)]});
29
+ if(!citation||!nonempty(citation.path)||!nonempty(citation.record_id)||(citation.repository!==null&&!nonempty(citation.repository))||!revisionValid(citation.revision,citation.repository)||!sha(citation.selector?.text_sha256)||!Array.isArray(citation.sources))return fail('CITATION_INVALID');
30
+ let bytes;
31
+ try {bytes=readVersion(citation.repository,citation.path,citation.revision);}catch{return fail('CITATION_VERSION_UNAVAILABLE');}
32
+ if(!Buffer.isBuffer(bytes))return fail('CITATION_VERSION_UNAVAILABLE');
33
+ // A Git readVersion implementation must read this exact repository/path/commit, never HEAD.
34
+ if(citation.revision.kind==='sha256'&&hash(bytes)!==citation.revision.value)return fail('CITATION_REVISION_MISMATCH');
35
+ try {new TextDecoder('utf-8',{fatal:true}).decode(bytes);}catch{return fail('INVALID_UTF8');}
36
+ const selector=citation.selector;let text;
37
+ if(selector.kind==='lines') {
38
+ if(!Number.isSafeInteger(selector.start)||!Number.isSafeInteger(selector.end)||selector.start<1||selector.end<selector.start)return fail('CITATION_SELECTOR_INVALID');
39
+ const starts=[0];for(let i=0;i<bytes.length;i++)if(bytes[i]===10&&i+1<bytes.length)starts.push(i+1);
40
+ if(!bytes.length||selector.end>starts.length)return fail('CITATION_SELECTOR_INVALID');
41
+ text=bytes.subarray(starts[selector.start-1],starts[selector.end]??bytes.length);
42
+ }else if(selector.kind==='turn') {
43
+ if(!turnIds(selector.ids))return fail('CITATION_SELECTOR_INVALID');
44
+ const record=parseConversation(bytes,{file:citation.path});
45
+ if(blocked(record)||record.header.body_format!=='turns-v1')return fail('CITATION_RECORD_INVALID');
46
+ if(record.id!==citation.record_id)return fail('CITATION_RECORD_MISMATCH');
47
+ const turns=selectedTurns(record,selector.ids);if(turns.some(t=>!t))return fail('NOT_FOUND');
48
+ if(!isDeepStrictEqual(citation.sources,turns.flatMap(t=>t.sources)))return fail('CITATION_SOURCES_MISMATCH');
49
+ text=Buffer.concat(turns.map(t=>t.text));
50
+ }else return fail('CITATION_SELECTOR_INVALID');
51
+ if(hash(text)!==selector.text_sha256)return fail('CITATION_TEXT_MISMATCH');
52
+ return {text,diagnostics:[]};
53
+ }
54
+
55
+ // A selected locator supplies a path; an unknown locator never silently picks the first asset.
56
+ function candidatePaths(record,turn,captureId) {
57
+ const capture=record.header.sources.find(c=>c.id===captureId);
58
+ const refs=turn.sources.filter(s=>s.capture===captureId);
59
+ const paths=refs.map(ref=>{
60
+ const locator=ref.locator===null?null:capture.locators[ref.locator];
61
+ return locator?.kind==='bundled'?resolve(record.file,'..',locator.path):locator?.path??null;
62
+ });
63
+ return [...new Set(paths.length?paths:[null])];
64
+ }
65
+ export function seekConversation(record,{turnId,captureId}={}) {
66
+ const fail=code=>({candidates:[],gaps:[diagnostic(code)]});
67
+ if(blocked(record))return fail('CONVERSATION_INVALID');
68
+ const turn=record.turns.find(t=>t.id===turnId);if(!turn)return fail('NOT_FOUND');
69
+ const t=turn.timing;if(t===null)return fail('TIMING_UNRESOLVED');
70
+ let spans=[];
71
+ if(t.basis==='capture')spans=[{capture:t.capture,offset_ms:t.start_ms,end_ms:t.end_ms}];
72
+ if(t.basis==='conversation')spans=t.mapping.filter(m=>t.start_ms>=m.conversation_start_ms&&t.start_ms<m.conversation_end_ms).map(m=>({capture:m.capture,offset_ms:m.capture_start_ms+(t.start_ms-m.conversation_start_ms),end_ms:t.end_ms===null?null:m.capture_start_ms+(Math.min(t.end_ms,m.conversation_end_ms)-m.conversation_start_ms),partial:t.end_ms!==null&&t.end_ms>m.conversation_end_ms}));
73
+ if(t.basis==='wall-clock') {
74
+ const start=parseConversationInstant(t.start),end=parseConversationInstant(t.end);
75
+ spans=t.mapping.flatMap(m=>{
76
+ const anchor=parseConversationInstant(m.wall_start),offset=conversationCaptureOffsetMs(start,anchor,m.capture_start_ms);
77
+ if(offset.negative)return [];
78
+ return [{capture:m.capture,offset_ms:offset.value,end_ms:end===null?null:conversationCaptureOffsetMs(end,anchor,m.capture_start_ms).value}];
79
+ });
80
+ }
81
+ if(captureId!==undefined)spans=spans.filter(s=>s.capture===captureId);
82
+ const candidates=spans.flatMap(span=>candidatePaths(record,turn,span.capture).map(path=>({capture:span.capture,path,offset_ms:span.offset_ms,end_ms:span.end_ms})));
83
+ const gaps=[];
84
+ if(!candidates.length)gaps.push(diagnostic('TIMING_GAP'));
85
+ if(candidates.length>1)gaps.push(diagnostic(captureId===undefined?'CAPTURE_SELECTION_REQUIRED':'TIMING_MAPPING_AMBIGUOUS'));
86
+ if(spans.some(s=>s.partial))gaps.push(diagnostic('TIMING_PARTIAL_COVERAGE'));
87
+ return {candidates,gaps};
88
+ }
@@ -0,0 +1,167 @@
1
+ // Read-only compatibility projection. Original bytes, not this projection, are authoritative.
2
+ import {createHash} from 'node:crypto';
3
+ import {basename,resolve} from 'node:path';
4
+ import {parseFlat,stripTrailingComment} from './frontmatter/yaml.mjs';
5
+ import {validateConversation} from './frontmatter/conversation.mjs';
6
+
7
+ const hash=b=>createHash('sha256').update(b).digest('hex');
8
+ const text=v=>typeof v==='string'&&v.length>0?v:null;
9
+ const list=v=>Array.isArray(v)?v: text(v)?[v]:[];
10
+ const safeKey=k=>/^[a-z][a-z0-9_]*$/.test(k)&&!['__proto__','prototype','constructor'].includes(k);
11
+ const diag=(code,field,severity='error')=>({code,severity,path:null,field,message:'Legacy metadata requires an explicit resolution; original bytes are retained.'});
12
+ // Existing concat producers declare scalar notes. Preserve them as metadata only;
13
+ // sourcesFrom deliberately never uses note as locator, identity or coverage proof.
14
+ const sourceKeys=new Set(['type','path','url','id','role','service','recording_id','note']);
15
+ const roles=new Set(['transcript-body','metadata','generated-notes','supplemental','unknown']);
16
+ const service=s=>typeof s==='string'&&/^[a-z][a-z0-9-]*$/.test(s);
17
+
18
+ // The flat parser is deliberately string-typed. Screen syntax it otherwise accepts literally
19
+ // (maps, anchors, tags and block scalars) before handing it any value.
20
+ function scalar(raw) {
21
+ const s=stripTrailingComment(raw);
22
+ if(!s||/^[\[\]{}|>&*!%@`]/.test(s))throw new Error('unsupported scalar');
23
+ if(s[0]==='"'||s[0]==="'") {
24
+ const q=s[0];let end=-1;
25
+ for(let i=1;i<s.length;i++){if(q==='"'&&s[i]==='\\'){i++;continue;}if(s[i]===q){end=i;break;}}
26
+ if(end!==s.length-1)throw new Error('unsupported quote');
27
+ } else if(/:\s|\s[&*!][^\s]/.test(s))throw new Error('unsupported scalar');
28
+ return parseFlat(`value: ${s}`).value;
29
+ }
30
+ function flatBlock(key,lines) {
31
+ const raw=stripTrailingComment(lines[0].slice(lines[0].indexOf(':')+1));
32
+ if(raw.startsWith('[')) {
33
+ if(!raw.endsWith(']')||lines.slice(1).some(l=>l.trim()&&!l.trim().startsWith('#')))throw new Error('complex list');
34
+ const inner=raw.slice(1,-1);let q=null,part='',parts=[];
35
+ for(let i=0;i<inner.length;i++){const c=inner[i];if(q){part+=c;if(c==='\\'&&q==='"'){part+=inner[++i]??'';}else if(c===q)q=null;}else if(c==='"'||c==="'"){q=c;part+=c;}else if(c===','){parts.push(part);part='';}else part+=c;}
36
+ if(q)throw new Error('unclosed quote');
37
+ if(inner.trim()){parts.push(part);parts.forEach(scalar);}
38
+ } else if(raw) {
39
+ scalar(raw);if(lines.slice(1).some(l=>l.trim()&&!l.trim().startsWith('#')))throw new Error('continuation');
40
+ } else {
41
+ const items=lines.slice(1).filter(l=>l.trim()&&!l.trim().startsWith('#'));
42
+ const indents=new Set();
43
+ for(const line of items){const m=/^( +)- (.+)$/.exec(line);if(!m)throw new Error('nested');indents.add(m[1].length);scalar(m[2]);}
44
+ if(indents.size>1)throw new Error('indent');
45
+ }
46
+ return parseFlat(lines.join('\n'))[key];
47
+ }
48
+ function sourceBlock(lines) {
49
+ if(lines[0].slice(lines[0].indexOf(':')+1).trim())throw new Error('inline sources');
50
+ const items=[];let current=null,base=null,unknown=false;
51
+ for(const line of lines.slice(1)) {
52
+ if(!line.trim()||line.trim().startsWith('#'))continue;
53
+ const head=/^( +)- ([a-z][a-z0-9_]*):\s*(.*)$/.exec(line);
54
+ let key,raw;
55
+ if(head){if(base===null)base=head[1].length;if(head[1].length!==base)throw new Error('indent');current={};items.push(current);[, ,key,raw]=head;}
56
+ else {const m=/^( +)([a-z][a-z0-9_]*):\s*(.*)$/.exec(line);if(!current||!m||m[1].length!==base+2)throw new Error('source shape');[, ,key,raw]=m;}
57
+ if(!safeKey(key)||Object.hasOwn(current,key))throw new Error('source key');
58
+ const value=scalar(raw);current[key]=value;if(!sourceKeys.has(key))unknown=true;
59
+ }
60
+ return {items,unknown};
61
+ }
62
+ function fieldsFrom(rawHeader) {
63
+ const fields={},unparsed=new Set(),diagnostics=[];
64
+ if(rawHeader===null)return {fields,unparsed,diagnostics};
65
+ const blocks=[];let block=null;
66
+ for(const line of rawHeader.toString('utf8').split(/\r?\n/)) {
67
+ if(!line.trim()||line.trim().startsWith('#')){if(block)block.lines.push(line);continue;}
68
+ if(!/^\s/.test(line)){const m=/^([^:]+):/.exec(line);block={key:m?.[1]??'unknown',lines:[line]};blocks.push(block);}
69
+ else if(block)block.lines.push(line);else{unparsed.add('unknown');diagnostics.push(diag('LEGACY_FIELD_UNPARSED',null));}
70
+ }
71
+ const counts=new Map();for(const b of blocks)counts.set(b.key,(counts.get(b.key)??0)+1);
72
+ for(const b of blocks) {
73
+ const {key,lines}=b;
74
+ try {
75
+ if(!safeKey(key)||counts.get(key)>1)throw new Error('duplicate or unsafe');
76
+ if(key==='sources'){const s=sourceBlock(lines);fields[key]=s.items;if(s.unknown)unparsed.add(key);}
77
+ else fields[key]=flatBlock(key,lines);
78
+ }catch{unparsed.add(key);}
79
+ }
80
+ for(const key of unparsed)diagnostics.push(diag(key==='date'&&counts.get(key)>1?'DATE_CONFLICT':'LEGACY_FIELD_UNPARSED',safeKey(key)?key:null));
81
+ return {fields,unparsed,diagnostics};
82
+ }
83
+ const locator=patch=>({kind:'service',path:null,url:null,service_id:null,media_type:null,sha256:null,bytes:null,checked_at:null,backup:{status:'unverified',locator:null,sha256:null,verified_at:null},...patch});
84
+ const namespace=c=>c.type==='cloud_recording'?`cloud:${c.service}`:c.type==='zoom_cloud'&&/^GMT\d{8}-\d{6}$/.test(c.vendor_id??'')?'zoom-cloud-gmt':({granola:'granola',meet_cloud:'meet',voice_memo:'voice-memo'}[c.type]??c.type);
85
+
86
+ function sourcesFrom(fields,evidence=()=>[]) {
87
+ const captures=[],aliases=[],diagnostics=[];
88
+ const alias=(namespace,value)=>{if(namespace&&text(value)&&!aliases.some(a=>a.namespace===namespace&&a.value===value))aliases.push({kind:'source-id',namespace,value});};
89
+ const identity=c=>c.type==='cloud_recording'?c.recording_id:c.vendor_id;
90
+ function add(s,field,top=false) {
91
+ let type=text(s.type),vendor=text(s.id),svc=null,recording=null;
92
+ const url=text(s.url),path=text(s.path);
93
+ if(type==='cloud_recording') {
94
+ svc=text(s.service);recording=text(s.recording_id);
95
+ if(!service(svc)){diagnostics.push(diag('SOURCE_SERVICE_UNRESOLVED',field));return;}
96
+ if(!recording){diagnostics.push(diag('RECORDING_ID_UNRESOLVED',field));return;}
97
+ if(vendor&&vendor!==recording)diagnostics.push(diag('SOURCE_ID_CONFLICT',field));vendor=null;
98
+ }
99
+ if(type==='meet_cloud'&&url){const id=/\/file\/d\/([^/?#]+)/.exec(url)?.[1];if(id&&vendor&&id!==vendor){diagnostics.push(diag('SOURCE_ID_CONFLICT',field));return;}vendor??=id??null;}
100
+ let role=text(s.role)??'unknown';if(!roles.has(role)){diagnostics.push(diag('SOURCE_ROLE_UNRESOLVED',field));role='unknown';}
101
+ const locators=[];
102
+ if(path)locators.push(locator({kind:'external-local',path}));
103
+ if(url||recording||vendor)locators.push(locator({url,service_id:recording??vendor}));
104
+ const c={id:null,type,service:svc,recording_id:recording,vendor_id:vendor,roles:[role],locators,availability:path?'unknown':locators.length?'remote-only':'unknown',evidence:evidence(field)};
105
+ // Only list/top-level equivalence folds. Multiple distinct list captures stay distinct.
106
+ const peers=top?captures.filter(x=>x.type===type&&x.service===svc):[];
107
+ const existing=peers.find(x=>identity(x)&&identity(x)===identity(c))??peers[0];
108
+ if(existing) {
109
+ const a=identity(existing),b=identity(c);
110
+ const overlaps=['path','url','service_id'];
111
+ const left={path:existing.locators.find(l=>l.path)?.path??null,url:existing.locators.find(l=>l.url)?.url??null,service_id:existing.locators.find(l=>l.service_id)?.service_id??null};
112
+ const right={path,url,service_id:recording??vendor};
113
+ if(a&&b&&a!==b||overlaps.some(k=>left[k]&&right[k]&&left[k]!==right[k])){diagnostics.push(diag('SOURCE_ID_CONFLICT',field));return;}
114
+ if(a&&b&&a===b){existing.roles=[...new Set([...existing.roles,role])];existing.evidence.push(...c.evidence);for(const l of locators)if(!existing.locators.some(x=>JSON.stringify(x)===JSON.stringify(l)))existing.locators.push(l);return;}
115
+ }
116
+ c.id=`c-${String(captures.length+1).padStart(3,'0')}`;captures.push(c);
117
+ alias(namespace(c),identity(c));if(type==='zoom_cloud')alias('zoom-cloud-url',url);
118
+ }
119
+ for(const [i,s] of (Array.isArray(fields.sources)?fields.sources:[]).entries())if(s&&typeof s==='object'&&!Array.isArray(s))add(s,`sources[${i}]`);
120
+ for(const [key,type,urlKey] of [['granola_id','granola','granola_url'],['meet_id','meet_cloud',null],['zoom_gmt','zoom_cloud',null],['voice_memo_id','voice_memo',null]])if(text(fields[key])) {
121
+ if(key==='zoom_gmt'&&!/^GMT\d{8}-\d{6}$/.test(fields[key]))diagnostics.push(diag('RECORDING_ID_UNRESOLVED',key));
122
+ else add({type,id:fields[key],url:fields[urlKey]},key,true);
123
+ }
124
+ if(text(fields.recording_id)){if(service(fields.source)&&fields.source!=='granola')add({type:'cloud_recording',service:fields.source,recording_id:fields.recording_id,url:fields.url},'recording_id',true);else diagnostics.push(diag('SOURCE_SERVICE_UNRESOLVED','recording_id'));}
125
+ if(text(fields.zoom_id)){alias('zoom-meeting',fields.zoom_id);diagnostics.push(diag('RECORDING_ID_UNRESOLVED','zoom_id'));}
126
+ return {captures,aliases,diagnostics};
127
+ }
128
+ /** Exact compatibility refs; no filesystem probes or remote fetches. */
129
+ export function legacySourceRefs(fields) {
130
+ return sourcesFrom(fields).captures.flatMap(c=>(c.locators.length?c.locators:[null]).flatMap(l=>c.roles.map(role=>({capture:c.id,type:c.type,service:c.service,recordingId:c.recording_id,vendorId:c.vendor_id,role,path:l?.path??null,url:l?.url??null,serviceId:l?.service_id??null,availability:c.availability}))));
131
+ }
132
+
133
+ /** Normalize an explicit old file; never infer facts from its name or modify its bytes. */
134
+ export function normalizeLegacy(bytes,{file}) {
135
+ if(!Buffer.isBuffer(bytes)||typeof file!=='string'||!file)throw new TypeError('legacy input requires bytes and a file');
136
+ const absolute=resolve(file),relativePath=basename(file),id=relativePath.replace(/\.md$/i,'');
137
+ const fileSha256=hash(bytes);
138
+ const base={file:absolute,relativePath,id,layout:'legacy',header:null,legacyFields:{},rawHeader:null,body:bytes,fileSha256,bodySha256:hash(bytes),sections:[],turns:[],gaps:[],diagnostics:[]};
139
+ const refuse=code=>({...base,diagnostics:[diag(code,null)]});
140
+ let decoded;try{decoded=new TextDecoder('utf-8',{fatal:true,ignoreBOM:true}).decode(bytes);}catch{return refuse('INVALID_UTF8');}
141
+ const open=/^(?:\uFEFF)?---(\r?\n)/.exec(decoded);
142
+ if(!open&&/^(?:\uFEFF)?---/.test(decoded))return refuse('MALFORMED_DELIMITER');
143
+ if(open){const offset=bytes.subarray(0,3).equals(Buffer.from([239,187,191]))?3:0;const start=offset+3+Buffer.byteLength(open[1]);let cursor=start,close=null;
144
+ while(cursor<bytes.length){const lf=bytes.indexOf(10,cursor),end=lf<0?bytes.length:lf;const line=bytes.subarray(cursor,end).toString('utf8');if(line==='---'||line==='---\r'){close={start:cursor,end:lf<0?end:end+1};break;}cursor=lf<0?bytes.length:lf+1;}
145
+ if(!close)return refuse('MALFORMED_DELIMITER');base.rawHeader=bytes.subarray(start,close.start);base.body=bytes.subarray(close.end);base.bodySha256=hash(base.body);
146
+ }
147
+ const parsed=fieldsFrom(base.rawHeader),f=parsed.fields;base.legacyFields=f;
148
+ const evidence=field=>[{kind:'existing-header',locator:`${file}#${field}`,revision:{kind:'sha256',value:fileSha256},note:'Original header declares this metadata; no human confirmation is inferred.'}];
149
+ const source=sourcesFrom(f,evidence);
150
+ const h={schema_version:1,kind:'conversation',id,title:text(f.title),description:text(f.description),read_when:list(f.read_when),date:null,start:null,as_of:null,audience:text(f.audience),keep:{scope:'unknown',group:null,decision:null},participants:[],sources:source.captures,connections:[],aliases:[{kind:'legacy-path',value:file,namespace:null},...source.aliases],review:{transcription:'unreviewed',attribution:'unreviewed',processing:'unprocessed',evidence:[]},body_format:'legacy',legacy:{original_path:file,original_revision:{kind:'sha256',value:fileSha256},raw_header_base64:base.rawHeader?.toString('base64')??null,raw_header_sha256:base.rawHeader===null?null:hash(base.rawHeader),fields:f,unparsed_keys:[...parsed.unparsed],body_sha256:base.bodySha256},extensions:{}};
151
+ const diagnostics=[...parsed.diagnostics,...source.diagnostics];
152
+ for(const key of ['date','as_of'])if(text(f[key])){const candidate={...h,[key]:f[key]};if(validateConversation(candidate).some(d=>d.field===key&&d.severity==='error'))diagnostics.push(diag('INVALID_DATE',key));else h[key]=f[key];}
153
+ if(['started_at','happened_at','timezone','time_precision','inferred_from','time_local','start_local','start'].some(k=>Object.hasOwn(f,k)||parsed.unparsed.has(k)))diagnostics.push(diag('TIME_BASIS_UNRESOLVED','start','warning'));
154
+ for(const key of ['private','sensitivity','keep','scope','sharing']) {
155
+ if(parsed.unparsed.has(key)){diagnostics.push(diag('RESTRICTION_UNRESOLVED',key));continue;}
156
+ if(!Object.hasOwn(f,key))continue;
157
+ if(key==='private'&&f[key]==='true'||key==='sensitivity'&&['private','confidential','restricted'].includes(f[key]))h.keep.scope='home';
158
+ else if(!(key==='private'&&f[key]==='false'))diagnostics.push(diag('RESTRICTION_UNRESOLVED',key));
159
+ }
160
+ for(const key of ['participants','attendees'])for(const label of list(f[key]).filter(text))h.participants.push({id:`p-${String(h.participants.length+1).padStart(3,'0')}`,label,person:null,status:'unknown',role:key==='attendees'?'attendee':'unknown',evidence:evidence(key)});
161
+ if(text(f.id)&&f.id!==id)h.aliases.push({kind:'record-id',value:f.id,namespace:null});
162
+ if(text(f.meeting_key))h.extensions.meeting_key=f.meeting_key;
163
+ diagnostics.push(...validateConversation(h));
164
+ if(h.sources.length===0)diagnostics.push(diag('SOURCE_UNKNOWN','sources','warning'));
165
+ base.header=h;base.diagnostics=diagnostics;base.gaps=diagnostics.filter(d=>d.severity==='warning'||d.code.endsWith('_UNRESOLVED'));
166
+ return base;
167
+ }
@@ -0,0 +1,41 @@
1
+ // Mutation-only module. The read door never imports subprocesses or locks.
2
+ import {execFileSync} from 'node:child_process';
3
+ import {realpathSync,lstatSync,mkdirSync} from 'node:fs';
4
+ import {resolve,join} from 'node:path';
5
+ import {paths,stateDir} from './freedom-workspace.mjs';
6
+ import {acquire,release} from './freedom-run-lock.mjs';
7
+ import {withRunLock,MIGRATE_LOCK} from './freedom-migrate-common.mjs';
8
+
9
+ const refuse=code=>{throw Object.assign(new Error(`${code}: Conversation mutation lock refused.`),{code});};
10
+ /** Git's common-dir output is resolved against the actual command cwd, including subfolders. */
11
+ export function conversationLockContext(root) {
12
+ if(typeof root!=='string'||!root)refuse('WORKSPACE_REQUIRED');
13
+ const workspace=realpathSync(resolve(root));
14
+ let gitCommonDir=null;
15
+ try {
16
+ const output=execFileSync('git',['rev-parse','--path-format=absolute','--git-common-dir'],{cwd:workspace,encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
17
+ gitCommonDir=realpathSync(resolve(workspace,output));
18
+ } catch(e) {
19
+ if(e.status!==128||!String(e.stderr).includes('not a git repository'))refuse('LOCK_IDENTITY_UNAVAILABLE');
20
+ }
21
+ const transcripts=resolve(paths(workspace).transcripts);
22
+ return {root:workspace,transcripts,gitCommonDir,scope:gitCommonDir?'common-git':'transcript-root-only',
23
+ lockPath:gitCommonDir?join(gitCommonDir,'freedom-conversation-mutation.lock'):join(transcripts,'.conversation-mutation.lock'),
24
+ journalDir:gitCommonDir?join(gitCommonDir,'freedom-conversation-revisions'):stateDir('conversation-revisions',workspace)};
25
+ }
26
+ const noLink=file=>{try{if(lstatSync(file).isSymbolicLink())refuse('SYMLINK_REFUSED');}catch(e){if(e.code!=='ENOENT')throw e;}};
27
+ /** Hold common-repository lock first, then the existing capture lock, throughout fn. */
28
+ export function withConversationLock(root,{pid,by},fn) {
29
+ const context=conversationLockContext(root);
30
+ noLink(context.transcripts);noLink(context.lockPath);
31
+ if(!context.gitCommonDir)mkdirSync(context.transcripts,{recursive:true});
32
+ const lock=acquire(context.lockPath,{pid,by});
33
+ if(!lock.acquired)refuse('LOCKED');
34
+ try {
35
+ noLink(stateDir(MIGRATE_LOCK,context.root));noLink(join(stateDir(MIGRATE_LOCK,context.root),'.lock'));
36
+ let entered=false;
37
+ const result=withRunLock(context.root,{pid,by},()=>{entered=true;return fn();});
38
+ if(!entered)refuse('LOCKED'); // Capture refusal is a result object, never fn success.
39
+ return result;
40
+ } finally {if(!lock.reentrant)release(context.lockPath,{pid});}
41
+ }
@@ -0,0 +1,36 @@
1
+ // Zoned Instants retain their full decimal fraction until relative arithmetic is complete.
2
+ // Date handles only validated whole seconds, which are exact throughout four-digit years.
3
+ export function parseConversationInstant(value) {
4
+ if(typeof value!=='string')return null;
5
+ const m=/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-](\d{2}):(\d{2}))$/.exec(value);
6
+ if(!m)return null;
7
+ const year=+m[1],month=+m[2],day=+m[3],leap=year%4===0&&(year%100!==0||year%400===0);
8
+ if(month<1||month>12||day<1||day>[31,leap?29:28,31,30,31,30,31,31,30,31,30,31][month-1]||+m[4]>=24||+m[5]>=60||+m[6]>=60||m[9]&&(+m[9]>=24||+m[10]>=60))return null;
9
+ const whole=Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${m[8]}`);
10
+ if(!Number.isFinite(whole))return null;
11
+ return {seconds:BigInt(whole/1000),fraction:(m[7]??'').replace(/0+$/,'')};
12
+ }
13
+ const power=scale=>10n**BigInt(scale);
14
+ function differenceMilliseconds(left,right) {
15
+ const scale=Math.max(left.fraction.length,right.fraction.length);
16
+ const fraction=value=>BigInt(value.fraction.padEnd(scale,'0')||'0');
17
+ return {units:((left.seconds-right.seconds)*power(scale)+fraction(left)-fraction(right))*1000n,scale};
18
+ }
19
+ export function compareConversationInstants(left,right) {
20
+ const {units}=differenceMilliseconds(left,right);
21
+ return units<0n?-1:units>0n?1:0;
22
+ }
23
+ // Convert the declared Number offset's decimal representation, including exponent notation.
24
+ // Add it before rounding, so cancellation cannot turn an exact negative offset into zero.
25
+ function decimalMilliseconds(value) {
26
+ const [mantissa,exponent='0']=String(value).split('e');
27
+ let units=BigInt(mantissa.replace('.','')),scale=(mantissa.split('.')[1]?.length??0)-Number(exponent);
28
+ if(scale<0){units*=power(-scale);scale=0;}
29
+ return {units,scale};
30
+ }
31
+ export function conversationCaptureOffsetMs(at,anchor,captureStartMs) {
32
+ const delta=differenceMilliseconds(at,anchor),base=decimalMilliseconds(captureStartMs);
33
+ const scale=Math.max(delta.scale,base.scale);
34
+ const units=delta.units*power(scale-delta.scale)+base.units*power(scale-base.scale);
35
+ return {value:Number(`${units}e-${scale}`),negative:units<0n};
36
+ }