opencode-workspace-mailbox 0.0.0-stage → 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/src/parser.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { messageHash } from "./hash.js"
2
+
3
+ export type MailboxMessage = {
4
+ header: string
5
+ from: string
6
+ to: string
7
+ status: string
8
+ topic: string
9
+ body: string
10
+ start: number
11
+ end: number
12
+ statusStart: number
13
+ statusEnd: number
14
+ /** Char offset of the end of the status line's content (before its line ending); -1 if absent. */
15
+ statusLineEnd: number
16
+ hash: string
17
+ }
18
+
19
+ function parseHeader(headerText: string): { from: string; to: string } {
20
+ const s = headerText.trim()
21
+ const re = /(?:—|–|-)\s*from\s+(.+?)\s*(?:→|->|-->)\s*to\s+(.+)$/
22
+ const m = s.match(re)
23
+ if (!m) return { from: "", to: "" }
24
+ return { from: m[1].trim(), to: m[2].trim() }
25
+ }
26
+
27
+ const HEADER_DATE_RE = /^\d{4}-\d{2}-\d{2}/
28
+
29
+ /**
30
+ * A `###` line is a message header only when it carries a date or a
31
+ * `from ... to ...` shape. Anything else (`### Notes` inside a body) stays part
32
+ * of the previous message instead of splitting it.
33
+ */
34
+ function looksLikeMessageHeader(header: string): boolean {
35
+ const text = header.trim()
36
+ if (HEADER_DATE_RE.test(text)) return true
37
+ const { from, to } = parseHeader(text)
38
+ return from.length > 0 && to.length > 0
39
+ }
40
+
41
+ export function parseMailbox(text: string): MailboxMessage[] {
42
+ const messages: MailboxMessage[] = []
43
+ const starts = Array.from(text.matchAll(/^###[ \t]+/gm))
44
+ .map((match) => match.index)
45
+ .filter((index) => {
46
+ const nl = text.indexOf("\n", index)
47
+ const line = text.slice(index, nl === -1 ? text.length : nl).replace(/\r$/, "")
48
+ return looksLikeMessageHeader(line.replace(/^###[ \t]+/, ""))
49
+ })
50
+
51
+ for (let i = 0; i < starts.length; i++) {
52
+ const start = starts[i]
53
+ const end = i + 1 < starts.length ? starts[i + 1] : text.length
54
+
55
+ let nl = text.indexOf("\n", start)
56
+ if (nl === -1 || nl >= end) nl = end
57
+ let headerLine = text.slice(start, nl)
58
+ if (headerLine.endsWith("\r")) headerLine = headerLine.slice(0, -1)
59
+ const header = headerLine.replace(/^###[ \t]+/, "")
60
+ const { from, to } = parseHeader(header)
61
+
62
+ const regionStart = nl >= text.length ? nl : nl + 1
63
+ const region = text.slice(regionStart, end)
64
+
65
+ let status = ""
66
+ let statusStart = -1
67
+ let statusEnd = -1
68
+ let statusLineEnd = -1
69
+ const statusMatch = /\*\*Status:\*\*[ \t]*([^\r\n]*)/.exec(region)
70
+ if (statusMatch) {
71
+ const raw = statusMatch[1]
72
+ const word = raw.match(/\S+/)
73
+ if (word) {
74
+ status = word[0].toUpperCase()
75
+ const prefix = statusMatch[0].length - statusMatch[1].length
76
+ statusStart = regionStart + statusMatch.index + prefix + (word.index ?? 0)
77
+ statusEnd = statusStart + word[0].length
78
+ }
79
+ statusLineEnd = regionStart + statusMatch.index + statusMatch[0].length
80
+ }
81
+
82
+ let topic = ""
83
+ let topicLineEnd = -1
84
+ const topicMatch = /\*\*Topic:\*\*[ \t]*([^\r\n]*)/.exec(region)
85
+ if (topicMatch) {
86
+ topic = topicMatch[1].trim()
87
+ topicLineEnd = regionStart + topicMatch.index + topicMatch[0].length
88
+ }
89
+
90
+ const bodyStart = Math.max(regionStart, statusLineEnd, topicLineEnd)
91
+ const body = text.slice(bodyStart, end).trim()
92
+
93
+ const hash = messageHash({ header, topic, body })
94
+
95
+ messages.push({ header, from, to, status, topic, body, start, end, statusStart, statusEnd, statusLineEnd, hash })
96
+ }
97
+
98
+ return messages
99
+ }
100
+
101
+ export function isOpen(message: MailboxMessage): boolean {
102
+ return message.status === "OPEN"
103
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,38 @@
1
+ import path from "node:path"
2
+
3
+ export const RECIPIENT_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
4
+
5
+ export class PathSafetyError extends Error {
6
+ constructor(message: string) {
7
+ super(message)
8
+ this.name = "PathSafetyError"
9
+ }
10
+ }
11
+
12
+ export function isSafeRecipient(name: unknown): name is string {
13
+ return typeof name === "string" && RECIPIENT_RE.test(name)
14
+ }
15
+
16
+ export function resolveInside(baseDir: string, ...segments: string[]): string {
17
+ const target = path.resolve(baseDir, ...segments)
18
+ const rel = path.relative(baseDir, target)
19
+ if (rel === "" || rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel)) {
20
+ throw new PathSafetyError(`Path escapes base directory: ${target}`)
21
+ }
22
+ return target
23
+ }
24
+
25
+ export function mailboxFile(mailboxDir: string, recipient: string): string {
26
+ if (!isSafeRecipient(recipient)) {
27
+ throw new PathSafetyError(`Invalid recipient "${recipient}"`)
28
+ }
29
+ return resolveInside(mailboxDir, recipient + ".md")
30
+ }
31
+
32
+ export function assertInside(baseDir: string, candidate: string, label: string): string {
33
+ try {
34
+ return resolveInside(baseDir, candidate)
35
+ } catch {
36
+ throw new PathSafetyError(`${label} must resolve inside the project directory: ${candidate}`)
37
+ }
38
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { MailboxMessage } from "./parser.js"
2
+
3
+ function truncate(text: string, max: number): string {
4
+ return text.length > max ? text.slice(0, max) : text
5
+ }
6
+
7
+ export function buildIdlePrompt(project: string, messages: MailboxMessage[]): string {
8
+ const n = messages.length
9
+ const topics = messages.map((m) => `- ${truncate(m.topic || "(no topic)", 120)}`).join("\n")
10
+ return (
11
+ `You have ${n} OPEN mailbox message(s) for this project.\n\n` +
12
+ `Load the \`workspace-mailbox\` skill, read \`handoffs/${project}.md\`, handle every OPEN message,\n` +
13
+ `flip each one to DONE (use \`mailbox_ack\`), and reply only if a decision is needed.\n\n` +
14
+ `Topics:\n${topics}`
15
+ )
16
+ }
17
+
18
+ export function buildCapNotice(project: string, count: number, maxAutoRounds = 2): string {
19
+ return `Mailbox: ${count} OPEN message(s) in ${project} — auto-handling stopped after ${maxAutoRounds} round(s); ask me to handle them.`
20
+ }
package/src/state.ts ADDED
@@ -0,0 +1,119 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ export type SessionState = {
5
+ autoRounds: number
6
+ lastPromptAt: number
7
+ lastNoticeAt?: number
8
+ noticeKey?: string
9
+ spawnsThisTurn?: number
10
+ }
11
+ export type MailboxState = {
12
+ version: 1
13
+ prompted: string[]
14
+ ownWrites: string[]
15
+ sessions: Record<string, SessionState>
16
+ /** Session ids this plugin created (spawn-on-send). */
17
+ spawnedSessions: string[]
18
+ /** Message hashes that already triggered a spawn. */
19
+ spawnedHashes: string[]
20
+ lastSpawnAt?: number
21
+ }
22
+
23
+ const MAX_ENTRIES = 500
24
+
25
+ export function emptyState(): MailboxState {
26
+ return { version: 1, prompted: [], ownWrites: [], sessions: {}, spawnedSessions: [], spawnedHashes: [] }
27
+ }
28
+
29
+ function finiteInt(value: unknown): number {
30
+ return typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : 0
31
+ }
32
+
33
+ function asStringArray(value: unknown): string[] {
34
+ if (!Array.isArray(value)) return []
35
+ const seen = new Set<string>()
36
+ const out: string[] = []
37
+ for (const entry of value) {
38
+ if (typeof entry === "string" && !seen.has(entry)) {
39
+ seen.add(entry)
40
+ out.push(entry)
41
+ }
42
+ }
43
+ return out.slice(-MAX_ENTRIES)
44
+ }
45
+
46
+ function coerceSession(value: unknown): SessionState {
47
+ const obj = value && typeof value === "object" ? (value as Record<string, unknown>) : {}
48
+ const session: SessionState = {
49
+ autoRounds: finiteInt(obj.autoRounds),
50
+ lastPromptAt: finiteInt(obj.lastPromptAt),
51
+ }
52
+ if (typeof obj.lastNoticeAt === "number" && Number.isFinite(obj.lastNoticeAt)) {
53
+ session.lastNoticeAt = obj.lastNoticeAt
54
+ }
55
+ if (typeof obj.noticeKey === "string") {
56
+ session.noticeKey = obj.noticeKey
57
+ }
58
+ if (typeof obj.spawnsThisTurn === "number" && Number.isFinite(obj.spawnsThisTurn)) {
59
+ session.spawnsThisTurn = Math.max(0, Math.floor(obj.spawnsThisTurn))
60
+ }
61
+ return session
62
+ }
63
+
64
+ function coerce(value: unknown): MailboxState {
65
+ if (!value || typeof value !== "object") return emptyState()
66
+ const obj = value as Record<string, unknown>
67
+ const sessions: Record<string, SessionState> = {}
68
+ if (obj.sessions && typeof obj.sessions === "object") {
69
+ for (const [key, entry] of Object.entries(obj.sessions as Record<string, unknown>)) {
70
+ sessions[key] = coerceSession(entry)
71
+ }
72
+ }
73
+ return {
74
+ version: 1,
75
+ prompted: asStringArray(obj.prompted),
76
+ ownWrites: asStringArray(obj.ownWrites),
77
+ sessions,
78
+ spawnedSessions: asStringArray(obj.spawnedSessions),
79
+ spawnedHashes: asStringArray(obj.spawnedHashes),
80
+ ...(typeof obj.lastSpawnAt === "number" && Number.isFinite(obj.lastSpawnAt)
81
+ ? { lastSpawnAt: obj.lastSpawnAt }
82
+ : {}),
83
+ }
84
+ }
85
+
86
+ export class StateStore {
87
+ private file: string
88
+ private now: () => number
89
+
90
+ constructor(file: string, now?: () => number) {
91
+ this.file = file
92
+ this.now = now ?? (() => Date.now())
93
+ }
94
+
95
+ read(): MailboxState {
96
+ if (!existsSync(this.file)) return emptyState()
97
+ try {
98
+ const raw = readFileSync(this.file, "utf8")
99
+ return coerce(JSON.parse(raw))
100
+ } catch {
101
+ return emptyState()
102
+ }
103
+ }
104
+
105
+ write(state: MailboxState): void {
106
+ const clean = coerce(state)
107
+ mkdirSync(path.dirname(this.file), { recursive: true })
108
+ const tmp = `${this.file}.tmp`
109
+ writeFileSync(tmp, JSON.stringify(clean, null, 2), "utf8")
110
+ renameSync(tmp, this.file)
111
+ }
112
+
113
+ update(mutator: (state: MailboxState) => void): MailboxState {
114
+ const state = this.read()
115
+ mutator(state)
116
+ this.write(state)
117
+ return state
118
+ }
119
+ }
package/src/store.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { ackMessage, readMailbox, sendMessage, type AckResult, type SendResult } from "./mailbox.js"
4
+ import { mailboxFile } from "./paths.js"
5
+ import type { MailboxMessage } from "./parser.js"
6
+
7
+ export type MailboxFilter = {
8
+ status?: string
9
+ topicContains?: string
10
+ limit?: number
11
+ }
12
+
13
+ export type AppendInput = {
14
+ from: string
15
+ topic: string
16
+ body: string
17
+ status?: "OPEN" | "DONE"
18
+ now?: Date
19
+ }
20
+
21
+ /**
22
+ * Storage contract. The file backend is the MVP; a SQLite backend only has to
23
+ * implement these five operations to replace it without touching the tools,
24
+ * the wake path, or spawn-on-send.
25
+ */
26
+ export interface MailboxStore {
27
+ readonly kind: string
28
+ readonly dir: string
29
+ /** Create the project's mailbox file if it is missing; returns its path. */
30
+ ensure(project: string): string
31
+ listOpen(project: string): MailboxMessage[]
32
+ read(project: string): MailboxMessage[]
33
+ append(project: string, input: AppendInput): SendResult
34
+ ack(project: string, target: { hash?: string; header?: string }): AckResult
35
+ query(project: string, filter?: MailboxFilter): MailboxMessage[]
36
+ }
37
+
38
+ export function createFileStore(dir: string): MailboxStore {
39
+ const root = path.resolve(dir)
40
+ const fileFor = (project: string): string => mailboxFile(root, project)
41
+
42
+ return {
43
+ kind: "file",
44
+ dir: root,
45
+ ensure(project: string): string {
46
+ const target = fileFor(project)
47
+ if (!existsSync(target)) {
48
+ mkdirSync(path.dirname(target), { recursive: true })
49
+ writeFileSync(
50
+ target,
51
+ `# ${project} mailbox\n\nMessages addressed to \`${project}\`. Append-only.\n`,
52
+ "utf8",
53
+ )
54
+ }
55
+ return target
56
+ },
57
+ listOpen(project: string): MailboxMessage[] {
58
+ return readMailbox(fileFor(project)).open
59
+ },
60
+ read(project: string): MailboxMessage[] {
61
+ return readMailbox(fileFor(project)).messages
62
+ },
63
+ append(project: string, input: AppendInput): SendResult {
64
+ return sendMessage({
65
+ mailboxDir: root,
66
+ to: project,
67
+ from: input.from,
68
+ topic: input.topic,
69
+ body: input.body,
70
+ status: input.status,
71
+ now: input.now,
72
+ })
73
+ },
74
+ ack(project: string, target: { hash?: string; header?: string }): AckResult {
75
+ return ackMessage({ file: fileFor(project), hash: target.hash, header: target.header })
76
+ },
77
+ query(project: string, filter: MailboxFilter = {}): MailboxMessage[] {
78
+ let list = readMailbox(fileFor(project)).messages
79
+ if (filter.status) {
80
+ const wanted = filter.status.toUpperCase()
81
+ list = list.filter((message) => message.status === wanted)
82
+ }
83
+ if (filter.topicContains) {
84
+ const needle = filter.topicContains.toLowerCase()
85
+ list = list.filter((message) => message.topic.toLowerCase().includes(needle))
86
+ }
87
+ if (filter.limit && filter.limit > 0) list = list.slice(-filter.limit)
88
+ return list
89
+ },
90
+ }
91
+ }