@hobocode/thought-layer 0.7.0 → 0.8.5
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 +5 -2
- package/core/artifacts-io.ts +146 -0
- package/core/artifacts.ts +690 -0
- package/core/index.ts +4 -0
- package/core/merge.ts +2 -1
- package/core/notion-io.ts +292 -0
- package/core/notion.ts +312 -0
- package/core/progress.ts +8 -5
- package/core/state-ops.ts +1 -1
- package/core/sync-io.ts +9 -6
- package/dist/tl.js +1298 -45
- package/extensions/thought-layer.ts +62 -2
- package/package.json +5 -1
- package/prompts/tl-artifacts.md +7 -0
- package/prompts/tl-compliance.md +7 -0
- package/prompts/tl-wiki.md +9 -0
- package/skills/thought-layer-compliance/SKILL.md +139 -0
- package/skills/thought-layer-wiki/SKILL.md +43 -0
package/core/index.ts
CHANGED
|
@@ -22,5 +22,9 @@ export * from "./backend.ts";
|
|
|
22
22
|
export * from "./backend-io.ts";
|
|
23
23
|
export * from "./scaffold.ts";
|
|
24
24
|
export * from "./scaffold-io.ts";
|
|
25
|
+
export * from "./artifacts.ts";
|
|
26
|
+
export * from "./artifacts-io.ts";
|
|
27
|
+
export * from "./notion.ts";
|
|
28
|
+
export * from "./notion-io.ts";
|
|
25
29
|
export * from "./deploy.ts";
|
|
26
30
|
export * from "./deploy-io.ts";
|
package/core/merge.ts
CHANGED
|
@@ -28,7 +28,7 @@ export interface MergeOpts {
|
|
|
28
28
|
theirsTs: number; // their file's writer.ts
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
const ARTIFACT_KEYS = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand"] as const;
|
|
31
|
+
const ARTIFACT_KEYS = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand", "governance"] as const;
|
|
32
32
|
|
|
33
33
|
const num = (v: unknown): number => (typeof v === "number" && !Number.isNaN(v) ? v : 0);
|
|
34
34
|
const genAt = (v: unknown): number =>
|
|
@@ -108,6 +108,7 @@ export function mergeProgressStates(ours: ProgressState, theirs: ProgressState,
|
|
|
108
108
|
prd: artifact("prd"),
|
|
109
109
|
naming: artifact("naming"),
|
|
110
110
|
brand: artifact("brand"),
|
|
111
|
+
governance: artifact("governance"),
|
|
111
112
|
kit: mergeKit(ours.kit, theirs.kit, oursNewer),
|
|
112
113
|
};
|
|
113
114
|
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Node IO for the Notion wiki, shared by the `tl wiki` CLI and the tl_wiki Pi
|
|
2
|
+
// tool. The pure plan (state -> pages + blocks) lives in notion.ts; this calls
|
|
3
|
+
// the Notion REST API directly with the global fetch (Node 18+), so the kit
|
|
4
|
+
// stays dependency-free and the bundle never grows. It find-or-creates the wiki
|
|
5
|
+
// pages idempotently (Notion has no upsert), refreshes their content, and builds
|
|
6
|
+
// an Artifacts database.
|
|
7
|
+
//
|
|
8
|
+
// Secrets: the Notion token is read ONLY from the environment (BYOK), never a
|
|
9
|
+
// CLI flag or tool parameter, never persisted. The parent page id is not a
|
|
10
|
+
// secret, so it may be a flag. The page-id map is machine-local and not synced.
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { git, isGitRepo, loadConfig, resolveWorkspace } from "./sync-io.ts";
|
|
16
|
+
import { slugify } from "./sync.ts";
|
|
17
|
+
import { repoOwnerName } from "./artifacts-io.ts";
|
|
18
|
+
import { STATE_DIR, loadStateFile } from "./state-file.ts";
|
|
19
|
+
import { buildWikiPlan, chunkChildren, type Block, type WikiPlan, type WikiArtifact } from "./notion.ts";
|
|
20
|
+
import type { ArtifactManifest } from "./artifacts.ts";
|
|
21
|
+
import type { StateOpResult } from "./state-ops.ts";
|
|
22
|
+
|
|
23
|
+
const NOTION_API = "https://api.notion.com/v1";
|
|
24
|
+
const NOTION_VERSION = "2022-06-28";
|
|
25
|
+
const MIN_INTERVAL_MS = 350; // ~3 requests/second
|
|
26
|
+
const TOKEN_ENVS = ["THOUGHT_LAYER_NOTION_TOKEN", "NOTION_TOKEN"];
|
|
27
|
+
|
|
28
|
+
export interface WikiRunOptions {
|
|
29
|
+
path?: string; // explicit source state file
|
|
30
|
+
name?: string; // session name (the wiki + id-map key)
|
|
31
|
+
workspace?: string; // sessions workspace label
|
|
32
|
+
dir?: string; // explicit clone dir
|
|
33
|
+
parentPage?: string; // Notion page id or URL the integration is shared with
|
|
34
|
+
replace?: boolean; // recreate the root page from scratch (new ids)
|
|
35
|
+
dryRun?: boolean; // build the plan, report counts, no network
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ok = (message: string, details: Record<string, unknown> = {}): StateOpResult => ({ ok: true, message, details });
|
|
39
|
+
const fail = (message: string, details: Record<string, unknown> = {}): StateOpResult => ({ ok: false, message, details });
|
|
40
|
+
|
|
41
|
+
// ---- machine-local page-id map (mirrors sync.json) ---------------------------
|
|
42
|
+
|
|
43
|
+
interface NotionEntry { rootPageId?: string; areaPageIds?: Record<string, string>; artifactsDbId?: string; updatedAt?: number; }
|
|
44
|
+
interface NotionConfig { schema: number; sessions: Record<string, NotionEntry>; }
|
|
45
|
+
|
|
46
|
+
function notionConfigPath(): string {
|
|
47
|
+
return process.env["THOUGHT_LAYER_NOTION_CONFIG"] || join(homedir(), ".thought-layer", "notion.json");
|
|
48
|
+
}
|
|
49
|
+
function loadNotionConfig(): NotionConfig {
|
|
50
|
+
const p = notionConfigPath();
|
|
51
|
+
if (!existsSync(p)) return { schema: 1, sessions: {} };
|
|
52
|
+
try {
|
|
53
|
+
const raw = JSON.parse(readFileSync(p, "utf8")) as Record<string, unknown>;
|
|
54
|
+
const sessions = raw["sessions"] && typeof raw["sessions"] === "object" ? raw["sessions"] as Record<string, NotionEntry> : {};
|
|
55
|
+
return { schema: 1, sessions };
|
|
56
|
+
} catch { return { schema: 1, sessions: {} }; }
|
|
57
|
+
}
|
|
58
|
+
function saveNotionConfig(cfg: NotionConfig): void {
|
|
59
|
+
const p = notionConfigPath();
|
|
60
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
61
|
+
writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- helpers -----------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
// Accept a raw 32-hex id, a dashed UUID, or a Notion URL; return a dashed UUID.
|
|
67
|
+
export function pageIdFromInput(s: string): string | null {
|
|
68
|
+
const t = String(s || "").trim();
|
|
69
|
+
const dashed = t.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
|
|
70
|
+
if (dashed) return dashed[0].toLowerCase();
|
|
71
|
+
const runs = t.match(/[0-9a-fA-F]{32}/g);
|
|
72
|
+
if (!runs || !runs.length) return null;
|
|
73
|
+
const id = runs[runs.length - 1]!.toLowerCase();
|
|
74
|
+
return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
78
|
+
|
|
79
|
+
// A small serial Notion client: paces requests to ~3/s and backs off on 429.
|
|
80
|
+
class Notion {
|
|
81
|
+
private last = 0;
|
|
82
|
+
constructor(private token: string) {}
|
|
83
|
+
|
|
84
|
+
async call(method: string, path: string, body?: unknown): Promise<{ status: number; json: Record<string, unknown> }> {
|
|
85
|
+
for (let attempt = 0; ; attempt++) {
|
|
86
|
+
const wait = MIN_INTERVAL_MS - (Date.now() - this.last);
|
|
87
|
+
if (wait > 0) await sleep(wait);
|
|
88
|
+
this.last = Date.now();
|
|
89
|
+
const res = await fetch(`${NOTION_API}${path}`, {
|
|
90
|
+
method,
|
|
91
|
+
headers: {
|
|
92
|
+
Authorization: `Bearer ${this.token}`,
|
|
93
|
+
"Notion-Version": NOTION_VERSION,
|
|
94
|
+
"Content-Type": "application/json",
|
|
95
|
+
},
|
|
96
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
97
|
+
});
|
|
98
|
+
if (res.status === 429 && attempt < 4) {
|
|
99
|
+
const retry = Number(res.headers.get("Retry-After")) || Math.pow(2, attempt);
|
|
100
|
+
await sleep(retry * 1000);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
let json: Record<string, unknown> = {};
|
|
104
|
+
try { json = (await res.json()) as Record<string, unknown>; } catch { /* empty body */ }
|
|
105
|
+
return { status: res.status, json };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async pageExists(id: string): Promise<boolean> {
|
|
110
|
+
const r = await this.call("GET", `/pages/${id}`);
|
|
111
|
+
return r.status === 200 && r.json["archived"] !== true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async createPage(parentPageId: string, title: string, emoji?: string): Promise<string> {
|
|
115
|
+
const r = await this.call("POST", "/pages", {
|
|
116
|
+
parent: { page_id: parentPageId },
|
|
117
|
+
...(emoji ? { icon: { type: "emoji", emoji } } : {}),
|
|
118
|
+
properties: { title: { title: [{ text: { content: title } }] } },
|
|
119
|
+
});
|
|
120
|
+
if (r.status !== 200) throw new Error(notionErr("create page", r));
|
|
121
|
+
return String(r.json["id"]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Replace a page's content: delete every existing child, then append the new
|
|
125
|
+
// blocks in <=100-block batches.
|
|
126
|
+
async replaceChildren(pageId: string, blocks: Block[]): Promise<void> {
|
|
127
|
+
let cursor: string | undefined;
|
|
128
|
+
do {
|
|
129
|
+
const q = cursor ? `?start_cursor=${cursor}&page_size=100` : "?page_size=100";
|
|
130
|
+
const r = await this.call("GET", `/blocks/${pageId}/children${q}`);
|
|
131
|
+
if (r.status !== 200) break;
|
|
132
|
+
for (const b of (r.json["results"] as Array<{ id: string }>) || []) await this.call("DELETE", `/blocks/${b.id}`);
|
|
133
|
+
cursor = r.json["has_more"] ? String(r.json["next_cursor"]) : undefined;
|
|
134
|
+
} while (cursor);
|
|
135
|
+
for (const batch of chunkChildren(blocks)) {
|
|
136
|
+
if (!batch.length) continue;
|
|
137
|
+
const r = await this.call("PATCH", `/blocks/${pageId}/children`, { children: batch });
|
|
138
|
+
if (r.status !== 200) throw new Error(notionErr("append blocks", r));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async createArtifactsDb(parentPageId: string, artifacts: WikiArtifact[]): Promise<string> {
|
|
143
|
+
const cats = Array.from(new Set(artifacts.map((a) => a.category)));
|
|
144
|
+
const r = await this.call("POST", "/databases", {
|
|
145
|
+
parent: { type: "page_id", page_id: parentPageId },
|
|
146
|
+
title: [{ text: { content: "Artifacts" } }],
|
|
147
|
+
icon: { type: "emoji", emoji: "📎" },
|
|
148
|
+
properties: {
|
|
149
|
+
Name: { title: {} },
|
|
150
|
+
Category: { select: { options: cats.map((c) => ({ name: c })) } },
|
|
151
|
+
Size: { rich_text: {} },
|
|
152
|
+
Link: { url: {} },
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
if (r.status !== 200) throw new Error(notionErr("create database", r));
|
|
156
|
+
return String(r.json["id"]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async addArtifactRow(dbId: string, a: WikiArtifact): Promise<void> {
|
|
160
|
+
await this.call("POST", "/pages", {
|
|
161
|
+
parent: { database_id: dbId },
|
|
162
|
+
properties: {
|
|
163
|
+
Name: { title: [{ text: { content: a.name } }] },
|
|
164
|
+
Category: { select: { name: a.category } },
|
|
165
|
+
Size: { rich_text: [{ text: { content: humanSize(a.bytes) } }] },
|
|
166
|
+
...(a.url ? { Link: { url: a.url } } : {}),
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function notionErr(what: string, r: { status: number; json: Record<string, unknown> }): string {
|
|
173
|
+
const msg = String(r.json["message"] || "").slice(0, 200);
|
|
174
|
+
return `Notion ${what} failed (${r.status})${msg ? `: ${msg}` : ""}`;
|
|
175
|
+
}
|
|
176
|
+
function humanSize(bytes: number): string {
|
|
177
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
178
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
179
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---- the orchestrator --------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
export async function runWiki(opts: WikiRunOptions): Promise<StateOpResult> {
|
|
185
|
+
try {
|
|
186
|
+
const cfg = loadConfig();
|
|
187
|
+
const { cloneDir, ws } = resolveWorkspace(opts as never, cfg);
|
|
188
|
+
if (!isGitRepo(cloneDir)) {
|
|
189
|
+
return fail(`No sessions workspace at ${cloneDir}. Run tl sync init then tl artifacts before building the wiki.`, { cloneDir });
|
|
190
|
+
}
|
|
191
|
+
const slug = slugify(opts.name || ws?.activeSession?.replace(/\.json$/, "") || "");
|
|
192
|
+
if (!slug) return fail("Name the session to publish: tl wiki --name <name>.");
|
|
193
|
+
|
|
194
|
+
// Load the session state.
|
|
195
|
+
const sessionPath = join(cloneDir, STATE_DIR, `${slug}.json`);
|
|
196
|
+
const useExplicit = !!((opts.path && opts.path.trim()) || (process.env["THOUGHT_LAYER_STATE"] || "").trim());
|
|
197
|
+
const loaded = loadStateFile(useExplicit ? opts.path : existsSync(sessionPath) ? sessionPath : opts.path);
|
|
198
|
+
if (!loaded.exists) return fail(`No session "${slug}" found (looked at ${loaded.path}). Save it first with tl sync save --name ${slug}.`, { cloneDir });
|
|
199
|
+
|
|
200
|
+
// Read the delivered artifacts manifest + recompute GitHub URLs (if delivered).
|
|
201
|
+
const artifactsDir = join(cloneDir, "artifacts", slug);
|
|
202
|
+
let manifest: ArtifactManifest | null = null;
|
|
203
|
+
const manifestPath = join(artifactsDir, "artifacts.json");
|
|
204
|
+
if (existsSync(manifestPath)) {
|
|
205
|
+
try { manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as ArtifactManifest; } catch { manifest = null; }
|
|
206
|
+
}
|
|
207
|
+
const branch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out.trim() || ws?.defaultBranch || "main";
|
|
208
|
+
const ownerName = repoOwnerName(ws?.repo || "");
|
|
209
|
+
const urls: Record<string, string> = {};
|
|
210
|
+
if (ownerName && manifest) {
|
|
211
|
+
const base = `https://github.com/${ownerName}/blob/${branch}/artifacts/${slug}`;
|
|
212
|
+
for (const f of manifest.files) urls[f.path] = `${base}/${f.path.split("/").map(encodeURIComponent).join("/")}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const plan: WikiPlan = buildWikiPlan(loaded.state, { manifest, urls });
|
|
216
|
+
const blockCount = plan.overview.length + plan.areas.reduce((n, a) => n + a.blocks.length, 0);
|
|
217
|
+
|
|
218
|
+
if (opts.dryRun) {
|
|
219
|
+
const areaList = plan.areas.map((a) => `${a.emoji} ${a.title} (${a.blocks.length} blocks)`).join(", ");
|
|
220
|
+
return ok(
|
|
221
|
+
`Dry run for "${plan.title}": ${plan.areas.length} area page(s), ${blockCount} blocks total, ${plan.artifacts.length} artifact(s) in the database.\nAreas: ${areaList || "(none with content yet)"}.${manifest ? "" : "\nNo delivered artifacts found; run tl artifacts first to populate the Artifacts database with GitHub links."}`,
|
|
222
|
+
{ title: plan.title, areas: plan.areas.map((a) => ({ key: a.key, blocks: a.blocks.length })), blockCount, artifacts: plan.artifacts.length, delivered: !!manifest },
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Token (BYOK, env only) and parent page are required for the live run.
|
|
227
|
+
const token = TOKEN_ENVS.map((e) => process.env[e]).find((v) => v && v.trim())?.trim() || "";
|
|
228
|
+
if (!token) {
|
|
229
|
+
return fail(
|
|
230
|
+
"No Notion token. Create an internal integration at https://www.notion.so/my-integrations, copy its secret, and set THOUGHT_LAYER_NOTION_TOKEN. Then share a Notion page with the integration and pass it as --parent-page.",
|
|
231
|
+
{ needs: "token" },
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const ncfg = loadNotionConfig();
|
|
236
|
+
const entry: NotionEntry = ncfg.sessions[slug] || {};
|
|
237
|
+
const notion = new Notion(token);
|
|
238
|
+
|
|
239
|
+
// Resolve / verify the root page. With --replace, force a fresh root.
|
|
240
|
+
let rootId = opts.replace ? "" : entry.rootPageId || "";
|
|
241
|
+
if (rootId && !(await notion.pageExists(rootId))) rootId = "";
|
|
242
|
+
if (!rootId) {
|
|
243
|
+
const parentInput = opts.parentPage || process.env["THOUGHT_LAYER_NOTION_PARENT"] || "";
|
|
244
|
+
const parentId = pageIdFromInput(parentInput);
|
|
245
|
+
if (!parentId) {
|
|
246
|
+
return fail(
|
|
247
|
+
"No Notion parent page. Share a page with your integration in Notion (Share, then add your integration), then pass it as --parent-page <id or url>.",
|
|
248
|
+
{ needs: "parent-page" },
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (!(await notion.pageExists(parentId))) {
|
|
252
|
+
return fail(`Notion cannot see the parent page ${parentId}. In Notion, open the page, click Share, and add your integration so it has access.`, { needs: "share" });
|
|
253
|
+
}
|
|
254
|
+
rootId = await notion.createPage(parentId, plan.title, plan.icon);
|
|
255
|
+
entry.areaPageIds = {};
|
|
256
|
+
entry.artifactsDbId = undefined;
|
|
257
|
+
}
|
|
258
|
+
await notion.replaceChildren(rootId, plan.overview);
|
|
259
|
+
|
|
260
|
+
// Area child pages: find-or-create, then refresh content.
|
|
261
|
+
const areaIds: Record<string, string> = entry.areaPageIds || {};
|
|
262
|
+
for (const area of plan.areas) {
|
|
263
|
+
let pid = areaIds[area.key] || "";
|
|
264
|
+
if (pid && !(await notion.pageExists(pid))) pid = "";
|
|
265
|
+
if (!pid) pid = await notion.createPage(rootId, `${area.emoji} ${area.title}`, area.emoji);
|
|
266
|
+
areaIds[area.key] = pid;
|
|
267
|
+
await notion.replaceChildren(pid, area.blocks);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Artifacts database: create once, then add a row per artifact. (Idempotent
|
|
271
|
+
// re-runs reuse the stored db; rows are appended, so --replace gives a clean
|
|
272
|
+
// db by recreating the whole wiki.)
|
|
273
|
+
let dbId = entry.artifactsDbId || "";
|
|
274
|
+
if (plan.artifacts.length && (!dbId || opts.replace)) {
|
|
275
|
+
dbId = await notion.createArtifactsDb(rootId, plan.artifacts);
|
|
276
|
+
for (const a of plan.artifacts) await notion.addArtifactRow(dbId, a);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
ncfg.sessions[slug] = { rootPageId: rootId, areaPageIds: areaIds, artifactsDbId: dbId || undefined, updatedAt: Date.now() };
|
|
280
|
+
saveNotionConfig(ncfg);
|
|
281
|
+
|
|
282
|
+
const rootUrl = `https://www.notion.so/${rootId.replace(/-/g, "")}`;
|
|
283
|
+
return ok(
|
|
284
|
+
`Built the "${plan.title}" wiki in Notion: ${plan.areas.length} area page(s), ${blockCount} blocks, ${plan.artifacts.length} artifact(s) listed.` +
|
|
285
|
+
`${manifest ? "" : " No delivered artifacts were found, so the Artifacts database links are empty; run tl artifacts first."}` +
|
|
286
|
+
`\nOpen it: ${rootUrl}`,
|
|
287
|
+
{ title: plan.title, rootPageId: rootId, rootUrl, areas: plan.areas.length, blockCount, artifacts: plan.artifacts.length },
|
|
288
|
+
);
|
|
289
|
+
} catch (e) {
|
|
290
|
+
return fail(`tl_wiki error: ${(e as Error).message}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
package/core/notion.ts
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// Pure mapping from a ProgressState (+ the delivered-artifacts manifest) to a
|
|
2
|
+
// Notion "wiki" plan: a root page, one child page per workflow area, and an
|
|
3
|
+
// Artifacts database. No fetch, no fs - the notion-io layer turns this plan into
|
|
4
|
+
// API calls. Kept pure so the block construction and the API-limit chunking are
|
|
5
|
+
// unit-testable without a token.
|
|
6
|
+
//
|
|
7
|
+
// Notion API limits this encodes: rich_text content max 2000 chars (chunkRichText),
|
|
8
|
+
// at most 100 children per append (chunkChildren), and at most 2 levels of block
|
|
9
|
+
// nesting per create/append (withinDepth guards it; we keep tables/toggles to one
|
|
10
|
+
// nested level). Notion has no upsert, so the io layer find-or-creates by stored id.
|
|
11
|
+
|
|
12
|
+
import { computeProjection, fmtMoney, type Assumptions } from "./model.ts";
|
|
13
|
+
import {
|
|
14
|
+
brandGuideMarkdown, requirementsMarkdown, glossaryMarkdown, swotMarkdown,
|
|
15
|
+
type Brand, type Grill, type Swot, type ArtifactManifest,
|
|
16
|
+
} from "./artifacts.ts";
|
|
17
|
+
import type { ProgressState } from "./progress.ts";
|
|
18
|
+
|
|
19
|
+
// Notion's free-tier single-file upload ceiling. Larger files link to GitHub.
|
|
20
|
+
export const NOTION_FREE_FILE_LIMIT = 5 * 1024 * 1024;
|
|
21
|
+
const RICH_TEXT_MAX = 2000;
|
|
22
|
+
const CHILDREN_MAX = 100;
|
|
23
|
+
|
|
24
|
+
export type Annotations = Partial<Record<"bold" | "italic" | "strikethrough" | "underline" | "code", boolean>> & { color?: string };
|
|
25
|
+
export interface RichText { type: "text"; text: { content: string; link?: { url: string } | null }; annotations?: Annotations; }
|
|
26
|
+
export type Block = Record<string, unknown>;
|
|
27
|
+
|
|
28
|
+
const obj = (v: unknown): Record<string, unknown> => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {});
|
|
29
|
+
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
|
30
|
+
|
|
31
|
+
// ---- rich text (with the 2000-char split) ------------------------------------
|
|
32
|
+
|
|
33
|
+
const rtSeg = (content: string, ann?: Annotations, link?: string): RichText => ({
|
|
34
|
+
type: "text",
|
|
35
|
+
text: { content, ...(link ? { link: { url: link } } : {}) },
|
|
36
|
+
...(ann ? { annotations: ann } : {}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Split a string into rich_text segments no longer than 2000 chars each.
|
|
40
|
+
export function chunkRichText(text: string, ann?: Annotations, link?: string): RichText[] {
|
|
41
|
+
const s = String(text ?? "");
|
|
42
|
+
if (s.length <= RICH_TEXT_MAX) return [rtSeg(s, ann, link)];
|
|
43
|
+
const out: RichText[] = [];
|
|
44
|
+
for (let i = 0; i < s.length; i += RICH_TEXT_MAX) out.push(rtSeg(s.slice(i, i + RICH_TEXT_MAX), ann, link));
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Minimal inline markdown: **bold** and `code`. Everything else is plain text.
|
|
49
|
+
// Every segment is itself chunked to the 2000-char limit.
|
|
50
|
+
function inline(text: string): RichText[] {
|
|
51
|
+
const out: RichText[] = [];
|
|
52
|
+
const re = /\*\*([^*]+)\*\*|`([^`]+)`/g;
|
|
53
|
+
let last = 0;
|
|
54
|
+
let m: RegExpExecArray | null;
|
|
55
|
+
while ((m = re.exec(text))) {
|
|
56
|
+
if (m.index > last) out.push(...chunkRichText(text.slice(last, m.index)));
|
|
57
|
+
if (m[1] != null) out.push(...chunkRichText(m[1], { bold: true }));
|
|
58
|
+
else if (m[2] != null) out.push(...chunkRichText(m[2], { code: true }));
|
|
59
|
+
last = re.lastIndex;
|
|
60
|
+
}
|
|
61
|
+
if (last < text.length) out.push(...chunkRichText(text.slice(last)));
|
|
62
|
+
return out.length ? out : [rtSeg("")];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---- block builders ----------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const para = (text: string): Block => ({ object: "block", type: "paragraph", paragraph: { rich_text: inline(text) } });
|
|
68
|
+
const heading = (level: 1 | 2 | 3, text: string): Block => {
|
|
69
|
+
const t = `heading_${level}`;
|
|
70
|
+
return { object: "block", type: t, [t]: { rich_text: inline(text) } };
|
|
71
|
+
};
|
|
72
|
+
const bullet = (text: string): Block => ({ object: "block", type: "bulleted_list_item", bulleted_list_item: { rich_text: inline(text) } });
|
|
73
|
+
const numbered = (text: string): Block => ({ object: "block", type: "numbered_list_item", numbered_list_item: { rich_text: inline(text) } });
|
|
74
|
+
const quote = (text: string): Block => ({ object: "block", type: "quote", quote: { rich_text: inline(text) } });
|
|
75
|
+
const callout = (text: string, emoji = "💡"): Block => ({ object: "block", type: "callout", callout: { rich_text: inline(text), icon: { type: "emoji", emoji } } });
|
|
76
|
+
const divider = (): Block => ({ object: "block", type: "divider", divider: {} });
|
|
77
|
+
const codeBlock = (text: string, language = "plain text"): Block => ({ object: "block", type: "code", code: { rich_text: chunkRichText(text), language } });
|
|
78
|
+
export const bookmark = (url: string): Block => ({ object: "block", type: "bookmark", bookmark: { url } });
|
|
79
|
+
export const externalImage = (url: string): Block => ({ object: "block", type: "image", image: { type: "external", external: { url } } });
|
|
80
|
+
|
|
81
|
+
function table(headers: string[], rows: string[][]): Block {
|
|
82
|
+
const toRow = (cells: string[]): Block => ({ object: "block", type: "table_row", table_row: { cells: cells.map((c) => chunkRichText(c)) } });
|
|
83
|
+
return {
|
|
84
|
+
object: "block",
|
|
85
|
+
type: "table",
|
|
86
|
+
table: { table_width: headers.length, has_column_header: true, has_row_header: false, children: [toRow(headers), ...rows.map(toRow)] },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---- markdown -> blocks (the workhorse; all artifacts are markdown) ----------
|
|
91
|
+
|
|
92
|
+
export function markdownToBlocks(md: string): Block[] {
|
|
93
|
+
const lines = String(md ?? "").split("\n");
|
|
94
|
+
const out: Block[] = [];
|
|
95
|
+
let i = 0;
|
|
96
|
+
while (i < lines.length) {
|
|
97
|
+
const raw = lines[i] ?? "";
|
|
98
|
+
const t = raw.trim();
|
|
99
|
+
if (t.startsWith("```")) {
|
|
100
|
+
const buf: string[] = [];
|
|
101
|
+
i++;
|
|
102
|
+
while (i < lines.length && !(lines[i] ?? "").trim().startsWith("```")) { buf.push(lines[i] ?? ""); i++; }
|
|
103
|
+
i++; // skip closing fence
|
|
104
|
+
out.push(codeBlock(buf.join("\n")));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!t) { i++; continue; }
|
|
108
|
+
if (t === "---" || t === "***" || t === "___") { out.push(divider()); i++; continue; }
|
|
109
|
+
const mh = t.match(/^(#{1,6})\s+(.*)$/);
|
|
110
|
+
if (mh) { out.push(heading(Math.min(3, mh[1]!.length) as 1 | 2 | 3, mh[2]!)); i++; continue; }
|
|
111
|
+
if (t.startsWith("> ")) { out.push(quote(t.slice(2))); i++; continue; }
|
|
112
|
+
const mb = t.match(/^[-*]\s+(.*)$/);
|
|
113
|
+
if (mb) { out.push(bullet(mb[1]!)); i++; continue; }
|
|
114
|
+
const mn = t.match(/^\d+\.\s+(.*)$/);
|
|
115
|
+
if (mn) { out.push(numbered(mn[1]!)); i++; continue; }
|
|
116
|
+
out.push(para(t));
|
|
117
|
+
i++;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- API-limit helpers (unit-tested) -----------------------------------------
|
|
123
|
+
|
|
124
|
+
// Split a children array into <=100-block append batches.
|
|
125
|
+
export function chunkChildren(blocks: Block[], size = CHILDREN_MAX): Block[][] {
|
|
126
|
+
const out: Block[][] = [];
|
|
127
|
+
for (let i = 0; i < blocks.length; i += size) out.push(blocks.slice(i, i + size));
|
|
128
|
+
return out.length ? out : [[]];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// True when no block nests deeper than maxDepth levels (Notion allows 2 per
|
|
132
|
+
// create/append). We keep tables/toggles to a single nested level, so this holds.
|
|
133
|
+
export function withinDepth(blocks: Block[], maxDepth = 2): boolean {
|
|
134
|
+
const depth = (bs: Block[], d: number): number => {
|
|
135
|
+
let max = d;
|
|
136
|
+
for (const b of bs) {
|
|
137
|
+
const t = b["type"] as string;
|
|
138
|
+
const inner = (obj(b[t])["children"]) as Block[] | undefined;
|
|
139
|
+
if (Array.isArray(inner) && inner.length) max = Math.max(max, depth(inner, d + 1));
|
|
140
|
+
}
|
|
141
|
+
return max;
|
|
142
|
+
};
|
|
143
|
+
return depth(blocks, 1) <= maxDepth;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- artifact references (link vs upload decision) ---------------------------
|
|
147
|
+
|
|
148
|
+
export function artifactCategory(path: string): string {
|
|
149
|
+
if (path.startsWith("Brand/")) return "Brand";
|
|
150
|
+
if (path.startsWith("Deploy/")) return "Deploy";
|
|
151
|
+
if (path.startsWith("LandingPage/")) return "Landing";
|
|
152
|
+
if (path.endsWith(".svg")) return "Infographic";
|
|
153
|
+
if (path === "BuildPrompt.md") return "Build prompt";
|
|
154
|
+
return "Doc";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Decide how an artifact appears in the wiki: upload to Notion when small enough
|
|
158
|
+
// and an uploadable type, else link to its GitHub copy.
|
|
159
|
+
export function artifactRef(bytes: number, hasGithubUrl: boolean): "upload" | "link" {
|
|
160
|
+
return bytes <= NOTION_FREE_FILE_LIMIT && !hasGithubUrl ? "upload" : hasGithubUrl ? "link" : "upload";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- the wiki plan -----------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
export interface WikiArtifact { name: string; path: string; category: string; bytes: number; url?: string; }
|
|
166
|
+
export interface WikiArea { key: string; title: string; emoji: string; blocks: Block[]; }
|
|
167
|
+
export interface WikiPlan { title: string; icon: string; overview: Block[]; areas: WikiArea[]; artifacts: WikiArtifact[]; }
|
|
168
|
+
|
|
169
|
+
export interface WikiBuildOptions {
|
|
170
|
+
manifest?: ArtifactManifest | null;
|
|
171
|
+
urls?: Record<string, string>; // artifact relpath -> GitHub blob URL
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The 8 workflow areas, in order, with their emoji.
|
|
175
|
+
export const WIKI_AREAS: Array<{ key: string; title: string; emoji: string }> = [
|
|
176
|
+
{ key: "big-idea", title: "The Big Idea", emoji: "💡" },
|
|
177
|
+
{ key: "business-model", title: "Business Model", emoji: "💰" },
|
|
178
|
+
{ key: "brand", title: "Brand", emoji: "🎨" },
|
|
179
|
+
{ key: "market-research", title: "Market Research", emoji: "📊" },
|
|
180
|
+
{ key: "strategy", title: "Strategy", emoji: "📈" },
|
|
181
|
+
{ key: "product", title: "Product (PRD)", emoji: "📋" },
|
|
182
|
+
{ key: "compliance", title: "Compliance & Tax", emoji: "⚖️" },
|
|
183
|
+
{ key: "decision-science", title: "Decision Science", emoji: "🧭" },
|
|
184
|
+
{ key: "library", title: "Library", emoji: "📚" },
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
export function buildWikiPlan(state: ProgressState, opts: WikiBuildOptions = {}): WikiPlan {
|
|
188
|
+
const answers = obj(state.answers);
|
|
189
|
+
const brand = (state.brand && typeof state.brand === "object" ? state.brand : null) as Brand | null;
|
|
190
|
+
const guide = brand?.guide || null;
|
|
191
|
+
const grill = (state.grill && typeof state.grill === "object" ? state.grill : null) as Grill | null;
|
|
192
|
+
const swot = (state.swot && typeof state.swot === "object" ? state.swot : null) as Swot | null;
|
|
193
|
+
const prd = obj(state.prd);
|
|
194
|
+
const bizModel = obj(state.bizModel);
|
|
195
|
+
const research = obj(state.research);
|
|
196
|
+
const assumptions = (bizModel["assumptions"] || null) as Assumptions | null;
|
|
197
|
+
const brandName = str(guide?.brandName) || "Your Product";
|
|
198
|
+
const oneLiner = str(answers["what-statement"]) || str(answers["pitch"]) || str(guide?.positioning);
|
|
199
|
+
|
|
200
|
+
const overview: Block[] = [];
|
|
201
|
+
if (oneLiner) overview.push(callout(oneLiner, "💡"));
|
|
202
|
+
overview.push(para("This private workspace was generated by The Thought Layer. Each section below is a page; the Artifacts database links the files delivered to your repo."));
|
|
203
|
+
|
|
204
|
+
const areaBlocks: Record<string, Block[]> = {};
|
|
205
|
+
|
|
206
|
+
// Big idea: the one-liner, positioning, and the press release if present.
|
|
207
|
+
{
|
|
208
|
+
const b: Block[] = [];
|
|
209
|
+
if (oneLiner) b.push(heading(2, "What it is"), callout(oneLiner, "🎯"));
|
|
210
|
+
if (guide?.positioning) b.push(heading(2, "Who it is for"), para(guide.positioning));
|
|
211
|
+
const press = str(answers["press-release"]);
|
|
212
|
+
if (press) b.push(heading(2, "The press release"), ...markdownToBlocks(press));
|
|
213
|
+
areaBlocks["big-idea"] = b;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Business model: a parties table + the projection summary.
|
|
217
|
+
{
|
|
218
|
+
const b: Block[] = [];
|
|
219
|
+
const proj = computeProjection(assumptions);
|
|
220
|
+
if (proj && assumptions) {
|
|
221
|
+
const s = proj.summary;
|
|
222
|
+
const cur = assumptions.currency || "USD";
|
|
223
|
+
b.push(heading(2, "The numbers"));
|
|
224
|
+
b.push(callout(
|
|
225
|
+
`Year 1 revenue ${fmtMoney(s.year1Revenue, cur)}. Monthly break-even ${s.breakEvenMonth ? `month ${s.breakEvenMonth}` : "beyond horizon"}. Max cash drawdown ${fmtMoney(s.maxDrawdown, cur)}. MRR at month ${s.horizon} is ${fmtMoney(s.endingMRR, cur)}.`,
|
|
226
|
+
"💰",
|
|
227
|
+
));
|
|
228
|
+
const rows = (assumptions.parties || []).slice(0, 12).map((p) => [
|
|
229
|
+
str(p.name) || p.id, str(p.role), String(p.startingCount ?? ""), String(p.monthlyNewBase ?? ""),
|
|
230
|
+
`${p.monthlyChurnPct ?? 0}%`, fmtMoney(Number(p.revenuePerUnitPerMonth) || 0, cur), fmtMoney(Number(p.cacPerUnit) || 0, cur),
|
|
231
|
+
]);
|
|
232
|
+
b.push(heading(2, "Parties"), table(["Party", "Role", "Start", "New/mo", "Churn", "Rev/unit/mo", "CAC"], rows));
|
|
233
|
+
if (assumptions.narrative) b.push(heading(2, "Notes"), para(assumptions.narrative));
|
|
234
|
+
}
|
|
235
|
+
areaBlocks["business-model"] = b;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Brand: the style guide rendered natively, plus a palette table.
|
|
239
|
+
{
|
|
240
|
+
const b: Block[] = [];
|
|
241
|
+
if (guide) {
|
|
242
|
+
b.push(...markdownToBlocks(brandGuideMarkdown(guide)));
|
|
243
|
+
const pal = (guide.palette || []).filter((p) => p?.hex);
|
|
244
|
+
if (pal.length) b.push(heading(2, "Palette"), table(["Color", "Hex", "Role"], pal.map((p) => [str(p.name), str(p.hex), str(p.role)])));
|
|
245
|
+
}
|
|
246
|
+
areaBlocks["brand"] = b;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Market research.
|
|
250
|
+
{
|
|
251
|
+
const b: Block[] = [];
|
|
252
|
+
const brief = str(research["brief"]);
|
|
253
|
+
if (brief) {
|
|
254
|
+
const desc = str(research["description"]);
|
|
255
|
+
if (desc) b.push(callout(desc, "📊"));
|
|
256
|
+
b.push(...markdownToBlocks(brief));
|
|
257
|
+
}
|
|
258
|
+
areaBlocks["market-research"] = b;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Strategy: the SWOT.
|
|
262
|
+
{
|
|
263
|
+
const b: Block[] = [];
|
|
264
|
+
const hasSwot = !!swot && Object.values(swot).some((v) => Array.isArray(v) && v.some((x) => x && String(x).trim()));
|
|
265
|
+
if (hasSwot) b.push(...markdownToBlocks(swotMarkdown(swot)));
|
|
266
|
+
areaBlocks["strategy"] = b;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Product: the PRD, requirements, and glossary.
|
|
270
|
+
{
|
|
271
|
+
const b: Block[] = [];
|
|
272
|
+
const prdMd = str(prd["markdown"]);
|
|
273
|
+
if (prdMd) b.push(...markdownToBlocks(prdMd));
|
|
274
|
+
if (grill?.requirements?.length) { b.push(divider()); b.push(...markdownToBlocks(requirementsMarkdown(grill))); }
|
|
275
|
+
if (grill?.glossary?.length) { b.push(divider()); b.push(...markdownToBlocks(glossaryMarkdown(grill))); }
|
|
276
|
+
areaBlocks["product"] = b;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Compliance: the researched GRC, licensing, and tax report (from the
|
|
280
|
+
// thought-layer-compliance skill). The report carries its own disclaimer.
|
|
281
|
+
{
|
|
282
|
+
const b: Block[] = [];
|
|
283
|
+
const report = str(obj(state.governance)["report"]);
|
|
284
|
+
if (report.trim()) b.push(...markdownToBlocks(report));
|
|
285
|
+
areaBlocks["compliance"] = b;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Decision science: any dq-* / decision-* answers.
|
|
289
|
+
{
|
|
290
|
+
const b: Block[] = [];
|
|
291
|
+
const dq = Object.keys(answers).filter((k) => /^(dq|decision)/i.test(k) && str(answers[k]).trim());
|
|
292
|
+
if (dq.length) { b.push(heading(2, "Decision records")); for (const k of dq) b.push(bullet(`**${k}**: ${str(answers[k])}`)); }
|
|
293
|
+
areaBlocks["decision-science"] = b;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Library: no structured data in state; left empty (the io layer skips empties).
|
|
297
|
+
areaBlocks["library"] = [];
|
|
298
|
+
|
|
299
|
+
const areas: WikiArea[] = WIKI_AREAS
|
|
300
|
+
.map((a) => ({ ...a, blocks: areaBlocks[a.key] || [] }))
|
|
301
|
+
.filter((a) => a.blocks.length > 0);
|
|
302
|
+
|
|
303
|
+
// Artifacts for the database: skip the landing-page SEO sidecars (keep only
|
|
304
|
+
// index.html), keep everything else.
|
|
305
|
+
const urls = opts.urls || {};
|
|
306
|
+
const files = opts.manifest?.files || [];
|
|
307
|
+
const artifacts: WikiArtifact[] = files
|
|
308
|
+
.filter((f) => !(f.path.startsWith("LandingPage/") && f.path !== "LandingPage/index.html"))
|
|
309
|
+
.map((f) => ({ name: f.path, path: f.path, category: artifactCategory(f.path), bytes: f.bytes, ...(urls[f.path] ? { url: urls[f.path] } : {}) }));
|
|
310
|
+
|
|
311
|
+
return { title: `${brandName} workspace`, icon: "🚀", overview, areas, artifacts };
|
|
312
|
+
}
|