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/config.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { assertInside, isSafeRecipient } from "./paths.js"
|
|
3
|
+
|
|
4
|
+
export type MailboxConfig = {
|
|
5
|
+
mailboxDir?: string
|
|
6
|
+
project?: string | null
|
|
7
|
+
maxAutoRounds?: number
|
|
8
|
+
cooldownMs?: number
|
|
9
|
+
enabled?: boolean
|
|
10
|
+
stateFile?: string | null
|
|
11
|
+
wake?: boolean
|
|
12
|
+
pollMs?: number
|
|
13
|
+
spawnOnSend?: boolean
|
|
14
|
+
spawnCooldownMs?: number
|
|
15
|
+
maxSpawnsPerTurn?: number
|
|
16
|
+
projectDirs?: Record<string, string>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type NormalizedConfig = {
|
|
20
|
+
mailboxDir: string
|
|
21
|
+
project: string
|
|
22
|
+
/** True when mailboxDir came from options (disables folder-tree discovery). */
|
|
23
|
+
explicitMailboxDir: boolean
|
|
24
|
+
storage: "file"
|
|
25
|
+
maxAutoRounds: number
|
|
26
|
+
cooldownMs: number
|
|
27
|
+
enabled: boolean
|
|
28
|
+
stateFile: string
|
|
29
|
+
wake: boolean
|
|
30
|
+
pollMs: number
|
|
31
|
+
spawnOnSend: boolean
|
|
32
|
+
spawnCooldownMs: number
|
|
33
|
+
maxSpawnsPerTurn: number
|
|
34
|
+
projectDirs: Record<string, string>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function clampInt(value: unknown, def: number, min: number, max: number): number {
|
|
38
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return def
|
|
39
|
+
return Math.min(max, Math.max(min, Math.floor(value)))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function nonNegativeInt(value: unknown, def: number): number {
|
|
43
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return def
|
|
44
|
+
return Math.max(0, Math.floor(value))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Turn an arbitrary directory name into a lowercase kebab-case recipient id. */
|
|
48
|
+
function toKebab(value: string): string {
|
|
49
|
+
return value
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
52
|
+
.replace(/^-+|-+$/g, "")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isStrictlyInside(base: string, candidate: string): boolean {
|
|
56
|
+
const rel = path.relative(path.resolve(base), path.resolve(candidate))
|
|
57
|
+
return rel !== "" && !path.isAbsolute(rel) && rel !== ".." && !rel.startsWith(".." + path.sep)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function normalizeConfig(
|
|
61
|
+
options: Record<string, unknown> | undefined,
|
|
62
|
+
directory: string,
|
|
63
|
+
): NormalizedConfig {
|
|
64
|
+
const opts = options ?? {}
|
|
65
|
+
|
|
66
|
+
const configuredMailboxDir =
|
|
67
|
+
typeof opts.mailboxDir === "string" && opts.mailboxDir.length > 0 ? opts.mailboxDir : null
|
|
68
|
+
const mailboxDir = path.resolve(configuredMailboxDir ?? path.join(directory, "handoffs"))
|
|
69
|
+
// Only the file backend is implemented today; the field exists so a SQLite
|
|
70
|
+
// backend can be selected later without a config-format change.
|
|
71
|
+
const storage: "file" = "file"
|
|
72
|
+
|
|
73
|
+
const rawProject = opts.project
|
|
74
|
+
const configuredProject = typeof rawProject === "string" && rawProject.length > 0 ? rawProject : null
|
|
75
|
+
// Identity is resolved per session, not per config:
|
|
76
|
+
// explicit `project` wins; otherwise a session whose directory *contains*
|
|
77
|
+
// the shared mailbox directory is the workspace parent, and every other
|
|
78
|
+
// session uses its own directory name. The containment rule only applies to
|
|
79
|
+
// an explicitly configured mailboxDir, so the per-project default
|
|
80
|
+
// (`<directory>/handoffs`) never turns every project into the parent.
|
|
81
|
+
const project =
|
|
82
|
+
configuredProject ??
|
|
83
|
+
(configuredMailboxDir !== null && isStrictlyInside(directory, mailboxDir)
|
|
84
|
+
? "workspace"
|
|
85
|
+
: toKebab(path.basename(path.resolve(directory))))
|
|
86
|
+
if (!isSafeRecipient(project)) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
configuredProject !== null
|
|
89
|
+
? `Invalid project name "${configuredProject}": must be lowercase kebab-case`
|
|
90
|
+
: `Cannot derive a valid project name from "${directory}": set the "project" plugin option`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const maxAutoRounds = clampInt(opts.maxAutoRounds, 2, 0, 20)
|
|
95
|
+
const cooldownMs = nonNegativeInt(opts.cooldownMs, 30000)
|
|
96
|
+
const enabled = typeof opts.enabled === "boolean" ? opts.enabled : true
|
|
97
|
+
const wake = typeof opts.wake === "boolean" ? opts.wake : false
|
|
98
|
+
const pollMs = clampInt(opts.pollMs, 10000, 100, 600000)
|
|
99
|
+
const spawnOnSend = typeof opts.spawnOnSend === "boolean" ? opts.spawnOnSend : false
|
|
100
|
+
const spawnCooldownMs = nonNegativeInt(opts.spawnCooldownMs, 60000)
|
|
101
|
+
const maxSpawnsPerTurn = clampInt(opts.maxSpawnsPerTurn, 1, 0, 10)
|
|
102
|
+
const projectDirs: Record<string, string> = {}
|
|
103
|
+
if (opts.projectDirs && typeof opts.projectDirs === "object" && !Array.isArray(opts.projectDirs)) {
|
|
104
|
+
for (const [key, value] of Object.entries(opts.projectDirs as Record<string, unknown>)) {
|
|
105
|
+
if (typeof value === "string" && value.length > 0 && isSafeRecipient(key)) {
|
|
106
|
+
projectDirs[key] = path.resolve(value)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const rawStateFile =
|
|
112
|
+
typeof opts.stateFile === "string" && opts.stateFile.length > 0
|
|
113
|
+
? opts.stateFile
|
|
114
|
+
: path.join(directory, ".atl", "mailbox-state.json")
|
|
115
|
+
const stateFile = assertInside(directory, rawStateFile, "stateFile")
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
mailboxDir,
|
|
119
|
+
project,
|
|
120
|
+
explicitMailboxDir: configuredMailboxDir !== null,
|
|
121
|
+
storage,
|
|
122
|
+
maxAutoRounds,
|
|
123
|
+
cooldownMs,
|
|
124
|
+
enabled,
|
|
125
|
+
stateFile,
|
|
126
|
+
wake,
|
|
127
|
+
pollMs,
|
|
128
|
+
spawnOnSend,
|
|
129
|
+
spawnCooldownMs,
|
|
130
|
+
maxSpawnsPerTurn,
|
|
131
|
+
projectDirs,
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
export type Tier = "meta" | "account" | "repo" | "custom"
|
|
5
|
+
|
|
6
|
+
export type TierContext = {
|
|
7
|
+
tier: Tier
|
|
8
|
+
/** Mailbox identity inside its own tier: "workspace" for a tier root, else the directory name. */
|
|
9
|
+
project: string
|
|
10
|
+
/** Directory holding this tier's files: `<tierRoot>/handoffs`. */
|
|
11
|
+
mailboxDir: string
|
|
12
|
+
/** Session directory the context was derived from. */
|
|
13
|
+
directory: string
|
|
14
|
+
/** The account directory (account or repo tier). */
|
|
15
|
+
accountRoot?: string
|
|
16
|
+
accountName?: string
|
|
17
|
+
/** The workspace root directory (meta tier, or the ancestor above an account). */
|
|
18
|
+
rootDir?: string
|
|
19
|
+
/** The root tier's handoffs directory, when one exists above this tier. */
|
|
20
|
+
rootMailboxDir?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function kebab(value: string): string {
|
|
24
|
+
return value
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
27
|
+
.replace(/^-+|-+$/g, "")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hasHandoffs(dir: string): boolean {
|
|
31
|
+
return existsSync(path.join(dir, "handoffs", "workspace.md")) || existsSync(path.join(dir, "handoffs"))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Derive the tier from the folder tree:
|
|
36
|
+
*
|
|
37
|
+
* - a directory with its own `handoffs/` is a tier root: meta when nothing
|
|
38
|
+
* above it has one, account when the parent does;
|
|
39
|
+
* - anything else belongs to the nearest ancestor that has one (repo tier),
|
|
40
|
+
* whatever the bucket depth (`<account>/tools/<repo>` still resolves);
|
|
41
|
+
* - an explicit `mailboxDir` in config overrides discovery entirely.
|
|
42
|
+
*/
|
|
43
|
+
export function discoverTier(
|
|
44
|
+
directory: string,
|
|
45
|
+
explicit?: { mailboxDir?: string; project?: string },
|
|
46
|
+
): TierContext {
|
|
47
|
+
const dir = path.resolve(directory)
|
|
48
|
+
|
|
49
|
+
if (explicit?.mailboxDir) {
|
|
50
|
+
const mailboxDir = path.resolve(explicit.mailboxDir)
|
|
51
|
+
const inside = isStrictlyInside(dir, mailboxDir)
|
|
52
|
+
return {
|
|
53
|
+
tier: "custom",
|
|
54
|
+
project: explicit.project ?? (inside ? "workspace" : kebab(path.basename(dir))),
|
|
55
|
+
mailboxDir,
|
|
56
|
+
directory: dir,
|
|
57
|
+
rootDir: inside ? path.dirname(mailboxDir) : undefined,
|
|
58
|
+
rootMailboxDir: inside ? mailboxDir : undefined,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (hasHandoffs(dir)) {
|
|
63
|
+
const parent = path.dirname(dir)
|
|
64
|
+
const above = parent !== dir && hasHandoffs(parent)
|
|
65
|
+
return {
|
|
66
|
+
tier: above ? "account" : "meta",
|
|
67
|
+
project: "workspace",
|
|
68
|
+
mailboxDir: path.join(dir, "handoffs"),
|
|
69
|
+
directory: dir,
|
|
70
|
+
accountRoot: above ? dir : undefined,
|
|
71
|
+
accountName: above ? kebab(path.basename(dir)) : undefined,
|
|
72
|
+
rootDir: above ? parent : dir,
|
|
73
|
+
rootMailboxDir: path.join(above ? parent : dir, "handoffs"),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let cursor = dir
|
|
78
|
+
for (;;) {
|
|
79
|
+
const parent = path.dirname(cursor)
|
|
80
|
+
if (parent === cursor) break
|
|
81
|
+
if (hasHandoffs(parent)) {
|
|
82
|
+
const grand = path.dirname(parent)
|
|
83
|
+
return {
|
|
84
|
+
tier: "repo",
|
|
85
|
+
project: kebab(path.basename(dir)),
|
|
86
|
+
mailboxDir: path.join(parent, "handoffs"),
|
|
87
|
+
directory: dir,
|
|
88
|
+
accountRoot: parent,
|
|
89
|
+
accountName: kebab(path.basename(parent)),
|
|
90
|
+
rootDir: grand,
|
|
91
|
+
rootMailboxDir: hasHandoffs(grand) ? path.join(grand, "handoffs") : undefined,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
cursor = parent
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
tier: "repo",
|
|
99
|
+
project: kebab(path.basename(dir)),
|
|
100
|
+
mailboxDir: path.join(dir, "handoffs"),
|
|
101
|
+
directory: dir,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type Route = { mailboxDir: string; project: string }
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Where a message addressed to `to` belongs.
|
|
109
|
+
* - `workspace` -> this tier's own inbox (the tier root's `workspace.md`)
|
|
110
|
+
* - an account name -> the root tier's `<account>.md` (escalate up / lateral between accounts)
|
|
111
|
+
* - anything else -> a sibling/child file in this tier (lateral / delegate down)
|
|
112
|
+
*/
|
|
113
|
+
export function routeRecipient(to: string, ctx: TierContext): Route {
|
|
114
|
+
if (to === "workspace") return { mailboxDir: ctx.mailboxDir, project: "workspace" }
|
|
115
|
+
if (to === ctx.project) return { mailboxDir: ctx.mailboxDir, project: ctx.project }
|
|
116
|
+
|
|
117
|
+
if (ctx.tier !== "meta" && ctx.rootMailboxDir) {
|
|
118
|
+
const ownAccount = ctx.accountName !== undefined && to === ctx.accountName
|
|
119
|
+
const siblingAccount =
|
|
120
|
+
existsSync(path.join(ctx.rootMailboxDir, `${to}.md`)) ||
|
|
121
|
+
(ctx.rootDir !== undefined && hasHandoffs(path.join(ctx.rootDir, to)))
|
|
122
|
+
if (ownAccount || siblingAccount) {
|
|
123
|
+
return { mailboxDir: ctx.rootMailboxDir, project: to }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { mailboxDir: ctx.mailboxDir, project: to }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isStrictlyInside(base: string, candidate: string): boolean {
|
|
131
|
+
const rel = path.relative(path.resolve(base), path.resolve(candidate))
|
|
132
|
+
return rel !== "" && !path.isAbsolute(rel) && rel !== ".." && !rel.startsWith(".." + path.sep)
|
|
133
|
+
}
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
export function normalizeForHash(text: string): string {
|
|
4
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function messageHash(parts: { header: string; topic: string; body: string }): string {
|
|
8
|
+
const header = normalizeForHash(parts.header)
|
|
9
|
+
const topic = normalizeForHash(parts.topic)
|
|
10
|
+
const body = normalizeForHash(parts.body)
|
|
11
|
+
return createHash("sha256").update(`${header}\n${topic}\n${body}`).digest("hex").slice(0, 16)
|
|
12
|
+
}
|