@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,248 @@
|
|
|
1
|
+
// .agents/lib/frontmatter/yaml.mjs
|
|
2
|
+
// The flat YAML subset this workspace writes, in both directions, and nothing else.
|
|
3
|
+
//
|
|
4
|
+
// WHY A SUBSET AND WHY HAND-ROLLED. The repo ships with no dependencies and already carries two
|
|
5
|
+
// flat frontmatter readers (freedom-wiki-canon.mjs, freedom-operator.mjs), each a different
|
|
6
|
+
// regex. The spec (docs/superpowers/specs/2026-09-13-frontmatter-is-the-truth-design.md) says
|
|
7
|
+
// frontmatter carries facts and never paragraphs: scalars, ISO dates, flat lists. A full YAML
|
|
8
|
+
// parser would accept anchors, multi-line folded scalars and nested maps, which is exactly the
|
|
9
|
+
// prose-in-braces the convention forbids, so the parser refuses them by construction rather than
|
|
10
|
+
// by policy. Every value is a string or an array of strings; a kind decides what it means.
|
|
11
|
+
//
|
|
12
|
+
// ONE NAMED EXCEPTION, AND IT IS OPT-IN PER CALL: `state:`. The artifacts host
|
|
13
|
+
// (@supersuit/artifacts 0.2.0+) accepts a `state:` block on a published page
|
|
14
|
+
// (writers/visibility/slots for a reader to answer), and that shape is genuinely a small map,
|
|
15
|
+
// not a paragraph wearing braces. This parser never decides on its own that a key may nest: the
|
|
16
|
+
// caller passes `{ nested: [...] }` naming exactly which keys are allowed to, for THIS read or
|
|
17
|
+
// write. A caller that passes nothing gets the original behaviour, every key refused. That
|
|
18
|
+
// scoping lives one layer up (`freedom-frontmatter.mjs` derives it from the document's `kind`,
|
|
19
|
+
// via `nestedKeysFor()` in `frontmatter/kinds.mjs`, for the one kind — `document` — whose schema
|
|
20
|
+
// declares `state: { type: "map" }`), so a caller with no kind in view (a map file, a project
|
|
21
|
+
// header) can never accidentally inherit nesting meant for a published artifact.
|
|
22
|
+
|
|
23
|
+
export class FrontmatterSyntaxError extends Error {
|
|
24
|
+
constructor(message, line) { super(`line ${line}: ${message}`); this.name = "FrontmatterSyntaxError"; this.line = line; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const KEY = /^([a-z][a-z0-9_]*):(?:\s+(.*))?$/;
|
|
28
|
+
const LIST_ITEM = /^\s+-\s+(.*)$/;
|
|
29
|
+
const INDENTED = /^\s+\S/;
|
|
30
|
+
|
|
31
|
+
// The inverse of quote()'s JSON.stringify. Without it the two halves are asymmetric: the
|
|
32
|
+
// writer escapes on every emit and the reader never unescapes, so a value carrying a quotation
|
|
33
|
+
// gains a pair of backslashes on every read/write cycle and degrades to unreadable. A value
|
|
34
|
+
// whose backslashes are not valid escapes (a Windows path typed by hand) is left exactly as
|
|
35
|
+
// written rather than thrown away.
|
|
36
|
+
const unescape = (inner) => {
|
|
37
|
+
try { return JSON.parse(`"${inner}"`); }
|
|
38
|
+
catch { return inner; }
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const unquote = (s) => {
|
|
42
|
+
const t = s.trim();
|
|
43
|
+
if (t.length >= 2 && t[0] === '"' && t.at(-1) === '"') return unescape(t.slice(1, -1));
|
|
44
|
+
// Single quotes carry no backslash escapes in YAML, so the slice IS the value.
|
|
45
|
+
if (t.length >= 2 && t[0] === "'" && t.at(-1) === "'") return t.slice(1, -1);
|
|
46
|
+
return t;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// `"example" # a note` is the value `example`. Only a comment that follows a CLOSED
|
|
50
|
+
// quote or a closed inline list is stripped: an unquoted value keeps any `#` it carries
|
|
51
|
+
// (`C# and F#`, `page#anchor`), because the writer quotes every value containing `#` and a
|
|
52
|
+
// hand-written unquoted one is safer kept whole than guessed at.
|
|
53
|
+
export const stripTrailingComment = (v) => {
|
|
54
|
+
const t = v.trim();
|
|
55
|
+
const close = t[0] === '"' || t[0] === "'" ? t[0] : t[0] === "[" ? "]" : null;
|
|
56
|
+
if (!close) return t;
|
|
57
|
+
let q = null;
|
|
58
|
+
for (let i = 1; i < t.length; i++) {
|
|
59
|
+
const ch = t[i];
|
|
60
|
+
if (q) { if (ch === "\\" && q === '"') { i++; continue; } if (ch === q) q = null; continue; }
|
|
61
|
+
if (close === "]" && (ch === '"' || ch === "'")) { q = ch; continue; }
|
|
62
|
+
if (close !== "]" && ch === "\\" && close === '"') { i++; continue; }
|
|
63
|
+
if (ch === close) {
|
|
64
|
+
const rest = t.slice(i + 1);
|
|
65
|
+
return /^\s+#/.test(rest) ? t.slice(0, i + 1) : t;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return t;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// `a, "b, c"`: split on commas outside quotes, leaving each piece unparsed.
|
|
72
|
+
const splitTopLevel = (inner) => {
|
|
73
|
+
const out = []; let cur = ""; let q = null;
|
|
74
|
+
for (let i = 0; i < inner.length; i++) {
|
|
75
|
+
const ch = inner[i];
|
|
76
|
+
if (q) {
|
|
77
|
+
cur += ch;
|
|
78
|
+
// Keep escaped pairs intact for unquote(). Paired backslashes leave a following
|
|
79
|
+
// quote free to close; single-quoted values have no backslash escapes.
|
|
80
|
+
if (ch === "\\" && q === '"') { cur += inner[++i] ?? ""; continue; }
|
|
81
|
+
if (ch === q) q = null;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
|
|
85
|
+
if (ch === ",") { out.push(cur); cur = ""; continue; }
|
|
86
|
+
cur += ch;
|
|
87
|
+
}
|
|
88
|
+
if (cur.trim()) out.push(cur);
|
|
89
|
+
return out;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const splitInline = (inner) => splitTopLevel(inner).map(unquote).filter((s) => s !== "");
|
|
93
|
+
|
|
94
|
+
// `{ shape: one }` / `{ shape: many, visibility: shared }`: a flow map, one level, scalar values
|
|
95
|
+
// only. This is the leaf shape the artifacts host's `state.slots.*` entries use; it is not a
|
|
96
|
+
// general flow-YAML parser, only enough of one to round-trip that shape.
|
|
97
|
+
function parseFlowMap(s) {
|
|
98
|
+
const inner = s.slice(1, -1).trim();
|
|
99
|
+
const out = {};
|
|
100
|
+
if (!inner) return out;
|
|
101
|
+
for (const part of splitTopLevel(inner)) {
|
|
102
|
+
const m = part.trim().match(/^([a-z][a-z0-9_]*)\s*:\s*(.*)$/);
|
|
103
|
+
if (!m) throw new FrontmatterSyntaxError(`"${part.trim()}" is not a "key: value" entry in a flow map`, 0);
|
|
104
|
+
out[m[1]] = unquote(m[2].trim());
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const indentOf = (line) => (line.match(/^(\s*)/) ?? ["", ""])[1].length;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A nested block starting at `lines[start]` (which sets the block's indent level): every
|
|
113
|
+
* `key: value` line at that indent, a value-less key followed by a MORE indented block recursing
|
|
114
|
+
* one level deeper, and a `{ ... }` value read as a flow map. Stops at the first line whose
|
|
115
|
+
* indent is less than the block's own. Returns `{ value, next }`, `next` being the index of the
|
|
116
|
+
* first line NOT consumed, so the caller's main loop can resume there.
|
|
117
|
+
*/
|
|
118
|
+
function parseNestedBlock(lines, start) {
|
|
119
|
+
const blockIndent = indentOf(lines[start]);
|
|
120
|
+
const out = {};
|
|
121
|
+
let i = start;
|
|
122
|
+
while (i < lines.length) {
|
|
123
|
+
const line = lines[i];
|
|
124
|
+
if (!line.trim()) { i++; continue; }
|
|
125
|
+
const ind = indentOf(line);
|
|
126
|
+
if (ind < blockIndent) break;
|
|
127
|
+
if (ind > blockIndent) throw new FrontmatterSyntaxError("bad indentation in a nested map", i + 1);
|
|
128
|
+
const m = line.match(/^\s*([a-z][a-z0-9_]*):(?:\s+(.*))?$/);
|
|
129
|
+
if (!m) throw new FrontmatterSyntaxError("not a \"key: value\" line inside a nested map", i + 1);
|
|
130
|
+
const [, key, rawVal] = m;
|
|
131
|
+
if (key in out) throw new FrontmatterSyntaxError(`duplicate key "${key}"`, i + 1);
|
|
132
|
+
const val = stripTrailingComment(rawVal ?? "");
|
|
133
|
+
if (val === "") {
|
|
134
|
+
const nextLine = lines[i + 1];
|
|
135
|
+
if (nextLine !== undefined && nextLine.trim() && indentOf(nextLine) > blockIndent) {
|
|
136
|
+
const sub = parseNestedBlock(lines, i + 1);
|
|
137
|
+
out[key] = sub.value;
|
|
138
|
+
i = sub.next;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
out[key] = "";
|
|
142
|
+
i++;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
out[key] = val.startsWith("{") && val.endsWith("}") ? parseFlowMap(val) : unquote(val);
|
|
146
|
+
i++;
|
|
147
|
+
}
|
|
148
|
+
return { value: out, next: i };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `state:`'s value, serialized as an indented block (mirroring how it is authored by hand). A
|
|
152
|
+
* nested map one level deep is written as a flow map (`{ k: v }`), matching the shape the host
|
|
153
|
+
* itself accepts and what a hand-authored file already looks like. */
|
|
154
|
+
function stringifyNestedBlock(obj, indent) {
|
|
155
|
+
const pad = " ".repeat(indent);
|
|
156
|
+
let out = "";
|
|
157
|
+
for (const [k, v] of Object.entries(obj ?? {})) {
|
|
158
|
+
if (v === undefined) continue;
|
|
159
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
160
|
+
const allScalar = Object.values(v).every((vv) => vv === null || typeof vv !== "object");
|
|
161
|
+
if (allScalar) {
|
|
162
|
+
const inner = Object.entries(v).map(([kk, vv]) => `${kk}: ${quote(String(vv))}`).join(", ");
|
|
163
|
+
out += `${pad}${k}: { ${inner} }\n`;
|
|
164
|
+
} else {
|
|
165
|
+
out += `${pad}${k}:\n${stringifyNestedBlock(v, indent + 1)}`;
|
|
166
|
+
}
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
out += `${pad}${k}: ${quote(String(v))}\n`;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function parseFlat(text, { nested = [] } = {}) {
|
|
175
|
+
const nestedKeys = nested instanceof Set ? nested : new Set(nested);
|
|
176
|
+
const out = {};
|
|
177
|
+
const lines = String(text ?? "").split("\n");
|
|
178
|
+
let listKey = null;
|
|
179
|
+
for (let i = 0; i < lines.length; i++) {
|
|
180
|
+
const line = lines[i], n = i + 1;
|
|
181
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
182
|
+
const item = line.match(LIST_ITEM);
|
|
183
|
+
if (item) {
|
|
184
|
+
if (!listKey) throw new FrontmatterSyntaxError("a list item with no list key above it", n);
|
|
185
|
+
out[listKey].push(unquote(stripTrailingComment(item[1])));
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (INDENTED.test(line)) {
|
|
189
|
+
throw new FrontmatterSyntaxError(/^\s+\S+:/.test(line)
|
|
190
|
+
? "a nested map; frontmatter here is flat (facts, never paragraphs)"
|
|
191
|
+
: "an indented continuation; a value is one line", n);
|
|
192
|
+
}
|
|
193
|
+
const kv = line.match(KEY);
|
|
194
|
+
if (!kv) {
|
|
195
|
+
const bad = line.match(/^([^:]+):/);
|
|
196
|
+
throw new FrontmatterSyntaxError(bad ? `key "${bad[1]}" is not snake_case` : "not a key: value line", n);
|
|
197
|
+
}
|
|
198
|
+
const [, key, rawVal] = kv;
|
|
199
|
+
if (key in out) throw new FrontmatterSyntaxError(`duplicate key "${key}"`, n);
|
|
200
|
+
const val = stripTrailingComment(rawVal ?? "");
|
|
201
|
+
if (val === "") {
|
|
202
|
+
// Either an empty scalar, the head of a block list, or (for a key THIS CALL named nestable)
|
|
203
|
+
// the head of a nested map; the next line decides.
|
|
204
|
+
const next = lines[i + 1] ?? "";
|
|
205
|
+
if (LIST_ITEM.test(next)) { out[key] = []; listKey = key; continue; }
|
|
206
|
+
if (nestedKeys.has(key) && next.trim() && INDENTED.test(next)) {
|
|
207
|
+
const sub = parseNestedBlock(lines, i + 1);
|
|
208
|
+
out[key] = sub.value;
|
|
209
|
+
listKey = null;
|
|
210
|
+
i = sub.next - 1; // the for-loop's own i++ resumes at sub.next
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
out[key] = ""; listKey = null; continue;
|
|
214
|
+
}
|
|
215
|
+
listKey = null;
|
|
216
|
+
if (val.startsWith("[")) {
|
|
217
|
+
if (!val.endsWith("]")) throw new FrontmatterSyntaxError("an inline list that does not close on its line", n);
|
|
218
|
+
out[key] = splitInline(val.slice(1, -1));
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
out[key] = unquote(val);
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const needsQuotes = (s) => s === "" || /[:#\[\]{}"'|>&*!%@`,]/.test(s) || /^\s|\s$/.test(s) || /^(true|false|null|yes|no|~)$/i.test(s) || /^[\d.+-]+$/.test(s) && !/^\d{4}-\d{2}-\d{2}$/.test(s);
|
|
227
|
+
const quote = (s) => needsQuotes(s) ? JSON.stringify(s) : s;
|
|
228
|
+
|
|
229
|
+
export function stringifyFlat(data, { nested = [] } = {}) {
|
|
230
|
+
const nestedKeys = nested instanceof Set ? nested : new Set(nested);
|
|
231
|
+
let out = "";
|
|
232
|
+
for (const [key, value] of Object.entries(data ?? {})) {
|
|
233
|
+
if (value === undefined) continue;
|
|
234
|
+
if (Array.isArray(value)) {
|
|
235
|
+
for (const v of value) if (typeof v !== "string") throw new Error(`"${key}": list items must be strings`);
|
|
236
|
+
out += value.length ? `${key}:\n${value.map((v) => ` - ${quote(v)}\n`).join("")}` : `${key}: []\n`;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (value !== null && typeof value === "object") {
|
|
240
|
+
if (!nestedKeys.has(key)) throw new Error(`"${key}": nested maps are not frontmatter here; facts, never paragraphs`);
|
|
241
|
+
out += `${key}:\n${stringifyNestedBlock(value, 1)}`;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (typeof value !== "string") throw new Error(`"${key}": values are strings (dates as YYYY-MM-DD)`);
|
|
245
|
+
out += `${key}: ${quote(value)}\n`;
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@supersuit/transcript-md",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local transcript.md records: lossless codec, validation, revisions and citations",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./index.mjs",
|
|
12
|
+
"./codec": "./lib/frontmatter/conversation.mjs",
|
|
13
|
+
"./reader": "./lib/freedom-conversations.mjs",
|
|
14
|
+
"./writer": "./lib/freedom-conversation-write.mjs",
|
|
15
|
+
"./citations": "./lib/freedom-conversation-citations.mjs",
|
|
16
|
+
"./schema": "./lib/frontmatter/conversation.schema.json",
|
|
17
|
+
"./python": "./lib/freedom_conversations.py",
|
|
18
|
+
"./cli": "./lib/freedom-conversations-cli.mjs"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"transcript-md": "lib/freedom-conversations-cli.mjs"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"index.mjs",
|
|
25
|
+
"lib/",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"NOTICE",
|
|
28
|
+
"README.md",
|
|
29
|
+
"FORMAT.md",
|
|
30
|
+
"CHANGELOG.md"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node scripts/run-tests.mjs",
|
|
34
|
+
"test:installed": "node scripts/run-tests.mjs --installed",
|
|
35
|
+
"audit:payload": "node scripts/verify-payload.mjs"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/SupersuitUp/transcript-md.git"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|