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/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
+ }
@@ -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
+ }