@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,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A run lock that is stale only when the run that holds it is DEAD.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS (freedom-dev#146). catch-up-freeda guarded itself with a lockfile holding a
|
|
5
|
+
// timestamp, and called any lock older than 15 minutes stale. Age was standing in for liveness,
|
|
6
|
+
// and a real run broke the stand-in: thirteen recordings, about ten hours of audio, several hours
|
|
7
|
+
// of transcription. A second run found the lock "stale", deleted it, and ran to completion on the
|
|
8
|
+
// same staging directory, sync state and people files. Neither run reported the overlap.
|
|
9
|
+
//
|
|
10
|
+
// So the lock names its holder, and nothing about its age decides anything:
|
|
11
|
+
//
|
|
12
|
+
// { pid, pidStarted, by, since }
|
|
13
|
+
//
|
|
14
|
+
// - `pid` is the holding SESSION's pid ($PPID from a skill's shell, never a helper's own pid).
|
|
15
|
+
// - `pidStarted` is that process's start time as `ps` reports it. A pid is recycled once its
|
|
16
|
+
// process exits, and a crashed run whose pid now belongs to an unrelated process would
|
|
17
|
+
// otherwise hold the lock forever, which silences the unattended timer for good. Matching the
|
|
18
|
+
// start time makes "this pid is alive" mean "THIS run is alive".
|
|
19
|
+
//
|
|
20
|
+
// A lock with no pid is LEGACY, written by a skill from before this library. It cannot say whether
|
|
21
|
+
// its run is alive, so it keeps the rule it was written under (15 minutes) rather than being held
|
|
22
|
+
// forever, and it is reported as legacy so nobody mistakes the guess for a verdict.
|
|
23
|
+
//
|
|
24
|
+
// This is the same liveness rule freedom-claims.mjs uses for a project claim, applied to a lock.
|
|
25
|
+
//
|
|
26
|
+
// CLI (exit codes are the contract):
|
|
27
|
+
// node freedom-run-lock.mjs acquire <skill> --pid $PPID [--by <who>] 0 taken, 3 held by a live run
|
|
28
|
+
// node freedom-run-lock.mjs status <skill> prints free|held|stale|legacy-held|legacy-stale
|
|
29
|
+
// node freedom-run-lock.mjs release <skill> [--pid $PPID] 0 released or already free, 3 held by another live run
|
|
30
|
+
// Any command takes --path <lockfile> instead of <skill>.
|
|
31
|
+
import { execFileSync } from "node:child_process";
|
|
32
|
+
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
33
|
+
import { dirname, join } from "node:path";
|
|
34
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
35
|
+
import { isMain } from "./freedom-is-main.mjs";
|
|
36
|
+
|
|
37
|
+
/** The age rule a legacy lock was written under. Never applied to a lock that names its holder. */
|
|
38
|
+
export const LEGACY_STALE_MS = 15 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
/** When `pid`'s process started, as `ps` prints it, or null when there is no such process. */
|
|
41
|
+
export function processStart(pid, { run = execFileSync } = {}) {
|
|
42
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
43
|
+
try {
|
|
44
|
+
const out = String(run("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })).trim();
|
|
45
|
+
return out || null;
|
|
46
|
+
} catch { return null; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True when the pid answers signal 0. EPERM means it exists and belongs to someone else. */
|
|
50
|
+
export function pidAlive(pid, { kill = process.kill.bind(process) } = {}) {
|
|
51
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
52
|
+
try { kill(pid, 0); return true; } catch (e) { return e?.code === "EPERM"; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readLock(path) {
|
|
56
|
+
let text;
|
|
57
|
+
try { text = readFileSync(path, "utf8"); } catch { return { exists: false }; }
|
|
58
|
+
try {
|
|
59
|
+
const rec = JSON.parse(text);
|
|
60
|
+
if (rec && Number.isInteger(rec.pid) && rec.pid > 0) return { exists: true, rec };
|
|
61
|
+
} catch { /* legacy: a bare timestamp or an empty file */ }
|
|
62
|
+
return { exists: true, rec: null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The lock's state. `held` and `legacy-held` mean a run owns it; `stale`, `legacy-stale` and
|
|
67
|
+
* `free` mean a new run may take it. Age decides only a legacy lock.
|
|
68
|
+
*/
|
|
69
|
+
export function lockStatus(path, { alive = pidAlive, startOf = processStart, now = Date.now() } = {}) {
|
|
70
|
+
const { exists, rec } = readLock(path);
|
|
71
|
+
if (!exists) return { state: "free" };
|
|
72
|
+
if (!rec) {
|
|
73
|
+
let age = 0;
|
|
74
|
+
try { age = now - statSync(path).mtimeMs; } catch { /* vanished between reads */ }
|
|
75
|
+
return age < LEGACY_STALE_MS
|
|
76
|
+
? { state: "legacy-held", ageMs: age, why: "a lock with no pid, younger than 15 minutes" }
|
|
77
|
+
: { state: "legacy-stale", ageMs: age, why: "a lock with no pid, older than 15 minutes" };
|
|
78
|
+
}
|
|
79
|
+
if (!alive(rec.pid)) return { state: "stale", record: rec, why: `pid ${rec.pid} is not running` };
|
|
80
|
+
if (rec.pidStarted) {
|
|
81
|
+
const started = startOf(rec.pid);
|
|
82
|
+
if (started && started !== rec.pidStarted) {
|
|
83
|
+
return { state: "stale", record: rec, why: `pid ${rec.pid} was reused by a process started ${started}` };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { state: "held", record: rec, why: `pid ${rec.pid} is running` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const isHeld = (s) => s.state === "held" || s.state === "legacy-held";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Take the lock for `pid`. Never takes it from a live run. Clears a stale one and says so.
|
|
93
|
+
* The write is exclusive (`wx`), so two runs racing past the status check cannot both win.
|
|
94
|
+
*/
|
|
95
|
+
export function acquire(path, { pid, by = "unknown", now = new Date(), alive = pidAlive, startOf = processStart } = {}) {
|
|
96
|
+
if (!Number.isInteger(pid) || pid <= 0) throw new Error(`acquire needs the holding session's pid ($PPID from a skill's shell), got ${pid}`);
|
|
97
|
+
const status = lockStatus(path, { alive, startOf, now: now.getTime() });
|
|
98
|
+
if (isHeld(status)) {
|
|
99
|
+
if (status.record?.pid === pid) return { acquired: true, reentrant: true, status };
|
|
100
|
+
return { acquired: false, status };
|
|
101
|
+
}
|
|
102
|
+
if (status.state !== "free") { try { unlinkSync(path); } catch { /* already gone */ } }
|
|
103
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
104
|
+
const rec = { pid, pidStarted: startOf(pid), by, since: now.toISOString() };
|
|
105
|
+
try {
|
|
106
|
+
writeFileSync(path, JSON.stringify(rec, null, 2) + "\n", { flag: "wx" });
|
|
107
|
+
} catch (e) {
|
|
108
|
+
if (e?.code === "EEXIST") return { acquired: false, status: lockStatus(path, { alive, startOf }) };
|
|
109
|
+
throw e;
|
|
110
|
+
}
|
|
111
|
+
return { acquired: true, record: rec, cleared: status.state === "free" ? null : status };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Release the lock. With a pid, refuses to delete a lock a DIFFERENT live run holds, which is the
|
|
116
|
+
* mirror of the bug this file exists for: a finishing run must not delete a newer run's lock.
|
|
117
|
+
*/
|
|
118
|
+
export function release(path, { pid = null, alive = pidAlive, startOf = processStart } = {}) {
|
|
119
|
+
const status = lockStatus(path, { alive, startOf });
|
|
120
|
+
if (status.state === "free") return { released: false, status };
|
|
121
|
+
if (pid != null && status.state === "held" && status.record.pid !== pid) return { released: false, status };
|
|
122
|
+
try { unlinkSync(path); } catch { /* already gone */ }
|
|
123
|
+
return { released: true, status };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function lockPathFor(args) {
|
|
127
|
+
const i = args.indexOf("--path");
|
|
128
|
+
if (i !== -1 && args[i + 1]) return args[i + 1];
|
|
129
|
+
const skill = args.find((a, j) => !a.startsWith("--") && !(j > 0 && args[j - 1].startsWith("--")));
|
|
130
|
+
if (!skill) throw new Error("name a skill, or pass --path <lockfile>");
|
|
131
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
132
|
+
const { stateDir } = await import(pathToFileURL(join(here, "freedom-workspace.mjs")).href);
|
|
133
|
+
return join(stateDir(skill), ".lock");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (isMain(import.meta.url)) {
|
|
137
|
+
const [cmd, ...args] = process.argv.slice(2);
|
|
138
|
+
const opt = (k) => { const i = args.indexOf(k); return i === -1 ? undefined : args[i + 1]; };
|
|
139
|
+
try {
|
|
140
|
+
const path = await lockPathFor(args);
|
|
141
|
+
if (cmd === "status") {
|
|
142
|
+
const s = lockStatus(path);
|
|
143
|
+
console.log(s.state);
|
|
144
|
+
if (s.why) console.error(s.why);
|
|
145
|
+
} else if (cmd === "acquire") {
|
|
146
|
+
const r = acquire(path, { pid: parseInt(opt("--pid") ?? "", 10), by: opt("--by") ?? "cli" });
|
|
147
|
+
if (r.acquired) {
|
|
148
|
+
if (r.cleared) console.error(`cleared a ${r.cleared.state} lock: ${r.cleared.why}`);
|
|
149
|
+
console.log("acquired");
|
|
150
|
+
} else {
|
|
151
|
+
console.log("Pipeline already running.");
|
|
152
|
+
console.error(r.status.why || r.status.state);
|
|
153
|
+
process.exit(3);
|
|
154
|
+
}
|
|
155
|
+
} else if (cmd === "release") {
|
|
156
|
+
const pid = opt("--pid") ? parseInt(opt("--pid"), 10) : null;
|
|
157
|
+
const r = release(path, { pid });
|
|
158
|
+
if (!r.released && r.status.state === "held") {
|
|
159
|
+
console.error(`not released: ${r.status.why}, and it is not this run`);
|
|
160
|
+
process.exit(3);
|
|
161
|
+
}
|
|
162
|
+
console.log(r.released ? "released" : "already free");
|
|
163
|
+
} else {
|
|
164
|
+
console.error("usage: freedom-run-lock.mjs {acquire <skill> --pid $PPID [--by <who>] | status <skill> | release <skill> [--pid $PPID]} (or --path <lockfile>)");
|
|
165
|
+
process.exit(2);
|
|
166
|
+
}
|
|
167
|
+
} catch (e) {
|
|
168
|
+
console.error(e.message);
|
|
169
|
+
process.exit(2);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Existing self reference only; no profile/global workspace discovery.
|
|
2
|
+
import {existsSync} from 'node:fs';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {paths} from './freedom-workspace.mjs';
|
|
5
|
+
export function workspaceFile(root,key){if(key!=='self.md')throw Error('Unsupported self reference');const p=paths(root),fresh=join(p.self,'self.md'),legacy=join(p.user,'USER.md');return existsSync(fresh)?fresh:existsSync(legacy)?legacy:fresh;}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Portable boundary: explicit caller roots and local markers only. No environment/home discovery.
|
|
2
|
+
import {existsSync,readFileSync,realpathSync,lstatSync} from 'node:fs';
|
|
3
|
+
import {resolve,join,dirname,isAbsolute} from 'node:path';
|
|
4
|
+
const required=root=>{if(typeof root!=='string'||!root)throw Object.assign(Error('explicit workspace root required'),{code:'WORKSPACE_REQUIRED'});return realpathSync(resolve(root));};
|
|
5
|
+
export function resolveTrackedRoot(root){return required(root);}
|
|
6
|
+
export function enclosingWorkspace(start){let dir=resolve(start);for(;;){if(existsSync(join(dir,'.freedom.json')))return required(dir);const parent=dirname(dir);if(parent===dir)return null;dir=parent;}}
|
|
7
|
+
export function paths(root){
|
|
8
|
+
root=required(root);const config=join(root,'.freedom.json');let cfg={};
|
|
9
|
+
if(existsSync(config)){cfg=JSON.parse(readFileSync(config,'utf8'));if(!cfg||typeof cfg!=='object'||Array.isArray(cfg)||cfg.paths!==undefined&&(!cfg.paths||typeof cfg.paths!=='object'||Array.isArray(cfg.paths)))throw Error('Invalid caller root configuration');}
|
|
10
|
+
const defaults={transcripts:'meeting-transcripts',state:'.agents/state',people:'people',self:'self',user:'user'};
|
|
11
|
+
const out={root};for(const [key,fallback]of Object.entries(defaults)){const value=cfg.paths?.[key]??fallback;if(typeof value!=='string'||!value||value.startsWith('~'))throw Error('Caller paths must be explicit paths');out[key]=resolve(root,value);}
|
|
12
|
+
// Relative state is shared by linked Git worktrees, matching the common-repository capture lock.
|
|
13
|
+
const dotgit=join(root,'.git');if(existsSync(dotgit)&&lstatSync(dotgit).isFile()){
|
|
14
|
+
const match=/^gitdir:\s*(.+)$/m.exec(readFileSync(dotgit,'utf8'));
|
|
15
|
+
if(match){const gitdir=resolve(root,match[1]),common=join(gitdir,'commondir');if(existsSync(common)){const main=dirname(realpathSync(resolve(gitdir,readFileSync(common,'utf8').trim())));const value=cfg.paths?.state??defaults.state;if(!isAbsolute(value))out.state=resolve(main,value);}}
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
export function stateDir(skill,root){if(typeof skill!=='string'||!skill||/[/\\]/.test(skill)||skill==='.'||skill==='..')throw Error('stateDir needs a bare skill name');return join(paths(root).state,skill);}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Thin Python door to the shared conversation CLI; never parse or write Markdown.
|
|
2
|
+
|
|
3
|
+
Installed callers resolve the adjacent packaged CLI; no global resolver or
|
|
4
|
+
source-checkout override is consulted.
|
|
5
|
+
Every bridge failure raises ConversationError rather than an empty archive.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConversationError(RuntimeError):
|
|
15
|
+
"""Redacted bridge failure retaining the actual exit and shared diagnostics."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message, returncode=None, diagnostics=None):
|
|
18
|
+
self.returncode = returncode
|
|
19
|
+
self.diagnostics = diagnostics or []
|
|
20
|
+
codes = ", ".join(d.get("code", "UNKNOWN") for d in self.diagnostics)
|
|
21
|
+
super().__init__(message + (": " + codes if codes else ""))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _diagnostics(value):
|
|
25
|
+
if not isinstance(value, list) or any(not isinstance(d, dict)
|
|
26
|
+
or not isinstance(d.get("code"), str)
|
|
27
|
+
or d.get("severity") not in ("error", "warning") for d in value):
|
|
28
|
+
raise ConversationError("Malformed conversation diagnostics")
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run(args, workspace=None):
|
|
33
|
+
# The shared workspace resolver prioritizes its environment binding over
|
|
34
|
+
# --workspace. Pin an explicit caller selector in the child too, without
|
|
35
|
+
# mutating the Python caller's environment or changing default resolution.
|
|
36
|
+
env = None if workspace is None else dict(os.environ, FREEDOM_WORKSPACE=os.fspath(workspace))
|
|
37
|
+
try:
|
|
38
|
+
result = subprocess.run(args, capture_output=True, text=True,
|
|
39
|
+
encoding="utf-8", check=True, env=env)
|
|
40
|
+
except OSError as exc:
|
|
41
|
+
raise ConversationError("Conversation runtime unavailable") from exc
|
|
42
|
+
except UnicodeError as exc:
|
|
43
|
+
raise ConversationError("Unreadable conversation output") from exc
|
|
44
|
+
except subprocess.CalledProcessError as exc:
|
|
45
|
+
diagnostics = []
|
|
46
|
+
try:
|
|
47
|
+
value = json.loads(exc.stdout)
|
|
48
|
+
if isinstance(value, dict):
|
|
49
|
+
diagnostics = _diagnostics(value.get("diagnostics", []))
|
|
50
|
+
except (ValueError, TypeError, ConversationError):
|
|
51
|
+
pass
|
|
52
|
+
raise ConversationError("Conversation CLI failed", exc.returncode, diagnostics) from exc
|
|
53
|
+
try:
|
|
54
|
+
value = json.loads(result.stdout)
|
|
55
|
+
except (ValueError, TypeError) as exc:
|
|
56
|
+
raise ConversationError("Malformed conversation JSON") from exc
|
|
57
|
+
if not isinstance(value, dict) or type(value.get("api_version")) is not int or value["api_version"] != 1:
|
|
58
|
+
raise ConversationError("Unsupported conversation API")
|
|
59
|
+
if "diagnostics" in value:
|
|
60
|
+
diagnostics = _diagnostics(value["diagnostics"])
|
|
61
|
+
if any(d["severity"] == "error" for d in diagnostics):
|
|
62
|
+
raise ConversationError("Conversation read refused", result.returncode, diagnostics)
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _cli(command):
|
|
67
|
+
cli = Path(__file__).resolve().with_name("freedom-conversations-cli.mjs")
|
|
68
|
+
if not cli.is_file():
|
|
69
|
+
raise ConversationError("Installed conversation CLI unavailable")
|
|
70
|
+
invocation = ["node", str(cli)]
|
|
71
|
+
caps = _run(invocation + ["--capabilities", "--json"])
|
|
72
|
+
if not isinstance(caps.get("schema_versions"), list) or not any(type(v) is int and v == 1 for v in caps["schema_versions"]):
|
|
73
|
+
raise ConversationError("Unsupported conversation schema capability")
|
|
74
|
+
if not isinstance(caps.get("commands"), list) or command not in caps["commands"]:
|
|
75
|
+
raise ConversationError("Missing conversation command capability")
|
|
76
|
+
return invocation
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _root_args(workspace=None, transcripts_dir=None):
|
|
80
|
+
if workspace is not None and transcripts_dir is not None:
|
|
81
|
+
raise ValueError("Use exactly one root selector")
|
|
82
|
+
if workspace is not None:
|
|
83
|
+
if not os.fspath(workspace):
|
|
84
|
+
raise ValueError("Empty workspace selector")
|
|
85
|
+
return ["--workspace", os.fspath(workspace)]
|
|
86
|
+
if transcripts_dir is not None:
|
|
87
|
+
if not os.fspath(transcripts_dir):
|
|
88
|
+
raise ValueError("Empty transcript selector")
|
|
89
|
+
return ["--transcripts-dir", os.fspath(transcripts_dir)]
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _records(value):
|
|
94
|
+
_diagnostics(value.get("diagnostics"))
|
|
95
|
+
records = value.get("records")
|
|
96
|
+
if not isinstance(records, list) or any(not isinstance(r, dict) or not isinstance(r.get("path"), str) for r in records):
|
|
97
|
+
raise ConversationError("Malformed conversation records")
|
|
98
|
+
for record in records:
|
|
99
|
+
diagnostics = _diagnostics(record.get("diagnostics"))
|
|
100
|
+
if any(d["severity"] == "error" for d in diagnostics):
|
|
101
|
+
raise ConversationError("Conversation record refused", diagnostics=diagnostics)
|
|
102
|
+
return records
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def list_records(workspace=None, transcripts_dir=None, include_sections=False):
|
|
106
|
+
"""Return metadata for admitted records, using exactly the supplied root."""
|
|
107
|
+
args = ["list"] + _root_args(workspace, transcripts_dir) + ["--json"]
|
|
108
|
+
if include_sections:
|
|
109
|
+
args.append("--include-sections")
|
|
110
|
+
return _records(_run(_cli("list") + args, workspace))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _read(command, path, workspace=None, include_body=False):
|
|
114
|
+
args = [command, os.fspath(path)] + _root_args(workspace) + ["--json"]
|
|
115
|
+
if include_body:
|
|
116
|
+
args.append("--include-body")
|
|
117
|
+
records = _records(_run(_cli(command) + args, workspace))
|
|
118
|
+
if len(records) != 1:
|
|
119
|
+
raise ConversationError("Conversation read requires one record")
|
|
120
|
+
return records[0]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def read_record(path, workspace=None, include_body=False):
|
|
124
|
+
"""Return one record; words/sections are included only on explicit request."""
|
|
125
|
+
return _read("read", path, workspace, include_body)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def read_source_refs(path, workspace=None):
|
|
129
|
+
"""Shared SourceRefs plus explicit resolvedPath, without changing identity.
|
|
130
|
+
|
|
131
|
+
Metadata Capture locators establish kind, never a guess from path spelling.
|
|
132
|
+
Pair exact capture/locator/role properties and pin the two read snapshots.
|
|
133
|
+
A relative external locator has no declared origin and therefore refuses.
|
|
134
|
+
Service-only and unknown locators retain null resolvedPath.
|
|
135
|
+
"""
|
|
136
|
+
record = read_record(path, workspace)
|
|
137
|
+
sourced = _read("sources", path, workspace)
|
|
138
|
+
if record.get("fileSha256") != sourced.get("fileSha256") or record["path"] != sourced["path"]:
|
|
139
|
+
raise ConversationError("Conversation changed during source read")
|
|
140
|
+
captures = record.get("sources")
|
|
141
|
+
refs = sourced.get("sources")
|
|
142
|
+
if not isinstance(captures, list) or not isinstance(refs, list):
|
|
143
|
+
raise ConversationError("Source metadata unavailable")
|
|
144
|
+
result = []
|
|
145
|
+
for ref in refs:
|
|
146
|
+
if not isinstance(ref, dict):
|
|
147
|
+
raise ConversationError("Malformed shared source reference")
|
|
148
|
+
matches = [c for c in captures if c.get("id") == ref.get("capture")]
|
|
149
|
+
if len(matches) != 1 or ref.get("role") not in matches[0].get("roles", []):
|
|
150
|
+
raise ConversationError("Source metadata mismatch")
|
|
151
|
+
capture = matches[0]
|
|
152
|
+
if any(capture.get(a) != ref.get(b) for a, b in (("type", "type"), ("service", "service"), ("recording_id", "recordingId"), ("vendor_id", "vendorId"), ("availability", "availability"))):
|
|
153
|
+
raise ConversationError("Source identity mismatch")
|
|
154
|
+
locators = capture.get("locators", [])
|
|
155
|
+
matching = [l for l in locators if all(l.get(a) == ref.get(b) for a, b in (("path", "path"), ("url", "url"), ("service_id", "serviceId")))]
|
|
156
|
+
kinds = {l.get("kind") for l in matching}
|
|
157
|
+
if not locators and all(ref.get(k) is None for k in ("path", "url", "serviceId")):
|
|
158
|
+
kind = None
|
|
159
|
+
elif len(kinds) == 1:
|
|
160
|
+
kind = next(iter(kinds))
|
|
161
|
+
else:
|
|
162
|
+
raise ConversationError("Source locator kind unresolved")
|
|
163
|
+
resolved = None
|
|
164
|
+
original = ref.get("path")
|
|
165
|
+
if kind == "bundled":
|
|
166
|
+
folder = Path(record["path"]).parent.resolve()
|
|
167
|
+
target = (folder / original).resolve()
|
|
168
|
+
if not target.is_relative_to(folder):
|
|
169
|
+
raise ConversationError("Bundled source escapes entry folder")
|
|
170
|
+
resolved = str(target)
|
|
171
|
+
elif kind == "external-local":
|
|
172
|
+
expanded = os.path.expanduser(original)
|
|
173
|
+
if not os.path.isabs(expanded):
|
|
174
|
+
raise ConversationError("External source origin unresolved")
|
|
175
|
+
resolved = os.path.abspath(expanded)
|
|
176
|
+
elif kind not in (None, "service"):
|
|
177
|
+
raise ConversationError("Source locator kind unsupported")
|
|
178
|
+
result.append(dict(ref, resolvedPath=resolved))
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _revision_result(value):
|
|
183
|
+
required = ("path", "revision_id", "changed", "before_sha256", "after_sha256", "turn_ids", "journal_path", "recovered")
|
|
184
|
+
if any(key not in value for key in required):
|
|
185
|
+
raise ConversationError("Malformed conversation revision result")
|
|
186
|
+
return {key: value[key] for key in required}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def revise_record(path, spec, expected_sha, workspace=None):
|
|
190
|
+
"""Transport the complete caller spec and exact pin to the shared writer."""
|
|
191
|
+
invocation = _cli("revise")
|
|
192
|
+
with tempfile.TemporaryDirectory(prefix="conversation-spec-") as temporary:
|
|
193
|
+
spec_file = Path(temporary) / "spec.json"
|
|
194
|
+
spec_file.write_text(json.dumps(spec, ensure_ascii=False, allow_nan=False), encoding="utf-8")
|
|
195
|
+
value = _run(invocation + ["revise", os.fspath(path), "--spec", str(spec_file),
|
|
196
|
+
"--expected-sha", expected_sha] + _root_args(workspace) + ["--json"], workspace)
|
|
197
|
+
return _revision_result(value)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def recover_revision(path, revision_id, action, workspace=None):
|
|
201
|
+
"""Resume/reverse only through the journal-aware shared recovery command."""
|
|
202
|
+
value = _run(_cli("recover") + ["recover", os.fspath(path), "--revision-id", revision_id,
|
|
203
|
+
"--action", action] + _root_args(workspace) + ["--json"], workspace)
|
|
204
|
+
return _revision_result(value)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def annotate_record(path, spec, expected_sha, workspace=None):
|
|
208
|
+
"""Transport append-only interpretation and caller pins to shared authority."""
|
|
209
|
+
invocation = _cli("annotate")
|
|
210
|
+
with tempfile.TemporaryDirectory(prefix="conversation-spec-") as temporary:
|
|
211
|
+
spec_file = Path(temporary) / "spec.json"
|
|
212
|
+
spec_file.write_text(json.dumps(spec, ensure_ascii=False, allow_nan=False), encoding="utf-8")
|
|
213
|
+
value = _run(invocation + ["annotate", os.fspath(path), "--spec", str(spec_file),
|
|
214
|
+
"--expected-sha", expected_sha] + _root_args(workspace) + ["--json"], workspace)
|
|
215
|
+
if (not isinstance(value.get("path"), str) or not value["path"]
|
|
216
|
+
or not isinstance(value.get("fileSha256"), str) or len(value["fileSha256"]) != 64
|
|
217
|
+
or any(c not in "0123456789abcdef" for c in value["fileSha256"])):
|
|
218
|
+
raise ConversationError("Malformed conversation annotation result")
|
|
219
|
+
return {key: value[key] for key in ("path", "fileSha256")}
|