claude-baton 2.0.1

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/dist/llm.js ADDED
@@ -0,0 +1,119 @@
1
+ import { spawn } from "child_process";
2
+ import os from "os";
3
+ /**
4
+ * Strip markdown code fences from LLM output.
5
+ * LLMs frequently wrap JSON in ```json ... ``` blocks.
6
+ */
7
+ export function stripCodeFences(text) {
8
+ const trimmed = text.trim();
9
+ // Match ```<optional language>\n...\n```
10
+ const match = trimmed.match(/^```(?:\w*)\s*\n?([\s\S]*?)\n?\s*```$/);
11
+ if (match) {
12
+ return match[1].trim();
13
+ }
14
+ return trimmed;
15
+ }
16
+ /**
17
+ * Internal: spawns claude -p and returns the raw stdout string.
18
+ * Used by both callClaude and callClaudeJson to avoid double-parse issues.
19
+ */
20
+ function callClaudeRaw(prompt, model = "haiku", timeout = 30000) {
21
+ return new Promise((resolve, reject) => {
22
+ let settled = false;
23
+ // Strip Claude Code env vars so nested claude -p works from hooks
24
+ const env = { ...process.env };
25
+ delete env.CLAUDECODE;
26
+ delete env.CLAUDE_CODE_ENTRYPOINT;
27
+ const proc = spawn("claude", ["-p", "--model", model, "--output-format", "json"], {
28
+ stdio: ["pipe", "pipe", "pipe"],
29
+ env,
30
+ cwd: os.tmpdir(),
31
+ });
32
+ let stdout = "";
33
+ let stderr = "";
34
+ proc.stdout.on("data", (data) => {
35
+ stdout += data.toString();
36
+ });
37
+ proc.stderr.on("data", (data) => {
38
+ stderr += data.toString();
39
+ });
40
+ const timer = setTimeout(() => {
41
+ if (settled)
42
+ return;
43
+ settled = true;
44
+ proc.kill();
45
+ reject(new Error(`claude -p timed out after ${timeout}ms`));
46
+ }, timeout);
47
+ proc.on("close", (code) => {
48
+ clearTimeout(timer);
49
+ if (settled)
50
+ return;
51
+ settled = true;
52
+ if (process.env.MEMORIA_DEBUG) {
53
+ console.error(`[DEBUG] claude -p exit=${code} stdout=${stdout.slice(0, 500)} stderr=${stderr.slice(0, 200)}`);
54
+ }
55
+ if (code !== 0) {
56
+ reject(new Error(`claude -p exited with code ${code}: ${stderr}`));
57
+ return;
58
+ }
59
+ resolve(stdout);
60
+ });
61
+ proc.on("error", (err) => {
62
+ clearTimeout(timer);
63
+ if (settled)
64
+ return;
65
+ settled = true;
66
+ reject(new Error(`Failed to spawn claude: ${err.message}`));
67
+ });
68
+ if (process.env.MEMORIA_DEBUG) {
69
+ console.error(`[DEBUG] Prompt length: ${prompt.length}, first 300 chars: ${prompt.slice(0, 300)}`);
70
+ }
71
+ proc.stdin.write(prompt);
72
+ proc.stdin.end();
73
+ });
74
+ }
75
+ /**
76
+ * Call claude -p and return the result as a string.
77
+ * If the raw output is JSON with a `result` field, extracts it.
78
+ * Always returns a string — non-string result values are JSON.stringified.
79
+ */
80
+ export async function callClaude(prompt, model = "haiku", timeout = 30000) {
81
+ const raw = await callClaudeRaw(prompt, model, timeout);
82
+ const stripped = stripCodeFences(raw);
83
+ try {
84
+ const parsed = JSON.parse(stripped);
85
+ const result = parsed.result ?? stripped;
86
+ if (typeof result === "string") {
87
+ return result;
88
+ }
89
+ return JSON.stringify(result);
90
+ }
91
+ catch {
92
+ return stripped;
93
+ }
94
+ }
95
+ /**
96
+ * Call claude -p and parse the response as JSON of type T.
97
+ * Uses the raw stdout (not the string-extracted callClaude) to avoid double-parse.
98
+ */
99
+ export async function callClaudeJson(prompt, model = "haiku", timeout = 30000) {
100
+ const raw = await callClaudeRaw(prompt, model, timeout);
101
+ const stripped = stripCodeFences(raw);
102
+ try {
103
+ const parsed = JSON.parse(stripped);
104
+ // If the output has a `result` field, use that as the JSON value
105
+ const value = parsed.result !== undefined ? parsed.result : parsed;
106
+ // If value is already an object/array, return it directly
107
+ if (typeof value === "object" && value !== null) {
108
+ return value;
109
+ }
110
+ // If value is a string, try to parse it as JSON
111
+ if (typeof value === "string") {
112
+ return JSON.parse(stripCodeFences(value));
113
+ }
114
+ return value;
115
+ }
116
+ catch {
117
+ throw new Error(`Failed to parse JSON from claude response: ${stripped.slice(0, 200)}`);
118
+ }
119
+ }
@@ -0,0 +1,28 @@
1
+ import { type Database } from "sql.js";
2
+ import type { Checkpoint, DailySummary } from "./types.js";
3
+ export declare function initDatabase(dbPath?: string): Promise<Database>;
4
+ export declare function saveDatabase(db: Database, dbPath: string): void;
5
+ export declare function getDefaultDbPath(): string;
6
+ export declare function initSchema(db: Database): void;
7
+ export declare function insertCheckpoint(db: Database, projectPath: string, sessionId: string, currentState: string, whatWasBuilt: string, nextSteps: string, opts?: {
8
+ branch?: string;
9
+ decisionsMade?: string;
10
+ blockers?: string;
11
+ uncommittedFiles?: string[];
12
+ gitSnapshot?: string;
13
+ planReference?: string;
14
+ }, dbPath?: string): string;
15
+ export declare function getCheckpoint(db: Database, id: string): Checkpoint | null;
16
+ export declare function getLatestCheckpoint(db: Database, projectPath: string): Checkpoint | null;
17
+ export declare function getCheckpointsByDate(db: Database, projectPath: string, date: string): Checkpoint[];
18
+ export declare function getAllCheckpoints(db: Database, projectPath?: string): Checkpoint[];
19
+ export declare function insertDailySummary(db: Database, projectPath: string, date: string, summary: Record<string, unknown>, dbPath?: string): string;
20
+ export declare function getDailySummary(db: Database, projectPath: string, date: string): DailySummary | null;
21
+ export declare function getAllDailySummaries(db: Database, projectPath?: string): DailySummary[];
22
+ export declare function countAll(db: Database, projectPath?: string): Record<string, number>;
23
+ export declare function listProjects(db: Database): Array<{
24
+ project_path: string;
25
+ checkpoint_count: number;
26
+ }>;
27
+ export declare function deleteProjectData(db: Database, projectPath: string, dbPath?: string): void;
28
+ export declare function deleteAllData(db: Database, dbPath?: string): void;
package/dist/store.js ADDED
@@ -0,0 +1,249 @@
1
+ import initSqlJs from "sql.js";
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+ import crypto from "crypto";
6
+ // --- Database lifecycle ---
7
+ export async function initDatabase(dbPath) {
8
+ const SQL = await initSqlJs();
9
+ let db;
10
+ if (dbPath && existsSync(dbPath)) {
11
+ const buffer = readFileSync(dbPath);
12
+ db = new SQL.Database(buffer);
13
+ }
14
+ else {
15
+ if (dbPath) {
16
+ mkdirSync(path.dirname(dbPath), { recursive: true });
17
+ }
18
+ db = new SQL.Database();
19
+ }
20
+ initSchema(db);
21
+ if (dbPath)
22
+ saveDatabase(db, dbPath);
23
+ return db;
24
+ }
25
+ export function saveDatabase(db, dbPath) {
26
+ const data = db.export();
27
+ const buffer = Buffer.from(data);
28
+ writeFileSync(dbPath, buffer);
29
+ }
30
+ export function getDefaultDbPath() {
31
+ return path.join(os.homedir(), ".claude-baton", "store.db");
32
+ }
33
+ export function initSchema(db) {
34
+ db.exec(`
35
+ CREATE TABLE IF NOT EXISTS checkpoints (
36
+ id TEXT PRIMARY KEY,
37
+ project_path TEXT NOT NULL,
38
+ session_id TEXT NOT NULL,
39
+ branch TEXT,
40
+ current_state TEXT NOT NULL,
41
+ what_was_built TEXT NOT NULL,
42
+ next_steps TEXT NOT NULL,
43
+ decisions_made TEXT,
44
+ blockers TEXT,
45
+ uncommitted_files TEXT DEFAULT '[]',
46
+ git_snapshot TEXT,
47
+ plan_reference TEXT,
48
+ created_at TEXT DEFAULT (datetime('now'))
49
+ );
50
+ CREATE INDEX IF NOT EXISTS idx_checkpoints_project ON checkpoints(project_path);
51
+
52
+ CREATE TABLE IF NOT EXISTS daily_summaries (
53
+ id TEXT PRIMARY KEY,
54
+ project_path TEXT NOT NULL,
55
+ date TEXT NOT NULL,
56
+ summary TEXT NOT NULL,
57
+ created_at TEXT DEFAULT (datetime('now'))
58
+ );
59
+ CREATE INDEX IF NOT EXISTS idx_daily_summaries_project ON daily_summaries(project_path);
60
+ CREATE INDEX IF NOT EXISTS idx_daily_summaries_date ON daily_summaries(date);
61
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_summaries_project_date ON daily_summaries(project_path, date);
62
+ `);
63
+ // Migration: add git_snapshot column for existing databases
64
+ try {
65
+ db.exec("ALTER TABLE checkpoints ADD COLUMN git_snapshot TEXT");
66
+ }
67
+ catch {
68
+ // Column already exists — expected for new databases or already-migrated ones
69
+ }
70
+ // Migration: add plan_reference column for existing databases
71
+ try {
72
+ db.exec("ALTER TABLE checkpoints ADD COLUMN plan_reference TEXT");
73
+ }
74
+ catch {
75
+ // Column already exists
76
+ }
77
+ }
78
+ // --- Checkpoints CRUD ---
79
+ export function insertCheckpoint(db, projectPath, sessionId, currentState, whatWasBuilt, nextSteps, opts, dbPath) {
80
+ const id = crypto.randomUUID();
81
+ db.run(`INSERT INTO checkpoints (id, project_path, session_id, branch, current_state, what_was_built, next_steps, decisions_made, blockers, uncommitted_files, git_snapshot, plan_reference, created_at)
82
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
83
+ id,
84
+ projectPath,
85
+ sessionId,
86
+ opts?.branch ?? null,
87
+ currentState,
88
+ whatWasBuilt,
89
+ nextSteps,
90
+ opts?.decisionsMade ?? null,
91
+ opts?.blockers ?? null,
92
+ JSON.stringify(opts?.uncommittedFiles ?? []),
93
+ opts?.gitSnapshot ?? null,
94
+ opts?.planReference ?? null,
95
+ new Date().toISOString(),
96
+ ]);
97
+ if (dbPath)
98
+ saveDatabase(db, dbPath);
99
+ return id;
100
+ }
101
+ export function getCheckpoint(db, id) {
102
+ const stmt = db.prepare("SELECT * FROM checkpoints WHERE id = ?");
103
+ stmt.bind([id]);
104
+ const row = stmt.step() ? stmt.getAsObject() : null;
105
+ stmt.free();
106
+ if (!row)
107
+ return null;
108
+ return parseCheckpointRow(row);
109
+ }
110
+ export function getLatestCheckpoint(db, projectPath) {
111
+ const stmt = db.prepare("SELECT * FROM checkpoints WHERE project_path = ? ORDER BY created_at DESC, rowid DESC LIMIT 1");
112
+ stmt.bind([projectPath]);
113
+ const row = stmt.step() ? stmt.getAsObject() : null;
114
+ stmt.free();
115
+ if (!row)
116
+ return null;
117
+ return parseCheckpointRow(row);
118
+ }
119
+ function parseCheckpointRow(row) {
120
+ return {
121
+ ...row,
122
+ uncommitted_files: JSON.parse(row.uncommitted_files),
123
+ git_snapshot: row.git_snapshot ?? null,
124
+ plan_reference: row.plan_reference ?? null,
125
+ };
126
+ }
127
+ export function getCheckpointsByDate(db, projectPath, date) {
128
+ const stmt = db.prepare("SELECT * FROM checkpoints WHERE project_path = ? AND created_at LIKE ? || '%' ORDER BY created_at ASC");
129
+ stmt.bind([projectPath, date]);
130
+ const results = [];
131
+ while (stmt.step()) {
132
+ results.push(parseCheckpointRow(stmt.getAsObject()));
133
+ }
134
+ stmt.free();
135
+ return results;
136
+ }
137
+ export function getAllCheckpoints(db, projectPath) {
138
+ let sql = "SELECT * FROM checkpoints";
139
+ const params = [];
140
+ if (projectPath) {
141
+ sql += " WHERE project_path = ?";
142
+ params.push(projectPath);
143
+ }
144
+ sql += " ORDER BY created_at DESC";
145
+ const stmt = db.prepare(sql);
146
+ stmt.bind(params);
147
+ const results = [];
148
+ while (stmt.step()) {
149
+ results.push(parseCheckpointRow(stmt.getAsObject()));
150
+ }
151
+ stmt.free();
152
+ return results;
153
+ }
154
+ // --- Daily Summaries CRUD ---
155
+ export function insertDailySummary(db, projectPath, date, summary, dbPath) {
156
+ const existing = getDailySummary(db, projectPath, date);
157
+ if (existing) {
158
+ db.run("UPDATE daily_summaries SET summary = ? WHERE id = ?", [
159
+ JSON.stringify(summary),
160
+ existing.id,
161
+ ]);
162
+ if (dbPath)
163
+ saveDatabase(db, dbPath);
164
+ return existing.id;
165
+ }
166
+ const id = crypto.randomUUID();
167
+ db.run(`INSERT INTO daily_summaries (id, project_path, date, summary, created_at)
168
+ VALUES (?, ?, ?, ?, ?)`, [id, projectPath, date, JSON.stringify(summary), new Date().toISOString()]);
169
+ if (dbPath)
170
+ saveDatabase(db, dbPath);
171
+ return id;
172
+ }
173
+ export function getDailySummary(db, projectPath, date) {
174
+ const stmt = db.prepare("SELECT * FROM daily_summaries WHERE project_path = ? AND date = ?");
175
+ stmt.bind([projectPath, date]);
176
+ const row = stmt.step() ? stmt.getAsObject() : null;
177
+ stmt.free();
178
+ if (!row)
179
+ return null;
180
+ return parseDailySummaryRow(row);
181
+ }
182
+ function parseDailySummaryRow(row) {
183
+ return { ...row, summary: JSON.parse(row.summary) };
184
+ }
185
+ export function getAllDailySummaries(db, projectPath) {
186
+ let sql = "SELECT * FROM daily_summaries";
187
+ const params = [];
188
+ if (projectPath) {
189
+ sql += " WHERE project_path = ?";
190
+ params.push(projectPath);
191
+ }
192
+ sql += " ORDER BY date DESC";
193
+ const stmt = db.prepare(sql);
194
+ stmt.bind(params);
195
+ const results = [];
196
+ while (stmt.step()) {
197
+ results.push(parseDailySummaryRow(stmt.getAsObject()));
198
+ }
199
+ stmt.free();
200
+ return results;
201
+ }
202
+ // --- Aggregate queries ---
203
+ function countTable(db, sql, params) {
204
+ const stmt = db.prepare(sql);
205
+ stmt.bind(params);
206
+ stmt.step();
207
+ const count = stmt.getAsObject().count ?? 0;
208
+ stmt.free();
209
+ return count;
210
+ }
211
+ export function countAll(db, projectPath) {
212
+ if (projectPath) {
213
+ const p = [projectPath];
214
+ return {
215
+ checkpoints: countTable(db, "SELECT COUNT(*) as count FROM checkpoints WHERE project_path = ?", p),
216
+ daily_summaries: countTable(db, "SELECT COUNT(*) as count FROM daily_summaries WHERE project_path = ?", p),
217
+ };
218
+ }
219
+ return {
220
+ checkpoints: countTable(db, "SELECT COUNT(*) as count FROM checkpoints", []),
221
+ daily_summaries: countTable(db, "SELECT COUNT(*) as count FROM daily_summaries", []),
222
+ };
223
+ }
224
+ export function listProjects(db) {
225
+ const stmt = db.prepare("SELECT project_path, COUNT(*) as count FROM checkpoints GROUP BY project_path ORDER BY count DESC");
226
+ const results = [];
227
+ while (stmt.step()) {
228
+ const row = stmt.getAsObject();
229
+ results.push({
230
+ project_path: row.project_path,
231
+ checkpoint_count: row.count,
232
+ });
233
+ }
234
+ stmt.free();
235
+ return results;
236
+ }
237
+ // --- Delete operations ---
238
+ export function deleteProjectData(db, projectPath, dbPath) {
239
+ db.run("DELETE FROM checkpoints WHERE project_path = ?", [projectPath]);
240
+ db.run("DELETE FROM daily_summaries WHERE project_path = ?", [projectPath]);
241
+ if (dbPath)
242
+ saveDatabase(db, dbPath);
243
+ }
244
+ export function deleteAllData(db, dbPath) {
245
+ db.run("DELETE FROM checkpoints");
246
+ db.run("DELETE FROM daily_summaries");
247
+ if (dbPath)
248
+ saveDatabase(db, dbPath);
249
+ }
@@ -0,0 +1,24 @@
1
+ /** A snapshot of session state saved before /compact or /clear. */
2
+ export interface Checkpoint {
3
+ id: string;
4
+ project_path: string;
5
+ session_id: string;
6
+ branch: string | null;
7
+ current_state: string;
8
+ what_was_built: string;
9
+ next_steps: string;
10
+ decisions_made: string | null;
11
+ blockers: string | null;
12
+ uncommitted_files: string[];
13
+ git_snapshot: string | null;
14
+ plan_reference: string | null;
15
+ created_at: string;
16
+ }
17
+ /** An aggregated end-of-day summary of project activity. */
18
+ export interface DailySummary {
19
+ id: string;
20
+ project_path: string;
21
+ date: string;
22
+ summary: Record<string, unknown>;
23
+ created_at: string;
24
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export declare function getProjectPath(): string;
2
+ export declare function ensureDir(dirPath: string): void;
package/dist/utils.js ADDED
@@ -0,0 +1,10 @@
1
+ import { mkdirSync, existsSync } from "fs";
2
+ // --- Path helpers ---
3
+ export function getProjectPath() {
4
+ return process.cwd();
5
+ }
6
+ export function ensureDir(dirPath) {
7
+ if (!existsSync(dirPath)) {
8
+ mkdirSync(dirPath, { recursive: true });
9
+ }
10
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "claude-baton",
3
+ "version": "2.0.1",
4
+ "description": "Session lifecycle management for Claude Code",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "claude-baton": "bin/claude-baton.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "bin",
14
+ "prompts",
15
+ "commands",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "keywords": [
20
+ "mcp",
21
+ "claude",
22
+ "claude-code",
23
+ "session",
24
+ "checkpoint",
25
+ "resume",
26
+ "lifecycle"
27
+ ],
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "scripts": {
32
+ "prepare": "tsc",
33
+ "build": "tsc",
34
+ "test": "vitest run",
35
+ "dev": "node dist/index.js",
36
+ "lint": "tsc --noEmit",
37
+ "format:check": "npx prettier --check 'src/**/*.ts' 'tests/**/*.ts'",
38
+ "format": "npx prettier --write 'src/**/*.ts' 'tests/**/*.ts'"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.12.1",
42
+ "commander": "^13.1.0",
43
+ "sql.js": "^1.12.0"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^22.15.3",
47
+ "typescript": "^5.8.3",
48
+ "vitest": "^3.1.2"
49
+ },
50
+ "license": "MIT",
51
+ "author": "bakabaka91",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/bakabaka91/claude-baton.git"
55
+ },
56
+ "homepage": "https://github.com/bakabaka91/claude-baton#readme",
57
+ "bugs": {
58
+ "url": "https://github.com/bakabaka91/claude-baton/issues"
59
+ }
60
+ }
@@ -0,0 +1,30 @@
1
+ You are extracting a session checkpoint from a Claude Code conversation transcript.
2
+
3
+ Analyze the transcript and produce a JSON object with these fields:
4
+
5
+ - what_was_built: What was accomplished in this session (1-3 sentences)
6
+ - current_state: Current state of the project — what's passing, what's broken, what's uncommitted (1-2 sentences)
7
+ - next_steps: What should be done next (1-2 sentences, actionable)
8
+ - decisions_made: Key decisions made during this session, if any (1-2 sentences, or "None")
9
+ - blockers: Anything blocking progress, if any (1 sentence, or "None")
10
+ - plan_reference: If the session references an active plan document (e.g. PLAN.md, docs/plan.md, a spec file), include the file path and the specific section being worked on (e.g. "docs/v2-plan.md Phase 2 Step 3"). Null if no plan document is referenced.
11
+
12
+ Rules:
13
+ - Be concise — each field should be 1-3 sentences max
14
+ - Focus on what changed, not what was discussed
15
+ - next_steps should be specific enough to resume work
16
+ - For plan_reference, look for file reads/references to plan docs, spec files, or roadmap documents in the transcript
17
+ - Output ONLY valid JSON, no markdown, no explanation
18
+
19
+ Example output:
20
+ {
21
+ "what_was_built": "Added user authentication with JWT tokens and password hashing.",
22
+ "current_state": "Build passing, 45 tests passing. Auth routes implemented but not yet integrated with frontend.",
23
+ "next_steps": "Wire up login form to POST /auth/login endpoint. Add token refresh middleware.",
24
+ "decisions_made": "Using bcrypt over argon2 for password hashing due to simpler deployment. Storing refresh tokens in httpOnly cookies.",
25
+ "blockers": "None",
26
+ "plan_reference": "docs/auth-plan.md Phase 2 Step 1"
27
+ }
28
+
29
+ Transcript:
30
+ {TRANSCRIPT}
@@ -0,0 +1,29 @@
1
+ You are a daily summary agent. Given a day's activity data, produce a structured end-of-day summary.
2
+
3
+ Date: {{DATE}}
4
+
5
+ Activity data:
6
+ {{ACTIVITY}}
7
+
8
+ Instructions:
9
+ - Summarize what was accomplished during the day based on the activity data
10
+ - Identify key decisions that were made and their rationale
11
+ - Note any blockers or unresolved issues that need attention
12
+ - Suggest concrete next steps based on the day's progress
13
+ - Write a single quotable highlight line that captures the day's essence
14
+ - Be concise — each field should capture signal, not noise
15
+ - Do not fabricate information not present in the activity data
16
+ - If the activity data is sparse, reflect that honestly rather than padding
17
+
18
+ Respond with ONLY a valid JSON object matching this shape:
19
+
20
+ {"what_was_built": "...", "decisions_made": ["..."], "blockers": ["..."], "next_steps": ["..."], "highlights": "..."}
21
+
22
+ Field rules:
23
+ - what_was_built: 1-3 sentence summary of concrete accomplishments
24
+ - decisions_made: array of short strings, each describing one decision and why
25
+ - blockers: array of unresolved issues; use [] if none
26
+ - next_steps: array of actionable items for the next session
27
+ - highlights: one-line summary suitable for a changelog or standup
28
+
29
+ Respond with ONLY valid JSON. Do not wrap in code fences. Do not include any text before or after the JSON object. Your entire response must be parseable by JSON.parse().