@pragma-sh/scratchpad-contract 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/package.json +36 -0
- package/src/comments.ts +91 -0
- package/src/document.ts +92 -0
- package/src/guards.ts +27 -0
- package/src/index.ts +17 -0
- package/src/types.ts +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @pragma-sh/scratchpad-contract
|
|
2
|
+
|
|
3
|
+
The Pragma scratchpad file contract. Part of [Pragma](https://github.com/pragma-sh/pragma) — a desktop workspace for
|
|
4
|
+
running persistent, worktree-scoped coding agents.
|
|
5
|
+
|
|
6
|
+
Pure data: the managed frontmatter of a `.mdx` scratchpad and the sibling
|
|
7
|
+
`<file>.mdx.comments.json` thread. No React, no renderer, no transport.
|
|
8
|
+
|
|
9
|
+
Everything that reads or writes those two files should go through here — a
|
|
10
|
+
second implementation of frontmatter parsing or comment serialization is how
|
|
11
|
+
clients drift apart and a comment written on one device stops showing up on
|
|
12
|
+
another.
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
bun add @pragma-sh/scratchpad-contract
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { parseScratchpadDocument } from "@pragma-sh/scratchpad-contract";
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Docs: <https://pragma-app.sh/docs/sdk/scratchpads>
|
|
23
|
+
|
|
24
|
+
## License
|
|
25
|
+
|
|
26
|
+
AGPL-3.0-only. See [LICENSE](https://github.com/pragma-sh/pragma/blob/main/LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pragma-sh/scratchpad-contract",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The scratchpad file contract: managed frontmatter, agent attachment, and the sibling comment-thread file. Source of truth shared by the desktop, the SDK, and the read-only viewer.",
|
|
5
|
+
"homepage": "https://github.com/pragma-sh/pragma#readme",
|
|
6
|
+
"bugs": "https://github.com/pragma-sh/pragma/issues",
|
|
7
|
+
"license": "AGPL-3.0-only",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/pragma-sh/pragma.git",
|
|
11
|
+
"directory": "packages/scratchpad-contract"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"!src/**/*.test.ts"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"test": "bun --bun vitest run",
|
|
27
|
+
"lint": "oxlint ."
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@pragma-sh/constants": "^0.1.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"typescript": "^6.0.3",
|
|
34
|
+
"vitest": "^4.1.8"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/comments.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { isNumber, isString, matchesShape, nullable } from "./guards";
|
|
2
|
+
import type { ScratchpadBlock, ScratchpadComment } from "./types";
|
|
3
|
+
|
|
4
|
+
export type { ScratchpadComment };
|
|
5
|
+
|
|
6
|
+
/** Sibling JSON path storing one scratchpad's comment threads. */
|
|
7
|
+
export function scratchpadCommentsPath(filePath: string): string {
|
|
8
|
+
return `${filePath}.comments.json`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parses a persisted comment file, dropping malformed entries instead of
|
|
13
|
+
* failing the whole document — one bad entry must not cost the user the rest of
|
|
14
|
+
* their comments.
|
|
15
|
+
*/
|
|
16
|
+
export function parseScratchpadComments(source: string): ScratchpadComment[] {
|
|
17
|
+
const value: unknown = JSON.parse(source);
|
|
18
|
+
if (!Array.isArray(value)) throw new Error("Scratchpad comments must be an array.");
|
|
19
|
+
return value.filter(isScratchpadComment);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Serializes comments in the exact on-disk shape the desktop writes. */
|
|
23
|
+
export function serializeScratchpadComments(comments: readonly ScratchpadComment[]): string {
|
|
24
|
+
return `${JSON.stringify(comments, null, 2)}\n`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Builds one comment anchored to a rendered block.
|
|
29
|
+
*
|
|
30
|
+
* `from`/`to` stay `0`: they are ProseMirror positions in the desktop editor's
|
|
31
|
+
* document, which a rendered web view has no way to compute. The desktop skips
|
|
32
|
+
* decorating a zero-width range, so a phone-authored comment shows up in its
|
|
33
|
+
* list and in the agent handoff without highlighting an arbitrary paragraph.
|
|
34
|
+
*/
|
|
35
|
+
export function createScratchpadComment(
|
|
36
|
+
block: ScratchpadBlock,
|
|
37
|
+
text: string,
|
|
38
|
+
now: number,
|
|
39
|
+
id: string,
|
|
40
|
+
): ScratchpadComment {
|
|
41
|
+
return {
|
|
42
|
+
id,
|
|
43
|
+
from: 0,
|
|
44
|
+
to: 0,
|
|
45
|
+
quote: block.quote,
|
|
46
|
+
text: text.trim(),
|
|
47
|
+
createdAt: now,
|
|
48
|
+
resolvedAt: null,
|
|
49
|
+
blockIndex: block.index,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Comments still awaiting the agent. */
|
|
54
|
+
export function unresolvedComments(
|
|
55
|
+
comments: readonly ScratchpadComment[],
|
|
56
|
+
): readonly ScratchpadComment[] {
|
|
57
|
+
return comments.filter((comment) => comment.resolvedAt === null);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Stamps every open comment as resolved at the given time. */
|
|
61
|
+
export function markAllResolved(
|
|
62
|
+
comments: readonly ScratchpadComment[],
|
|
63
|
+
now: number,
|
|
64
|
+
): ScratchpadComment[] {
|
|
65
|
+
return comments.map((comment) =>
|
|
66
|
+
comment.resolvedAt === null ? { ...comment, resolvedAt: now } : comment,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The handoff message an agent receives when the user submits open comments.
|
|
72
|
+
* Shared with the desktop so an agent sees the same prompt either way.
|
|
73
|
+
*/
|
|
74
|
+
export function unresolvedCommentsPrompt(unresolved: readonly ScratchpadComment[]): string {
|
|
75
|
+
return [
|
|
76
|
+
"The user left the following scratchpad comments for you to address:",
|
|
77
|
+
...unresolved.map((comment, index) => `${index + 1}. On "${comment.quote}": ${comment.text}`),
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isScratchpadComment(value: unknown): value is ScratchpadComment {
|
|
82
|
+
return matchesShape(value, {
|
|
83
|
+
id: isString,
|
|
84
|
+
from: isNumber,
|
|
85
|
+
to: isNumber,
|
|
86
|
+
quote: isString,
|
|
87
|
+
text: isString,
|
|
88
|
+
createdAt: isNumber,
|
|
89
|
+
resolvedAt: nullable(isNumber),
|
|
90
|
+
});
|
|
91
|
+
}
|
package/src/document.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { constants } from "@pragma-sh/constants";
|
|
2
|
+
|
|
3
|
+
import { isNumber, isString, matchesShape, nullable } from "./guards";
|
|
4
|
+
|
|
5
|
+
/** Managed metadata stored as one JSON line in scratchpad YAML frontmatter. */
|
|
6
|
+
export interface ScratchpadMetadata {
|
|
7
|
+
version: number;
|
|
8
|
+
id: string;
|
|
9
|
+
title: string;
|
|
10
|
+
agentTabId: string | null;
|
|
11
|
+
agentId: string | null;
|
|
12
|
+
createdAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Parsed managed scratchpad with its editable MDX body split from frontmatter. */
|
|
16
|
+
export interface ScratchpadDocument {
|
|
17
|
+
metadata: ScratchpadMetadata;
|
|
18
|
+
body: string;
|
|
19
|
+
frontmatter: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n)?/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parses required managed frontmatter. MDX dropped into the scratchpad
|
|
26
|
+
* directory by hand is rejected: without the managed line there is no id, no
|
|
27
|
+
* title, and no agent attachment to edit.
|
|
28
|
+
*/
|
|
29
|
+
export function parseScratchpadDocument(source: string): ScratchpadDocument {
|
|
30
|
+
const frontmatter = FRONTMATTER_PATTERN.exec(source);
|
|
31
|
+
if (!frontmatter) {
|
|
32
|
+
throw new Error("This MDX file is not a managed Pragma scratchpad.");
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
metadata: parseMetadata(metadataLine(frontmatter[1] ?? "")),
|
|
36
|
+
body: source.slice(frontmatter[0].length),
|
|
37
|
+
frontmatter: frontmatter[0],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The one managed line inside a frontmatter block. */
|
|
42
|
+
function metadataLine(frontmatter: string): string {
|
|
43
|
+
const key = constants.scratchpads.frontmatterKey;
|
|
44
|
+
const line = frontmatter.split(/\r?\n/).find((entry) => entry.startsWith(`${key}: `));
|
|
45
|
+
if (!line) throw new Error("Scratchpad frontmatter is missing managed metadata.");
|
|
46
|
+
return line.slice(key.length + 2);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Parses that line, rejecting a contract version this build does not know. */
|
|
50
|
+
function parseMetadata(json: string): ScratchpadMetadata {
|
|
51
|
+
const parsed: unknown = JSON.parse(json);
|
|
52
|
+
if (!isScratchpadMetadata(parsed) || parsed.version !== constants.scratchpads.version) {
|
|
53
|
+
throw new Error("Scratchpad metadata is invalid or uses an unsupported version.");
|
|
54
|
+
}
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Replaces the MDX body while preserving every frontmatter byte. */
|
|
59
|
+
export function replaceScratchpadBody(document: ScratchpadDocument, body: string): string {
|
|
60
|
+
return `${document.frontmatter}${body}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Updates the attached agent identity in managed frontmatter. */
|
|
64
|
+
export function attachScratchpadAgent(
|
|
65
|
+
source: string,
|
|
66
|
+
agent: { tabId: string; agentId: string },
|
|
67
|
+
): string {
|
|
68
|
+
const document = parseScratchpadDocument(source);
|
|
69
|
+
const metadata: ScratchpadMetadata = {
|
|
70
|
+
...document.metadata,
|
|
71
|
+
agentTabId: agent.tabId,
|
|
72
|
+
agentId: agent.agentId,
|
|
73
|
+
};
|
|
74
|
+
const key = constants.scratchpads.frontmatterKey;
|
|
75
|
+
const nextLine = `${key}: ${JSON.stringify(metadata)}`;
|
|
76
|
+
const lines = document.frontmatter.split(/\r?\n/);
|
|
77
|
+
const index = lines.findIndex((line) => line.startsWith(`${key}: `));
|
|
78
|
+
if (index < 0) throw new Error("Scratchpad frontmatter is missing managed metadata.");
|
|
79
|
+
lines[index] = nextLine;
|
|
80
|
+
return `${lines.join("\n")}${document.body}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isScratchpadMetadata(value: unknown): value is ScratchpadMetadata {
|
|
84
|
+
return matchesShape(value, {
|
|
85
|
+
version: isNumber,
|
|
86
|
+
id: isString,
|
|
87
|
+
title: isString,
|
|
88
|
+
agentTabId: nullable(isString),
|
|
89
|
+
agentId: nullable(isString),
|
|
90
|
+
createdAt: isNumber,
|
|
91
|
+
});
|
|
92
|
+
}
|
package/src/guards.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** A predicate narrowing one field of a parsed JSON value. */
|
|
2
|
+
export type FieldGuard = (value: unknown) => boolean;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks a parsed JSON value field by field.
|
|
6
|
+
*
|
|
7
|
+
* Both files this package parses — the comment thread and the managed
|
|
8
|
+
* frontmatter — are written by another process (an agent, or an older Pragma),
|
|
9
|
+
* so every field is validated before it is trusted. Sharing one checker keeps
|
|
10
|
+
* those two validations from drifting into subtly different strictness.
|
|
11
|
+
*/
|
|
12
|
+
export function matchesShape(value: unknown, fields: Record<string, FieldGuard>): boolean {
|
|
13
|
+
if (typeof value !== "object" || value === null) return false;
|
|
14
|
+
const record = value as Record<string, unknown>;
|
|
15
|
+
return Object.entries(fields).every(([field, guard]) => guard(record[field]));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Field is a string. */
|
|
19
|
+
export const isString: FieldGuard = (value) => typeof value === "string";
|
|
20
|
+
|
|
21
|
+
/** Field is a number. */
|
|
22
|
+
export const isNumber: FieldGuard = (value) => typeof value === "number";
|
|
23
|
+
|
|
24
|
+
/** Field is `null` or passes `guard`. */
|
|
25
|
+
export function nullable(guard: FieldGuard): FieldGuard {
|
|
26
|
+
return (value) => value === null || guard(value);
|
|
27
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createScratchpadComment,
|
|
3
|
+
markAllResolved,
|
|
4
|
+
parseScratchpadComments,
|
|
5
|
+
scratchpadCommentsPath,
|
|
6
|
+
serializeScratchpadComments,
|
|
7
|
+
unresolvedComments,
|
|
8
|
+
unresolvedCommentsPrompt,
|
|
9
|
+
} from "./comments";
|
|
10
|
+
export {
|
|
11
|
+
attachScratchpadAgent,
|
|
12
|
+
parseScratchpadDocument,
|
|
13
|
+
replaceScratchpadBody,
|
|
14
|
+
type ScratchpadDocument,
|
|
15
|
+
type ScratchpadMetadata,
|
|
16
|
+
} from "./document";
|
|
17
|
+
export type { ScratchpadBlock, ScratchpadComment } from "./types";
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The on-disk shapes every scratchpad client shares.
|
|
3
|
+
*
|
|
4
|
+
* They live in their own module (and their own package) because the desktop
|
|
5
|
+
* editor, the SDK, the read-only viewer, and the native clients all read and
|
|
6
|
+
* write the same two files — the MDX with its managed frontmatter, and the
|
|
7
|
+
* sibling comment thread — and a second definition of either shape is how they
|
|
8
|
+
* drift apart.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One block of the rendered document a comment can be attached to. */
|
|
12
|
+
export interface ScratchpadBlock {
|
|
13
|
+
/** Index of the block among the document's top-level rendered blocks. */
|
|
14
|
+
index: number;
|
|
15
|
+
/** Plain-text excerpt of the block, used as the comment's quote. */
|
|
16
|
+
quote: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One persisted scratchpad comment, in the desktop's on-disk shape. */
|
|
20
|
+
export interface ScratchpadComment {
|
|
21
|
+
id: string;
|
|
22
|
+
/**
|
|
23
|
+
* ProseMirror document positions the desktop editor decorates. A native
|
|
24
|
+
* client has no ProseMirror document, so it writes `0`/`0` and anchors by
|
|
25
|
+
* {@link ScratchpadComment.quote} and {@link ScratchpadComment.blockIndex}
|
|
26
|
+
* instead; the desktop then renders the comment without a highlight rather
|
|
27
|
+
* than highlighting the wrong range.
|
|
28
|
+
*/
|
|
29
|
+
from: number;
|
|
30
|
+
to: number;
|
|
31
|
+
quote: string;
|
|
32
|
+
text: string;
|
|
33
|
+
createdAt: number;
|
|
34
|
+
resolvedAt: number | null;
|
|
35
|
+
/** Rendered block this comment was attached to, when written on mobile. */
|
|
36
|
+
blockIndex?: number;
|
|
37
|
+
}
|