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/LICENSE +21 -0
- package/README.md +347 -2
- package/dist/config.d.ts +32 -0
- package/dist/config.js +85 -0
- package/dist/config.js.map +1 -0
- package/dist/discovery.d.ts +41 -0
- package/dist/discovery.js +101 -0
- package/dist/discovery.js.map +1 -0
- package/dist/hash.d.ts +6 -0
- package/dist/hash.js +11 -0
- package/dist/hash.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +537 -0
- package/dist/index.js.map +1 -0
- package/dist/mailbox.d.ts +35 -0
- package/dist/mailbox.js +80 -0
- package/dist/mailbox.js.map +1 -0
- package/dist/parser.d.ts +17 -0
- package/dist/parser.js +78 -0
- package/dist/parser.js.map +1 -0
- package/dist/paths.d.ts +8 -0
- package/dist/paths.js +34 -0
- package/dist/paths.js.map +1 -0
- package/dist/prompt.d.ts +3 -0
- package/dist/prompt.js +15 -0
- package/dist/prompt.js.map +1 -0
- package/dist/state.d.ts +27 -0
- package/dist/state.js +94 -0
- package/dist/state.js.map +1 -0
- package/dist/store.d.ts +34 -0
- package/dist/store.js +55 -0
- package/dist/store.js.map +1 -0
- package/package.json +46 -4
- package/src/config.ts +133 -0
- package/src/discovery.ts +133 -0
- package/src/hash.ts +12 -0
- package/src/index.ts +589 -0
- package/src/mailbox.ts +105 -0
- package/src/parser.ts +103 -0
- package/src/paths.ts +38 -0
- package/src/prompt.ts +20 -0
- package/src/state.ts +119 -0
- package/src/store.ts +91 -0
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
|
+
}
|