drafted 1.18.1 → 1.18.2

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,30 @@
1
+ // Single source of truth for the per-project auto-inject context budget.
2
+ // Shared by the MCP gate logic (mcp/gates.mjs) and the server-side deposit caps
3
+ // (server/lib/project-skill-routes.mjs) so web-remote, local-stdio, and raw-API
4
+ // callers all enforce the same limit. ~24k chars ≈ 6k tokens.
5
+ export const PROJECT_CONTEXT_BUDGET_CHARS = 24000;
6
+
7
+ function gateItemChars(it) {
8
+ if (it && typeof it === 'object') {
9
+ if (it.content != null) return String(it.content).length;
10
+ if (typeof it.chars === 'number') return it.chars;
11
+ }
12
+ if (typeof it === 'string') return it.length;
13
+ if (typeof it === 'number') return it;
14
+ return 0;
15
+ }
16
+
17
+ // Greedily include items whose sizes fit the budget (in priority order); defer the
18
+ // rest. Shared by the server-side project-open priming assembly and the MCP gate
19
+ // fallback so both produce identical auto-inject sets.
20
+ export function selectWithinBudget(items, budget = PROJECT_CONTEXT_BUDGET_CHARS, sizeOf = gateItemChars) {
21
+ const included = [];
22
+ const deferred = [];
23
+ let used = 0;
24
+ for (const it of items || []) {
25
+ const size = sizeOf(it);
26
+ if (used + size <= budget) { included.push(it); used += size; }
27
+ else { deferred.push(it); }
28
+ }
29
+ return { included, deferred, used, budget };
30
+ }
@@ -0,0 +1,67 @@
1
+ // Deploy-seeded starting points for the Minion builder ("presets"). Pure data,
2
+ // shared for every org (no per-org copy). Each preset pre-wires the builder
3
+ // config for a use case; the user then edits any field. `config: null` means
4
+ // the full blank/advanced form. See feature card: contexts/minions/feature-card.
5
+ //
6
+ // A preset's `config` is a partial minion the builder hydrates via showConfig:
7
+ // { description, checklist:[{label,evidence,required}], output:{mode,grouping,format,register} }
8
+ // Drive-only bits (format:google-*, register) degrade to a markdown record when
9
+ // the org has no Google Drive connected — the builder simply hides those fields.
10
+ export const MINION_PRESETS = [
11
+ {
12
+ key: 'reports-sheet',
13
+ name: 'Collect reports → spreadsheet',
14
+ icon: '▤',
15
+ tagline: 'A form people fill out. Each one becomes a document + a new row in a master sheet.',
16
+ config: {
17
+ description: '',
18
+ checklist: [
19
+ { label: 'Department', evidence: 'text', required: true },
20
+ { label: 'What happened', evidence: 'text', required: true },
21
+ { label: 'Attachment (file or photo)', evidence: 'file', required: false },
22
+ ],
23
+ output: {
24
+ mode: 'generate',
25
+ grouping: 'frame',
26
+ format: 'google-doc',
27
+ register: { columns: ['Date', 'Department', 'Summary', 'Report link'] },
28
+ },
29
+ },
30
+ },
31
+ {
32
+ key: 'files',
33
+ name: 'Gather files / evidence',
34
+ icon: '▦',
35
+ tagline: 'Collect photos & documents into one organized folder, with a short summary.',
36
+ config: {
37
+ description: '',
38
+ checklist: [
39
+ { label: 'Files or photos', evidence: 'file', required: true },
40
+ { label: 'A short note about them', evidence: 'text', required: false },
41
+ ],
42
+ output: { mode: 'generate', grouping: 'lane' },
43
+ },
44
+ },
45
+ {
46
+ key: 'survey',
47
+ name: 'Survey / intake form',
48
+ icon: '≡',
49
+ tagline: 'Ask a set of questions. One tidy record per response.',
50
+ config: {
51
+ description: '',
52
+ checklist: [
53
+ { label: 'Question 1', evidence: 'text', required: true },
54
+ { label: 'Question 2', evidence: 'text', required: true },
55
+ { label: 'Question 3', evidence: 'text', required: false },
56
+ ],
57
+ output: { mode: 'generate', grouping: 'frame' },
58
+ },
59
+ },
60
+ {
61
+ key: 'blank',
62
+ name: 'Blank (advanced)',
63
+ icon: '+',
64
+ tagline: 'Start from raw settings — full control.',
65
+ config: null,
66
+ },
67
+ ];
@@ -0,0 +1,62 @@
1
+ /**
2
+ * OKF v0.1 log.md formatting (knowledge-catalog okf/SPEC.md, reserved files):
3
+ * a `# ... Log` title, then newest-first `## YYYY-MM-DD` date headings (ISO
4
+ * date ONLY), each with `* **Verb**: message` bullets.
5
+ *
6
+ * Lives in src/shared (shipped in the npm package) because both the server
7
+ * (server/lib/okf.mjs) and the stdio MCP wiki `log` action need it —
8
+ * server/lib is NOT shipped to npm installs.
9
+ */
10
+
11
+ const OKF_LOG_VERBS = ['Update', 'Creation', 'Deprecation', 'Initialization'];
12
+
13
+ /** Normalize a verb: capitalized, defaulting to 'Update'. Unknown verbs pass
14
+ * through capitalized — the leading bold word is an OKF convention, not an
15
+ * enforced enum. */
16
+ export function okfLogVerb(verb) {
17
+ const v = typeof verb === 'string' ? verb.trim() : '';
18
+ if (!v) return 'Update';
19
+ return v[0].toUpperCase() + v.slice(1);
20
+ }
21
+
22
+ /** One OKF log bullet: `* **Verb**: message (agent, HH:MM UTC)` */
23
+ export function formatOkfLogEntry(verb, message, agent, when = new Date()) {
24
+ const hh = String(when.getUTCHours()).padStart(2, '0');
25
+ const mm = String(when.getUTCMinutes()).padStart(2, '0');
26
+ return `* **${okfLogVerb(verb)}**: ${message} (${agent}, ${hh}:${mm} UTC)`;
27
+ }
28
+
29
+ /**
30
+ * Insert `entryLine` under the `## YYYY-MM-DD` heading for `when` (UTC),
31
+ * creating the heading (newest-first, after the `# ... Log` title when one
32
+ * exists) if missing. Legacy `## <ISO datetime> ...` headings are left
33
+ * untouched — permissive, no destructive rewrite.
34
+ */
35
+ export function appendOkfLogEntry(content, entryLine, when = new Date(), title = 'Log') {
36
+ const dateHeading = '## ' + when.toISOString().slice(0, 10);
37
+ const text = content || '';
38
+ if (!text.trim()) {
39
+ return ['# ' + title, '', dateHeading, '', entryLine, ''].join('\n');
40
+ }
41
+ const lines = text.split('\n');
42
+ const idx = lines.findIndex((l) => l.trim() === dateHeading);
43
+ if (idx >= 0) {
44
+ // Append at the end of today's section (before the next heading),
45
+ // skipping past trailing blank lines inside the section.
46
+ let end = idx + 1;
47
+ while (end < lines.length && !/^#{1,6}\s/.test(lines[end])) end++;
48
+ let insertAt = end;
49
+ while (insertAt > idx + 1 && lines[insertAt - 1].trim() === '') insertAt--;
50
+ lines.splice(insertAt, 0, entryLine);
51
+ return lines.join('\n');
52
+ }
53
+ // New date section goes at the top (newest first), after a leading
54
+ // `# ...` document title when present.
55
+ let at = 0;
56
+ if (/^#\s/.test(lines[0])) {
57
+ at = 1;
58
+ while (at < lines.length && lines[at].trim() === '') at++;
59
+ }
60
+ lines.splice(at, 0, dateHeading, '', entryLine, '');
61
+ return lines.join('\n');
62
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Conformance of org-defined record types.
3
+ *
4
+ * Drafted deliberately has NO built-in schema for "Process" or anything else —
5
+ * an org's agents invent their own vocabulary by writing typed wiki pages, and
6
+ * `wiki_pages.type` accepts any string. The cost of that freedom is drift: an
7
+ * agent declares a Process needs `steps`, then six months of writes quietly
8
+ * omit it, and structural queries return partial answers that LOOK complete.
9
+ *
10
+ * This is the drift report. It rejects nothing — a type definition is usually
11
+ * wrong on its first draft, so gating writes on one would be worse than the
12
+ * drift. Enforcement is opt-in per type (`enforced: true`), read by callers
13
+ * that choose to act on it; this module only observes.
14
+ *
15
+ * ONE reserved word: a page typed `RecordType` defines a record type. That is
16
+ * the single fixed point the org does not get to invent — you cannot discover
17
+ * definitions without agreeing on how a definition announces itself. Everything
18
+ * else (the type names, the field names, what they mean) belongs to the org.
19
+ */
20
+
21
+ export const RECORD_TYPE = 'RecordType';
22
+
23
+ /** How many same-typed pages before an undefined type looks like a record type
24
+ * someone forgot to declare. Low enough to catch an ingest early, high enough
25
+ * that a handful of Notes or Concepts never trips it. */
26
+ export const UNDECLARED_CLUSTER_MIN = 5;
27
+
28
+ function isBlank(v) {
29
+ if (v === null || v === undefined) return true;
30
+ if (typeof v === 'string') return v.trim() === '';
31
+ if (Array.isArray(v)) return v.length === 0;
32
+ if (typeof v === 'object') return Object.keys(v).length === 0;
33
+ return false;
34
+ }
35
+
36
+ /**
37
+ * Normalize a `fields` declaration. Both forms are accepted because agents
38
+ * write both:
39
+ * fields: [owner, steps] -> known, not required
40
+ * fields: [{ name: owner, required: true }] -> required
41
+ * A bare string is deliberately NOT treated as required: shorthand should be
42
+ * the cheap way to sketch a type, not a way to accidentally mass-flag pages.
43
+ */
44
+ export function normalizeFields(fields) {
45
+ if (!Array.isArray(fields)) return [];
46
+ const out = [];
47
+ for (const f of fields) {
48
+ if (typeof f === 'string' && f.trim()) {
49
+ out.push({ name: f.trim(), required: false });
50
+ } else if (f && typeof f === 'object' && typeof f.name === 'string' && f.name.trim()) {
51
+ out.push({ name: f.name.trim(), required: f.required === true });
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** The type name a definition page defines: explicit `defines`, else its title. */
58
+ export function definedTypeName(page) {
59
+ const fm = (page && page.frontmatter) || {};
60
+ const explicit = typeof fm.defines === 'string' ? fm.defines.trim() : '';
61
+ if (explicit) return explicit;
62
+ const title = typeof page?.title === 'string' ? page.title.trim() : '';
63
+ return title || null;
64
+ }
65
+
66
+ /**
67
+ * @param pages - every wiki page for the org ({ path, title, type, frontmatter })
68
+ * @returns {{ types: Array, summary: object }}
69
+ * types[]: { type, definedAt, enforced, fields, instances, violations[] }
70
+ * violations[]: { path, missing: string[] }
71
+ *
72
+ * Only types that HAVE a definition are checked. Untyped and ad-hoc pages are
73
+ * none of this report's business — flagging them would bury the real drift.
74
+ */
75
+ export function checkRecordConformance(pages) {
76
+ const all = Array.isArray(pages) ? pages : [];
77
+ const definitions = all.filter((p) => String(p?.type || '').trim() === RECORD_TYPE);
78
+
79
+ const types = [];
80
+ const seen = new Set();
81
+ for (const def of definitions) {
82
+ const typeName = definedTypeName(def);
83
+ if (!typeName) {
84
+ types.push({
85
+ type: null,
86
+ definedAt: def.path,
87
+ enforced: false,
88
+ fields: [],
89
+ instances: 0,
90
+ violations: [],
91
+ error: 'definition declares no type name (set frontmatter `defines`, or give the page a title)',
92
+ });
93
+ continue;
94
+ }
95
+ // Two pages defining the same type is itself drift worth surfacing —
96
+ // otherwise whichever sorts first silently wins.
97
+ if (seen.has(typeName)) {
98
+ types.push({
99
+ type: typeName,
100
+ definedAt: def.path,
101
+ enforced: false,
102
+ fields: [],
103
+ instances: 0,
104
+ violations: [],
105
+ error: 'duplicate definition for this type',
106
+ });
107
+ continue;
108
+ }
109
+ seen.add(typeName);
110
+
111
+ const fm = def.frontmatter || {};
112
+ const fields = normalizeFields(fm.fields);
113
+ const required = fields.filter((f) => f.required).map((f) => f.name);
114
+ const instances = all.filter((p) => String(p?.type || '').trim() === typeName);
115
+
116
+ const violations = [];
117
+ for (const inst of instances) {
118
+ const ifm = inst.frontmatter || {};
119
+ const missing = required.filter((name) => isBlank(ifm[name]));
120
+ if (missing.length) violations.push({ path: inst.path, missing });
121
+ }
122
+
123
+ types.push({
124
+ type: typeName,
125
+ definedAt: def.path,
126
+ enforced: fm.enforced === true,
127
+ fields,
128
+ instances: instances.length,
129
+ violations,
130
+ });
131
+ }
132
+
133
+ types.sort((a, b) => String(a.type || '').localeCompare(String(b.type || '')));
134
+
135
+ // The blind spot this closes: a report that only checks DEFINED types is
136
+ // silent in the most likely failure — an agent asked to "ingest all our X"
137
+ // writes 200 prose pages typed `Page`, defines nothing, and every number
138
+ // below reads zero. Health looks perfect precisely when nothing is
139
+ // queryable. So surface the clusters that LOOK like undeclared record types.
140
+ const defined = new Set(types.map((t) => t.type).filter(Boolean));
141
+ const counts = new Map();
142
+ for (const p of all) {
143
+ const t = String(p?.type || '').trim();
144
+ if (!t || t === RECORD_TYPE || defined.has(t)) continue;
145
+ counts.set(t, (counts.get(t) || 0) + 1);
146
+ }
147
+ const undeclared = [...counts.entries()]
148
+ .filter(([, n]) => n >= UNDECLARED_CLUSTER_MIN)
149
+ .sort((a, b) => b[1] - a[1])
150
+ .map(([type, pages]) => ({
151
+ type,
152
+ pages,
153
+ hint: `${pages} pages share type "${type}" with no RecordType definition — if these get compared or audited, define one so the fields become queryable`,
154
+ }));
155
+
156
+ return {
157
+ types,
158
+ undeclared,
159
+ summary: {
160
+ typesDefined: types.length,
161
+ instances: types.reduce((n, t) => n + t.instances, 0),
162
+ violations: types.reduce((n, t) => n + t.violations.length, 0),
163
+ definitionErrors: types.filter((t) => t.error).length,
164
+ undeclaredClusters: undeclared.length,
165
+ },
166
+ };
167
+ }
@@ -0,0 +1,53 @@
1
+ // Runnable self-check for mergeExcalidrawElements. No framework: `node src/shared/test-excalidraw-merge.mjs`.
2
+ import assert from 'node:assert';
3
+ import { mergeExcalidrawElements } from './excalidraw.mjs';
4
+
5
+ const base = {
6
+ type: 'excalidraw', version: 2, source: 'x',
7
+ elements: [
8
+ { id: 'a', type: 'rectangle', x: 0, y: 0, strokeColor: '#000' },
9
+ { id: 'b', type: 'text', x: 10, y: 10, text: 'hi' },
10
+ { id: 'c', type: 'ellipse', x: 20, y: 20 },
11
+ ],
12
+ appState: {}, files: {},
13
+ };
14
+
15
+ // update (partial, shallow-merge keeps other props) + keeps position
16
+ let r = mergeExcalidrawElements(base, [{ id: 'b', x: 99 }]);
17
+ assert.deepStrictEqual(r.elements.map(e => e.id), ['a', 'b', 'c'], 'order preserved on update');
18
+ assert.strictEqual(r.elements[1].x, 99, 'x updated');
19
+ assert.strictEqual(r.elements[1].text, 'hi', 'other props preserved on partial update');
20
+
21
+ // add appends
22
+ r = mergeExcalidrawElements(base, [{ id: 'd', type: 'diamond', x: 5 }]);
23
+ assert.deepStrictEqual(r.elements.map(e => e.id), ['a', 'b', 'c', 'd'], 'new element appended');
24
+
25
+ // remove drops by id
26
+ r = mergeExcalidrawElements(base, [], ['a', 'c']);
27
+ assert.deepStrictEqual(r.elements.map(e => e.id), ['b'], 'ids removed');
28
+
29
+ // remove wins over upsert of the same id
30
+ r = mergeExcalidrawElements(base, [{ id: 'a', x: 1 }], ['a']);
31
+ assert.deepStrictEqual(r.elements.map(e => e.id), ['b', 'c'], 'remove beats upsert');
32
+
33
+ // accepts a JSON string as stored content (frame.content is a string)
34
+ r = mergeExcalidrawElements(JSON.stringify(base), [{ id: 'e', type: 'text', x: 0 }]);
35
+ assert.strictEqual(r.elements.length, 4, 'parses stored string content');
36
+
37
+ // linear elements without points are repaired (Excalidraw crashes on restore otherwise)
38
+ r = mergeExcalidrawElements({
39
+ ...base,
40
+ elements: [
41
+ { id: 'a1', type: 'arrow', x: 0, y: 0, width: 60, height: 0 },
42
+ { id: 'l1', type: 'line', x: 0, y: 0, width: 0, height: 0 },
43
+ { id: 'ok', type: 'arrow', x: 0, y: 0, width: 10, height: 10, points: [[0, 0], [10, 10]] },
44
+ ],
45
+ }, []);
46
+ assert.deepStrictEqual(r.elements[0].points, [[0, 0], [60, 0]], 'arrow points synthesized from width');
47
+ assert.deepStrictEqual(r.elements[1].points, [[0, 0], [1, 0]], 'degenerate line gets a fallback line');
48
+ assert.deepStrictEqual(r.elements[2].points, [[0, 0], [10, 10]], 'valid points untouched');
49
+
50
+ // upsert without id is rejected
51
+ assert.throws(() => mergeExcalidrawElements(base, [{ x: 1 }]), /must be an object with an id/);
52
+
53
+ console.log('ok: mergeExcalidrawElements');
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Excalidraw diagrams embedded in wiki page markdown.
3
+ *
4
+ * A diagram is a fenced block whose language is `excalidraw` and whose body is
5
+ * an Excalidraw scene. Keeping it inside the page (rather than as a separate
6
+ * frame referenced by link) means the process model and its diagram version,
7
+ * export and move together, and cannot drift apart.
8
+ *
9
+ * The fence pattern here MUST stay identical to the one in renderMarkdown()
10
+ * (server/lib/markdown.mjs) — what we find has to be exactly what renders, or
11
+ * an edit writes back to a block the reader never saw.
12
+ *
13
+ * Addressing: the diagram id lives INSIDE the scene JSON as `draftedDiagramId`,
14
+ * not in the fence info string. The shared renderer's fence regex captures the
15
+ * language as `\w*`, so `\`\`\`excalidraw id=d1` would not match as a fence at
16
+ * all and the block would stop rendering everywhere. Ordinal position was the
17
+ * other option and was rejected: inserting a paragraph above a diagram would
18
+ * silently repoint every id after it.
19
+ */
20
+
21
+ const FENCE_RE = /```(\w*)\n([\s\S]*?)```/g;
22
+ export const DIAGRAM_ID_KEY = 'draftedDiagramId';
23
+
24
+ /** Stable-ish id for a new diagram. Callers pass an existing set to avoid collisions. */
25
+ export function nextDiagramId(taken = new Set()) {
26
+ for (let n = 1; n < 10000; n++) {
27
+ const id = 'd' + n;
28
+ if (!taken.has(id)) return id;
29
+ }
30
+ throw new Error('Too many diagrams on one page');
31
+ }
32
+
33
+ /**
34
+ * Every excalidraw fence in the document, in order.
35
+ * Returns `[{ id, scene, raw, start, end }]`. A block whose body is not valid
36
+ * JSON is still reported (scene null) so a caller can surface it rather than
37
+ * silently skipping a block the reader can see.
38
+ */
39
+ export function findExcalidrawBlocks(md) {
40
+ const text = String(md || '');
41
+ const out = [];
42
+ FENCE_RE.lastIndex = 0;
43
+ let m;
44
+ while ((m = FENCE_RE.exec(text)) !== null) {
45
+ if (m[1] !== 'excalidraw') continue;
46
+ const body = m[2];
47
+ let scene = null;
48
+ try { scene = JSON.parse(body); } catch { /* malformed — reported with scene null */ }
49
+ const id = scene && typeof scene[DIAGRAM_ID_KEY] === 'string' ? scene[DIAGRAM_ID_KEY] : null;
50
+ out.push({ id, scene, raw: body, start: m.index, end: m.index + m[0].length });
51
+ }
52
+ return out;
53
+ }
54
+
55
+ export function getExcalidrawBlock(md, diagramId) {
56
+ return findExcalidrawBlocks(md).find((b) => b.id === diagramId) || null;
57
+ }
58
+
59
+ /** Ids already used on this page — pass to nextDiagramId when inserting. */
60
+ export function usedDiagramIds(md) {
61
+ return new Set(findExcalidrawBlocks(md).map((b) => b.id).filter(Boolean));
62
+ }
63
+
64
+ function serialize(scene, diagramId) {
65
+ // Id first so it survives a human hand-editing the block and is visible at a glance.
66
+ const { [DIAGRAM_ID_KEY]: _drop, ...rest } = scene || {};
67
+ return JSON.stringify({ [DIAGRAM_ID_KEY]: diagramId, ...rest }, null, 2);
68
+ }
69
+
70
+ /**
71
+ * Replace one diagram's scene, leaving every other byte of the page untouched.
72
+ * Throws when the id is absent — a save must never silently no-op or append a
73
+ * duplicate block the user did not ask for.
74
+ */
75
+ export function replaceExcalidrawBlock(md, diagramId, scene) {
76
+ const text = String(md || '');
77
+ const block = getExcalidrawBlock(text, diagramId);
78
+ if (!block) throw new Error(`No excalidraw block with id "${diagramId}" on this page`);
79
+ const fence = '```excalidraw\n' + serialize(scene, diagramId) + '\n```';
80
+ return text.slice(0, block.start) + fence + text.slice(block.end);
81
+ }
82
+
83
+ /** Append a new diagram block and return `{ content, id }`. */
84
+ export function appendExcalidrawBlock(md, scene) {
85
+ const text = String(md || '');
86
+ const id = nextDiagramId(usedDiagramIds(text));
87
+ const fence = '```excalidraw\n' + serialize(scene, id) + '\n```';
88
+ const sep = text && !text.endsWith('\n\n') ? (text.endsWith('\n') ? '\n' : '\n\n') : '';
89
+ return { content: text + sep + fence + '\n', id };
90
+ }