passalong 0.20.0 → 1.0.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/README.md +62 -72
- package/bin/passalong +217 -378
- package/package.json +4 -4
- package/skill/SKILL.md +55 -167
- package/src/api.js +100 -190
- package/src/capture.js +15 -51
- package/src/checks.js +5 -5
- package/src/hooks.js +9 -18
- package/src/mcp.js +691 -1484
- package/src/passalong.js +365 -702
- package/src/store.js +18 -28
- package/src/work.js +416 -0
- package/src/guide.js +0 -811
package/src/store.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// The local store: ~/.passalong (override with PASSALONG_HOME).
|
|
1
|
+
// The local store: ~/.passalong (override with PASSALONG_HOME). Work are one markdown file each so
|
|
2
2
|
// the whole thing is greppable, git-friendly, and there is nothing to export that isn't
|
|
3
3
|
// already a file you own.
|
|
4
4
|
//
|
|
5
5
|
// ~/.passalong/config.json api url + token
|
|
6
|
-
// ~/.passalong/
|
|
6
|
+
// ~/.passalong/work/<id>.md
|
|
7
7
|
// ~/.passalong/drafts/*.md captured but not yet shared
|
|
8
8
|
import {
|
|
9
9
|
existsSync,
|
|
@@ -11,21 +11,20 @@ import {
|
|
|
11
11
|
readdirSync,
|
|
12
12
|
readFileSync,
|
|
13
13
|
renameSync,
|
|
14
|
-
statSync,
|
|
15
14
|
unlinkSync,
|
|
16
15
|
writeFileSync,
|
|
17
16
|
} from "node:fs";
|
|
18
17
|
import { homedir } from "node:os";
|
|
19
18
|
import { join } from "node:path";
|
|
20
|
-
import { parse, serialize } from "./
|
|
19
|
+
import { parse, serialize } from "./work.js";
|
|
21
20
|
|
|
22
21
|
export const HOME = process.env.PASSALONG_HOME || join(homedir(), ".passalong");
|
|
23
|
-
export const
|
|
22
|
+
export const WORKS = join(HOME, "work");
|
|
24
23
|
export const DRAFTS = join(HOME, "drafts");
|
|
25
24
|
const CONFIG = join(HOME, "config.json");
|
|
26
25
|
|
|
27
26
|
function ensure() {
|
|
28
|
-
for (const d of [HOME,
|
|
27
|
+
for (const d of [HOME, WORKS, DRAFTS])
|
|
29
28
|
if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
30
29
|
}
|
|
31
30
|
|
|
@@ -146,46 +145,37 @@ export function removeAccount(name) {
|
|
|
146
145
|
return { removed: true, active: "" };
|
|
147
146
|
}
|
|
148
147
|
|
|
149
|
-
export function
|
|
150
|
-
return join(
|
|
148
|
+
export function workPath(id) {
|
|
149
|
+
return join(WORKS, `${id}.md`);
|
|
151
150
|
}
|
|
152
151
|
|
|
153
152
|
export function get(id) {
|
|
154
|
-
const p =
|
|
153
|
+
const p = workPath(id);
|
|
155
154
|
if (!existsSync(p)) return null;
|
|
156
155
|
return parse(readFileSync(p, "utf8"));
|
|
157
156
|
}
|
|
158
157
|
|
|
159
|
-
export function save(
|
|
158
|
+
export function save(work) {
|
|
160
159
|
ensure();
|
|
161
|
-
writeFileSync(
|
|
162
|
-
return
|
|
160
|
+
writeFileSync(workPath(work.meta.id), serialize(work));
|
|
161
|
+
return workPath(work.meta.id);
|
|
163
162
|
}
|
|
164
163
|
|
|
165
164
|
export function remove(id) {
|
|
166
|
-
const p =
|
|
165
|
+
const p = workPath(id);
|
|
167
166
|
if (existsSync(p)) unlinkSync(p);
|
|
168
167
|
}
|
|
169
168
|
|
|
170
|
-
/** All local
|
|
169
|
+
/** All local work, newest first. */
|
|
171
170
|
export function list() {
|
|
172
171
|
ensure();
|
|
173
|
-
return readdirSync(
|
|
172
|
+
return readdirSync(WORKS)
|
|
174
173
|
.filter((f) => f.endsWith(".md"))
|
|
175
|
-
.map((f) => parse(readFileSync(join(
|
|
174
|
+
.map((f) => parse(readFileSync(join(WORKS, f), "utf8")))
|
|
176
175
|
.filter((g) => g.meta.id)
|
|
177
176
|
.sort((a, b) => (b.meta.created || "").localeCompare(a.meta.created || ""));
|
|
178
177
|
}
|
|
179
178
|
|
|
180
|
-
/** Draft files, newest first. */
|
|
181
|
-
export function drafts() {
|
|
182
|
-
ensure();
|
|
183
|
-
return readdirSync(DRAFTS)
|
|
184
|
-
.filter((f) => f.endsWith(".md"))
|
|
185
|
-
.map((f) => join(DRAFTS, f))
|
|
186
|
-
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
179
|
export function draftPath(name) {
|
|
190
180
|
ensure();
|
|
191
181
|
return join(DRAFTS, `${name}.md`);
|
|
@@ -196,11 +186,11 @@ export function removeDraft(path) {
|
|
|
196
186
|
}
|
|
197
187
|
|
|
198
188
|
/** Case-insensitive match on title, tags, source context, and body. */
|
|
199
|
-
export function search(query,
|
|
189
|
+
export function search(query, work = list()) {
|
|
200
190
|
const q = query.trim().toLowerCase();
|
|
201
|
-
if (!q) return
|
|
191
|
+
if (!q) return work;
|
|
202
192
|
const terms = q.split(/\s+/);
|
|
203
|
-
return
|
|
193
|
+
return work.filter((g) => {
|
|
204
194
|
const hay = [
|
|
205
195
|
g.meta.title,
|
|
206
196
|
g.meta.source_context,
|
package/src/work.js
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// The work format: plain markdown with a small YAML-ish frontmatter block.
|
|
2
|
+
//
|
|
3
|
+
// No proprietary format and no YAML library. The frontmatter only ever holds strings and
|
|
4
|
+
// lists of strings, so a deliberately tiny parser covers it and stays readable when hand-edited.
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Work is Draft, Ready or Done. Done is the shelf — off the board, out of the free tier's count,
|
|
9
|
+
* reversible. Mirrors STATUSES in apps/api/src/work.ts.
|
|
10
|
+
*/
|
|
11
|
+
export const STATUSES = ["draft", "ready", "done"];
|
|
12
|
+
/**
|
|
13
|
+
* What work says to a person, in a sentence or two, and the longest that may be.
|
|
14
|
+
*
|
|
15
|
+
* Everything an agent writes is written for another agent: exact, dense, full of ids and paths. A
|
|
16
|
+
* person scanning their work wants "what is this, and do I need to do anything", so every piece of work
|
|
17
|
+
* carries both — the document is the agent's form, and `summary:` is the human one, shown by default
|
|
18
|
+
* in the hub with the document behind "Show details".
|
|
19
|
+
*
|
|
20
|
+
* Said by the author and refused when absent: a summary written afterwards by something that was
|
|
21
|
+
* not there is a second voice that can say what nobody claimed.
|
|
22
|
+
*/
|
|
23
|
+
export const SUMMARY_MAX = 400;
|
|
24
|
+
|
|
25
|
+
// Fields that hold a list of strings. Everything else is a plain string.
|
|
26
|
+
/**
|
|
27
|
+
* One tag, in the one style tags are written in: lowercase, words joined by a hyphen.
|
|
28
|
+
*
|
|
29
|
+
* Nothing used to normalise them, so `custom-fields` and `additional_information` could sit on the
|
|
30
|
+
* same work and the same idea exist twice under two spellings. A tag is a controlled vocabulary,
|
|
31
|
+
* not prose — its whole value is that two people who mean the same thing write the same string.
|
|
32
|
+
* Mirrors `tag()` in apps/api/src/work.ts.
|
|
33
|
+
*/
|
|
34
|
+
export function tag(raw) {
|
|
35
|
+
return String(raw ?? "")
|
|
36
|
+
.trim()
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/[\s_]+/g, "-")
|
|
39
|
+
.replace(/[^a-z0-9-]/g, "")
|
|
40
|
+
.replace(/-{2,}/g, "-")
|
|
41
|
+
.slice(0, 32)
|
|
42
|
+
.replace(/^-+|-+$/g, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Work's tags: normalised, emptied of blanks, deduped once two spellings become one. */
|
|
46
|
+
export function tagList(raw) {
|
|
47
|
+
const list = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
|
|
48
|
+
return [...new Set(list.map(tag).filter(Boolean))];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const LIST_FIELDS = new Set(["stack_assumptions", "tags", "blocked_by"]);
|
|
52
|
+
|
|
53
|
+
// IDs are short enough to type and say out loud. 8 chars from a 31-letter alphabet with the
|
|
54
|
+
// look-alikes removed (0/o, 1/l/i) is ~40 bits: plenty for addressing, not a secret.
|
|
55
|
+
const ID_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
|
|
56
|
+
export function newId(length = 8) {
|
|
57
|
+
const bytes = randomBytes(length);
|
|
58
|
+
let out = "";
|
|
59
|
+
for (let i = 0; i < length; i++) out += ID_ALPHABET[bytes[i] % ID_ALPHABET.length];
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
export const ID_RE = /^[a-z0-9]{6,12}$/;
|
|
63
|
+
|
|
64
|
+
function scalar(raw) {
|
|
65
|
+
const v = raw.trim();
|
|
66
|
+
if (v === "") return "";
|
|
67
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
68
|
+
return v.slice(1, -1).replace(/\\"/g, '"');
|
|
69
|
+
}
|
|
70
|
+
return v;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Split a flow sequence's inner text on the commas *between* items, not the ones inside them.
|
|
75
|
+
* `quote()` wraps any value containing a comma, so a plain `split(",")` tears those items apart —
|
|
76
|
+
* and the pieces keep their stray quotes, because `scalar()` only unwraps a value quoted at both
|
|
77
|
+
* ends. Serialising a list and parsing it back has to return the same list.
|
|
78
|
+
*/
|
|
79
|
+
function items(inner) {
|
|
80
|
+
const out = [];
|
|
81
|
+
let buf = "";
|
|
82
|
+
let quoted = null;
|
|
83
|
+
for (let i = 0; i < inner.length; i++) {
|
|
84
|
+
const c = inner[i];
|
|
85
|
+
if (quoted) {
|
|
86
|
+
// `quote()` escapes an inner double quote as \", which does not close the value.
|
|
87
|
+
if (c === "\\" && quoted === '"' && inner[i + 1] === '"') {
|
|
88
|
+
buf += c + inner[i + 1];
|
|
89
|
+
i++;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
buf += c;
|
|
93
|
+
if (c === quoted) quoted = null;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (c === '"' || c === "'") quoted = c;
|
|
97
|
+
if (c === ",") {
|
|
98
|
+
out.push(buf);
|
|
99
|
+
buf = "";
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
buf += c;
|
|
103
|
+
}
|
|
104
|
+
out.push(buf);
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function inlineList(raw) {
|
|
109
|
+
const inner = raw.trim().slice(1, -1).trim();
|
|
110
|
+
if (inner === "") return [];
|
|
111
|
+
return items(inner)
|
|
112
|
+
.map(scalar)
|
|
113
|
+
.filter((s) => s !== "");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Parse a frontmatter block (the text between the --- fences) into an object. */
|
|
117
|
+
export function parseFrontmatter(text) {
|
|
118
|
+
const meta = {};
|
|
119
|
+
const lines = text.split(/\r?\n/);
|
|
120
|
+
let listKey = null;
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
if (line.trim() === "" || line.trim().startsWith("#")) continue;
|
|
123
|
+
// YAML lets a block sequence sit flush with its key, so the indent is optional. Requiring it
|
|
124
|
+
// dropped `- item` lines silently: they match no key either, and the field stayed empty.
|
|
125
|
+
const item = /^\s*-\s*(.*)$/.exec(line);
|
|
126
|
+
if (item && listKey) {
|
|
127
|
+
meta[listKey].push(scalar(item[1]));
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const kv = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line);
|
|
131
|
+
if (!kv) continue;
|
|
132
|
+
const [, key, rest] = kv;
|
|
133
|
+
if (rest.trim() === "" && LIST_FIELDS.has(key)) {
|
|
134
|
+
meta[key] = [];
|
|
135
|
+
listKey = key;
|
|
136
|
+
} else if (rest.trim().startsWith("[") && rest.trim().endsWith("]")) {
|
|
137
|
+
meta[key] = inlineList(rest);
|
|
138
|
+
listKey = null;
|
|
139
|
+
} else {
|
|
140
|
+
meta[key] = LIST_FIELDS.has(key) ? [scalar(rest)] : scalar(rest);
|
|
141
|
+
listKey = null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Only the lists every piece of work has. `blocked_by` is optional, and defaulting it would write
|
|
145
|
+
// an empty one into every document anybody re-shares.
|
|
146
|
+
for (const k of ["stack_assumptions", "tags"]) if (meta[k] === undefined) meta[k] = [];
|
|
147
|
+
// Both ends of this module: what it reads and what it writes are in one style, so work
|
|
148
|
+
// written before there was a rule comes back normalised and goes out normalised.
|
|
149
|
+
meta.tags = tagList(meta.tags);
|
|
150
|
+
return meta;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Frontmatter fields holding more than this format can: a nested map, a block scalar (`|`), a
|
|
155
|
+
* sequence under a field that is not a list. `parseFrontmatter` keeps only the key's own line, so
|
|
156
|
+
* each of these would come back as `""` with its content gone — a design.md's `colors:` block did
|
|
157
|
+
* exactly that, and a publish would have stored the document with every token wiped. The format
|
|
158
|
+
* does not grow to hold them (strings and string lists only); the publish refuses them by name
|
|
159
|
+
* instead, so nothing is lost without its author being told. Mirrors `unheldFields` in
|
|
160
|
+
* apps/api/src/work.ts, which is the refusal every client meets.
|
|
161
|
+
*/
|
|
162
|
+
export function unheldFields(markdown) {
|
|
163
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(markdown);
|
|
164
|
+
if (!m) return [];
|
|
165
|
+
const out = new Set();
|
|
166
|
+
let key = null;
|
|
167
|
+
let listKey = null;
|
|
168
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
169
|
+
if (line.trim() === "" || line.trim().startsWith("#")) continue;
|
|
170
|
+
if (listKey && /^\s*-\s*/.test(line)) continue;
|
|
171
|
+
const kv = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line);
|
|
172
|
+
if (kv) {
|
|
173
|
+
key = kv[1];
|
|
174
|
+
listKey = kv[2].trim() === "" && LIST_FIELDS.has(key) ? key : null;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// Anything else is a line the parser skips: an indented child, or an item under a field
|
|
178
|
+
// that is not a list. It belongs to the key above it, and that is the key to name.
|
|
179
|
+
out.add(key ?? line.trim());
|
|
180
|
+
}
|
|
181
|
+
return [...out];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Fields this format no longer has, refused by name rather than carried. `kind` split work into
|
|
186
|
+
* kinds, each with its own sections, and every rule that turned on it is gone
|
|
187
|
+
* (docs/WORK.md): a document still saying it is one of them is an old export or an old client, and
|
|
188
|
+
* carrying the line would put back an answer nothing reads. `retiredFields` in apps/api/src/work.ts
|
|
189
|
+
* is the refusal every client meets.
|
|
190
|
+
*/
|
|
191
|
+
export const RETIRED = ["kind"];
|
|
192
|
+
|
|
193
|
+
function quote(v) {
|
|
194
|
+
const s = String(v ?? "");
|
|
195
|
+
// Quote anything YAML would misread: colons, leading symbols, or empty.
|
|
196
|
+
return /[:#[\]{}"'|>&*!%@`,]|^\s|\s$|^$/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const META_ORDER = [
|
|
200
|
+
"id",
|
|
201
|
+
"title",
|
|
202
|
+
// Right under the title: it is what a person reads first about it, so it is what the document
|
|
203
|
+
// opens with.
|
|
204
|
+
"summary",
|
|
205
|
+
"created",
|
|
206
|
+
"author",
|
|
207
|
+
"source_context",
|
|
208
|
+
// The repo the work is for. Optional — work without one is not tied to a repo.
|
|
209
|
+
"target_context",
|
|
210
|
+
// The work this waits for, by id: it is not handed to an agent until each is Done. See
|
|
211
|
+
// migrations/0022_blocks.sql.
|
|
212
|
+
"blocked_by",
|
|
213
|
+
// Where it came from, in the two senses work has one: `source_context` is the repo and branch it
|
|
214
|
+
// was written in, `parent` is the work it came *out of*.
|
|
215
|
+
"parent",
|
|
216
|
+
"status",
|
|
217
|
+
"team",
|
|
218
|
+
"to",
|
|
219
|
+
"stack_assumptions",
|
|
220
|
+
"tags",
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
export function serializeFrontmatter(meta) {
|
|
224
|
+
const keys = [
|
|
225
|
+
...META_ORDER.filter((k) => k in meta),
|
|
226
|
+
...Object.keys(meta).filter((k) => !META_ORDER.includes(k)),
|
|
227
|
+
];
|
|
228
|
+
const out = [];
|
|
229
|
+
for (const key of keys) {
|
|
230
|
+
const v = key === "tags" ? tagList(meta[key]) : meta[key];
|
|
231
|
+
if (Array.isArray(v)) {
|
|
232
|
+
out.push(v.length ? `${key}: [${v.map(quote).join(", ")}]` : `${key}: []`);
|
|
233
|
+
} else {
|
|
234
|
+
out.push(`${key}: ${quote(v)}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out.join("\n");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Split a markdown document into { meta, body }. A document with no frontmatter has empty meta. */
|
|
241
|
+
export function parse(markdown) {
|
|
242
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(markdown);
|
|
243
|
+
if (!m) return { meta: parseFrontmatter(""), body: markdown.trim() };
|
|
244
|
+
return { meta: parseFrontmatter(m[1]), body: m[2].trim() };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function serialize({ meta, body }) {
|
|
248
|
+
return `---\n${serializeFrontmatter(meta)}\n---\n\n${body.trim()}\n`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Image targets no reader will ever load. Mirrors `unreachableImages` in apps/api/src/work.ts,
|
|
253
|
+
* which is the authority and refuses the publish; this lets a local share fail before the network.
|
|
254
|
+
*
|
|
255
|
+
* Only a foreign scheme counts — `attachment://`, `blob:`, `file:`, `data:`. `https:` is left alone
|
|
256
|
+
* because embedding an image you host is legitimate, and a relative path is left alone because it
|
|
257
|
+
* has always been accepted.
|
|
258
|
+
*/
|
|
259
|
+
export function unreachableImages(markdown) {
|
|
260
|
+
const bad = [...String(markdown).matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)]
|
|
261
|
+
.map((m) => m[1].trim())
|
|
262
|
+
.filter((target) => {
|
|
263
|
+
const scheme = /^([a-z][a-z0-9+.\-]*):/i.exec(target);
|
|
264
|
+
return Boolean(scheme) && !/^https?$/i.test(scheme[1]);
|
|
265
|
+
});
|
|
266
|
+
return [...new Set(bad)];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Problems that make work unfit to publish. Empty array means it is fine. */
|
|
270
|
+
export function validate({ meta, body }) {
|
|
271
|
+
const errors = [];
|
|
272
|
+
if (!meta.title) errors.push("frontmatter needs a title");
|
|
273
|
+
if (meta.id && !ID_RE.test(meta.id)) errors.push(`id "${meta.id}" is not a valid passalong id`);
|
|
274
|
+
// A parent is optional, and a self-parent is always a mistake worth refusing.
|
|
275
|
+
//
|
|
276
|
+
// A malformed one is not refused, though, and that is deliberate. `parent:` is a field name this
|
|
277
|
+
// format did not reserve until now, so work written months ago may already carry one meaning
|
|
278
|
+
// something else entirely — and work shared then has to still re-share today. Rejecting the
|
|
279
|
+
// value would make that document unpublishable by its own author, which is the failure the
|
|
280
|
+
// STATUSES comment above describes. The server drops a parent it cannot resolve, so the worst
|
|
281
|
+
// case is lineage that silently does not apply rather than work nobody can hand on.
|
|
282
|
+
if (meta.parent && meta.id && meta.parent === meta.id)
|
|
283
|
+
errors.push("work cannot come out of itself");
|
|
284
|
+
if (meta.status && !STATUSES.includes(meta.status)) {
|
|
285
|
+
errors.push(`status must be one of ${STATUSES.join(", ")}`);
|
|
286
|
+
}
|
|
287
|
+
const said = String(meta.summary ?? "").trim();
|
|
288
|
+
if (!said)
|
|
289
|
+
errors.push(
|
|
290
|
+
"say it to a person: add `summary:` — one or two plain sentences, no ids or paths, saying what " +
|
|
291
|
+
`this is and whether anybody needs to act (${SUMMARY_MAX} characters at most)`,
|
|
292
|
+
);
|
|
293
|
+
else if (said.length > SUMMARY_MAX)
|
|
294
|
+
errors.push(`summary is ${said.length} characters; ${SUMMARY_MAX} at most`);
|
|
295
|
+
for (const field of RETIRED)
|
|
296
|
+
if (meta[field] !== undefined)
|
|
297
|
+
errors.push(`\`${field}:\` is gone — work has no kinds; remove the line`);
|
|
298
|
+
// An agent that has a file id but never called add_file writes the id as if it were a link. It
|
|
299
|
+
// publishes, and stores work whose screenshot was never uploaded anywhere.
|
|
300
|
+
for (const target of unreachableImages(body))
|
|
301
|
+
errors.push(
|
|
302
|
+
`${target} is not a URL a reader can load — attach the image first and use the URL it returns`,
|
|
303
|
+
);
|
|
304
|
+
return errors;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Fill in what a fresh work needs before it can be stored. Returns a new object. */
|
|
308
|
+
export function stamp(work, defaults = {}) {
|
|
309
|
+
const meta = { ...work.meta };
|
|
310
|
+
if (!meta.id) meta.id = newId();
|
|
311
|
+
if (!meta.created) meta.created = new Date().toISOString();
|
|
312
|
+
// Ready unless whoever wrote it said draft: work sent to someone is meant to reach them, and
|
|
313
|
+
// drafts were where addressed work sat forgotten (every draft in production on 2026-10-15).
|
|
314
|
+
if (!meta.status) meta.status = "ready";
|
|
315
|
+
for (const [k, v] of Object.entries(defaults))
|
|
316
|
+
if (meta[k] === undefined || meta[k] === "") meta[k] = v;
|
|
317
|
+
return { meta, body: work.body };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---- reading work in parts, and how big one may be -----------------------------------------
|
|
321
|
+
// Mirrors apps/api/src/sections.ts — apps/api/test/sections.test.mjs holds the two to the same
|
|
322
|
+
// answers. Most of what is stored is a few very large work (six of 123 held 72% of the text), and
|
|
323
|
+
// an agent that wants only the Acceptance of a long task should not read the rest first.
|
|
324
|
+
|
|
325
|
+
/** Over this work is large: its readers are offered the outline first, and its author a warning. */
|
|
326
|
+
export const WORK_WARN = 20_000;
|
|
327
|
+
/** Over this a new work is refused by the server. Work stored before it existed may keep its size. */
|
|
328
|
+
export const WORK_MAX = 60_000;
|
|
329
|
+
|
|
330
|
+
/** Where each heading starts, outside the frontmatter and outside code fences. */
|
|
331
|
+
function headingLines(markdown) {
|
|
332
|
+
const fm = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(markdown);
|
|
333
|
+
const bodyStart = fm ? fm[0].length : 0;
|
|
334
|
+
const lines = [];
|
|
335
|
+
let fence = "";
|
|
336
|
+
let at = bodyStart;
|
|
337
|
+
for (const raw of markdown.slice(bodyStart).split("\n")) {
|
|
338
|
+
const mark = /^\s*(```|~~~)/.exec(raw)?.[1];
|
|
339
|
+
if (mark) fence = fence ? (fence === mark ? "" : fence) : mark;
|
|
340
|
+
const m = !fence && !mark ? /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(raw) : null;
|
|
341
|
+
if (m) lines.push({ heading: m[2].trim(), level: m[1].length, start: at });
|
|
342
|
+
at += raw.length + 1;
|
|
343
|
+
}
|
|
344
|
+
return lines;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The end of the section that begins at lines[i]: the next heading at its level or above. */
|
|
348
|
+
function sectionEnd(lines, i, total) {
|
|
349
|
+
for (let j = i + 1; j < lines.length; j++)
|
|
350
|
+
if (lines[j].level <= lines[i].level) return lines[j].start;
|
|
351
|
+
return total;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Every heading, with the characters in its section (nested ones included). */
|
|
355
|
+
export function outline(markdown) {
|
|
356
|
+
const lines = headingLines(markdown);
|
|
357
|
+
return lines.map((l, i) => ({
|
|
358
|
+
heading: l.heading,
|
|
359
|
+
level: l.level,
|
|
360
|
+
chars: sectionEnd(lines, i, markdown.length) - l.start,
|
|
361
|
+
}));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** One section by its heading — exact, then by its start, then by what it contains. */
|
|
365
|
+
export function section(markdown, name) {
|
|
366
|
+
const lines = headingLines(markdown);
|
|
367
|
+
const want = String(name ?? "")
|
|
368
|
+
.trim()
|
|
369
|
+
.toLowerCase();
|
|
370
|
+
if (!want) return null;
|
|
371
|
+
const at = (test) => lines.findIndex((l) => test(l.heading.toLowerCase()));
|
|
372
|
+
const i = [
|
|
373
|
+
at((h) => h === want),
|
|
374
|
+
at((h) => h.startsWith(want)),
|
|
375
|
+
at((h) => h.includes(want)),
|
|
376
|
+
].find((n) => n >= 0);
|
|
377
|
+
if (i === undefined) return null;
|
|
378
|
+
return {
|
|
379
|
+
heading: lines[i].heading,
|
|
380
|
+
text: markdown.slice(lines[i].start, sectionEnd(lines, i, markdown.length)).trimEnd(),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** What the author of a piece of work is told when it is on the large side. */
|
|
385
|
+
export function sizeWarning(chars) {
|
|
386
|
+
return chars > WORK_WARN
|
|
387
|
+
? `this is ${chars.toLocaleString("en")} characters, and over ${WORK_WARN.toLocaleString("en")} ` +
|
|
388
|
+
"it is read by outline first. Put logs, dumps and long output in a file (add_file) and " +
|
|
389
|
+
"link it; keep the text to what the next agent has to act on."
|
|
390
|
+
: null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* What an agent is shown instead of a large work: who it is, what it is, and where its parts are.
|
|
395
|
+
* `path` is where the whole of it was written, for a search rather than a read.
|
|
396
|
+
*/
|
|
397
|
+
export function outlineText(markdown, { id = "", path = "" } = {}) {
|
|
398
|
+
const { meta } = parse(markdown);
|
|
399
|
+
const rows = outline(markdown)
|
|
400
|
+
.filter((h) => h.level <= 3)
|
|
401
|
+
.map(
|
|
402
|
+
(h) => `${" ".repeat(h.level - 1)}- ${h.heading} (${h.chars.toLocaleString("en")} chars)`,
|
|
403
|
+
);
|
|
404
|
+
return [
|
|
405
|
+
`${meta.title || id} — ${markdown.length.toLocaleString("en")} characters`,
|
|
406
|
+
meta.summary ? `Summary: ${meta.summary}` : "",
|
|
407
|
+
"",
|
|
408
|
+
"Sections:",
|
|
409
|
+
...rows,
|
|
410
|
+
"",
|
|
411
|
+
`Read one with find_work id=${id} section="<heading>", or the whole thing with full=true.` +
|
|
412
|
+
(path ? ` The full text is also on disk at ${path}, if you only need to search it.` : ""),
|
|
413
|
+
]
|
|
414
|
+
.filter((l, i, a) => l !== "" || a[i - 1] !== "")
|
|
415
|
+
.join("\n");
|
|
416
|
+
}
|