opencode-global-habit-profile 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/App.vue ADDED
@@ -0,0 +1,295 @@
1
+ <script>
2
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
+ import { checkpointTitle, dailyTrend, groupCheckpoints, groupTodos, projectDirectoryLabel, reportGroups, trendBars } from './ui-model.js'
4
+ import { viewFromHash, watchHashView } from './ui-navigation.js'
5
+
6
+ export default {
7
+ setup() {
8
+ const data = ref(null)
9
+ const error = ref('')
10
+ const language = ref('es')
11
+ const theme = ref('dark')
12
+ const activeView = ref(viewFromHash(window.location.hash))
13
+ const busy = ref(false)
14
+ const includeArchived = ref(false)
15
+ const token = document.querySelector('meta[name="habit-token"]')?.content
16
+ const t = (es, en) => language.value === 'es' ? es : en
17
+ const scopes = computed(() => groupCheckpoints(data.value?.checkpoints ?? []))
18
+ const projectDirectories = computed(() => new Map(scopes.value.map(({ project, directoryName }) => [project, directoryName])))
19
+ const todoGroups = computed(() => groupTodos(data.value?.todos ?? [], includeArchived.value, projectDirectories.value))
20
+ const todoStatuses = ['pending', 'completed', 'archived']
21
+ const reports = computed(() => reportGroups(data.value?.habits.counts ?? {}))
22
+ const trend = computed(() => dailyTrend(data.value?.habits.daily ?? {}))
23
+ const bars = computed(() => trendBars(trend.value))
24
+ const hasHabitData = computed(() => reports.value.some((group) => group.total > 0) || trend.value.length > 0)
25
+ const kindLabel = (kind) => ({
26
+ tool: t('Herramienta', 'Tool'),
27
+ cli: t('Comando', 'Command'),
28
+ }[kind] ?? '')
29
+ const reportTitle = (key) => ({
30
+ tools: t('Herramientas', 'Tools'),
31
+ commands: t('Comandos', 'Commands'),
32
+ }[key] ?? key)
33
+ const signalLabel = (signal) => signal === 'request'
34
+ ? t('solicitud explícita', 'explicit request')
35
+ : t('uso observado', 'observed use')
36
+ const todoStatusLabel = (status) => ({
37
+ pending: t('Pendientes', 'Pending'),
38
+ completed: t('Completados', 'Completed'),
39
+ archived: t('Archivados', 'Archived'),
40
+ }[status] ?? status)
41
+
42
+ watch(language, (value) => { document.documentElement.lang = value })
43
+ watch(theme, (value) => { document.documentElement.dataset.theme = value }, { immediate: true })
44
+
45
+ async function request(path, body) {
46
+ const response = await fetch(path, {
47
+ method: body ? 'POST' : 'GET',
48
+ cache: 'no-store',
49
+ headers: body ? { 'content-type': 'application/json', origin: location.origin, 'x-habit-token': token } : {},
50
+ body: body && JSON.stringify(body),
51
+ })
52
+ if (!response.ok) throw new Error(`${t('El servidor local rechazó la solicitud', 'The local server rejected the request')} (${response.status}).`)
53
+ return body ? null : response.json()
54
+ }
55
+
56
+ async function load() {
57
+ try { data.value = await request('/api/data'); error.value = '' } catch (e) { error.value = e.message }
58
+ }
59
+
60
+ async function mutate(path, body, confirmText) {
61
+ if (busy.value || (confirmText && !confirm(confirmText))) return
62
+ busy.value = true
63
+ error.value = ''
64
+ try { await request(path, body); await load() } catch (e) { error.value = e.message } finally { busy.value = false }
65
+ }
66
+
67
+ async function edit(item) {
68
+ if (busy.value) return
69
+ const summary = prompt(t('Resumen del punto guardado', 'Checkpoint summary'), item.summary)
70
+ if (summary === null) return
71
+ if (!summary.trim() || summary.length > 240) { error.value = t('Escribe entre 1 y 240 caracteres.', 'Enter 1–240 characters.'); return }
72
+ await mutate('/api/checkpoint/edit', { project: item.project, context: item.context, id: item.id, summary })
73
+ }
74
+
75
+ function removeCheckpoint(item) {
76
+ return mutate('/api/checkpoint/delete', { project: item.project, context: item.context, id: item.id },
77
+ t('¿Eliminar este punto guardado?', 'Delete this checkpoint?'))
78
+ }
79
+
80
+ function removeScope(scope) {
81
+ const message = t(
82
+ `¿Eliminar todos los puntos de este contexto (${scope.items.length})?\nProyecto: ${JSON.stringify(scope.project)}\nContexto: ${JSON.stringify(scope.context)}`,
83
+ `Delete all checkpoints in this context (${scope.items.length})?\nProject: ${JSON.stringify(scope.project)}\nContext: ${JSON.stringify(scope.context)}`,
84
+ )
85
+ return mutate('/api/scope/delete', { project: scope.project, context: scope.context }, message)
86
+ }
87
+
88
+ function clearHabits() {
89
+ return mutate('/api/habits/clear', { confirm: true }, t(
90
+ '¿Borrar los conteos acumulados y tendencias de hábitos? Esta acción no se puede deshacer. Los checkpoints no se borrarán.',
91
+ 'Clear all accumulated habit counts and trends? This cannot be undone. Checkpoints will not be deleted.',
92
+ ))
93
+ }
94
+
95
+ let stopWatchingHash = () => {}
96
+ let eventSource, refreshTimer
97
+ const refreshOnVisible = () => { if (!document.hidden) load() }
98
+ onMounted(() => {
99
+ stopWatchingHash = watchHashView(window, (view) => {
100
+ activeView.value = view
101
+ window.scrollTo(0, 0)
102
+ })
103
+ load()
104
+ if (typeof EventSource === 'function') {
105
+ eventSource = new EventSource('/api/events')
106
+ eventSource.onmessage = () => {
107
+ clearTimeout(refreshTimer)
108
+ refreshTimer = setTimeout(load, 150)
109
+ }
110
+ }
111
+ document.addEventListener('visibilitychange', refreshOnVisible)
112
+ })
113
+ onBeforeUnmount(() => {
114
+ stopWatchingHash()
115
+ eventSource?.close()
116
+ clearTimeout(refreshTimer)
117
+ document.removeEventListener('visibilitychange', refreshOnVisible)
118
+ })
119
+ return { data, error, language, theme, activeView, busy, includeArchived, t, scopes, todoGroups, todoStatuses, todoStatusLabel, reports, trend, bars, hasHabitData, reportTitle, signalLabel, kindLabel, checkpointTitle, projectDirectoryLabel, load, edit, removeCheckpoint, removeScope, clearHabits }
120
+ },
121
+ }
122
+ </script>
123
+
124
+ <template>
125
+ <div class="app-shell" id="inicio">
126
+ <aside class="sidebar">
127
+ <a class="brand" href="#reports">Habit Memory</a>
128
+ <nav class="sidebar-nav" :aria-label="t('Navegación principal', 'Main navigation')">
129
+ <a href="#reports" :aria-current="activeView === 'reports' ? 'page' : undefined">{{ t('Informes', 'Reports') }}</a>
130
+ <a href="#checkpoints" :aria-current="activeView === 'checkpoints' ? 'page' : undefined">{{ t('Puntos guardados', 'Saved checkpoints') }}</a>
131
+ <a href="#todos" :aria-current="activeView === 'todos' ? 'page' : undefined">{{ t('TODOs', 'TODOs') }}</a>
132
+ </nav>
133
+ <p class="local-note">{{ t('Tus datos permanecen en este equipo.', 'Your data stays on this machine.') }}</p>
134
+ </aside>
135
+
136
+ <div class="workspace">
137
+ <header class="masthead">
138
+ <div>
139
+ <p class="eyebrow">{{ t('MEMORIA DE TRABAJO', 'WORKING MEMORY') }}</p>
140
+ <h1>{{ t('Tu memoria de trabajo.', 'Your working memory.') }}</h1>
141
+ <p class="intro">{{ t('Hábitos, puntos guardados y TODOs de este equipo.', 'Stored habits, checkpoints, and TODOs on this machine.') }}</p>
142
+ </div>
143
+ <div class="masthead-controls">
144
+ <label class="language"><span>{{ t('Idioma', 'Language') }}</span>
145
+ <select v-model="language">
146
+ <option value="es">ES</option>
147
+ <option value="en">EN</option>
148
+ </select>
149
+ </label>
150
+ <button class="theme-toggle" type="button" :aria-pressed="theme === 'light'"
151
+ :aria-label="theme === 'dark' ? t('Cambiar a tema claro', 'Switch to light theme') : t('Cambiar a tema oscuro', 'Switch to dark theme')"
152
+ @click="theme = theme === 'dark' ? 'light' : 'dark'">
153
+ {{ theme === 'dark' ? t('Claro', 'Light') : t('Oscuro', 'Dark') }}
154
+ </button>
155
+ </div>
156
+ </header>
157
+
158
+ <main>
159
+ <p class="page-note">{{ t('Este índice contiene resúmenes, hábitos agregados y TODOs persistentes del complemento.', 'This index contains summaries, aggregate habits, and persistent TODOs saved by the plugin.') }}</p>
160
+ <p v-if="error" class="notice" role="alert">{{ error }} <button type="button" :disabled="busy" @click="load">{{ t('Reintentar', 'Retry') }}</button></p>
161
+ <p v-if="busy" role="status">{{ t('Guardando cambios…', 'Saving changes…') }}</p>
162
+ <p v-if="!data && !error" role="status">{{ t('Leyendo la memoria local…', 'Reading local memory…') }}</p>
163
+ <template v-if="data">
164
+ <section id="reports" v-show="activeView === 'reports'" aria-labelledby="reports-heading">
165
+ <header class="report-heading">
166
+ <div>
167
+ <h2 id="reports-heading">{{ t('Informes de actividad', 'Activity reports') }}</h2>
168
+ <p class="section-note">{{ t('Conteos acumulados heredados incluidos; las tendencias empiezan con la instrumentación nueva y no reconstruyen el pasado.', 'Includes legacy accumulated counts; trends start with the new instrumentation and do not reconstruct the past.') }}</p>
169
+ </div>
170
+ <button type="button" class="action-delete" :disabled="busy || !hasHabitData" @click="clearHabits">{{ t('Borrar hábitos', 'Clear habits') }}</button>
171
+ </header>
172
+ <div class="report-grid">
173
+ <article v-for="group in reports" :key="group.key" class="report-card">
174
+ <header class="report-card-head">
175
+ <h3>{{ reportTitle(group.key) }}</h3>
176
+ <strong class="report-total">{{ group.total }}</strong>
177
+ </header>
178
+ <ol v-if="group.items.length" class="report-ranking">
179
+ <li v-for="item in group.items" :key="item.key">
180
+ <div class="report-label">
181
+ <span>{{ signalLabel(item.signal) }} · {{ kindLabel(item.kind) }} · {{ item.name }}</span>
182
+ <code>{{ item.key }}</code>
183
+ </div>
184
+ <strong>{{ item.count }}</strong>
185
+ </li>
186
+ </ol>
187
+ <p v-else class="empty-report">{{ t('Sin conteos.', 'No counts yet.') }}</p>
188
+ </article>
189
+ </div>
190
+
191
+ <div class="trend-panel">
192
+ <h3>{{ t('Tendencia diaria · UTC', 'Daily trend · UTC') }}</h3>
193
+ <p v-if="!trend.length" class="empty-report">{{ t('Aún no hay actividad diaria. La serie comienza ahora; los conteos anteriores no tienen fecha asignada.', 'No daily activity yet. The series starts now; earlier counts have no assigned date.') }}</p>
194
+ <template v-else>
195
+ <p class="section-note">{{ t(`Días registrados: ${trend[0].date} – ${trend.at(-1).date}.`, `Recorded days: ${trend[0].date} – ${trend.at(-1).date}.`) }}</p>
196
+ <ul class="trend-legend" :aria-label="t('Leyenda del gráfico', 'Chart legend')">
197
+ <li class="tools-series">{{ t('Herramientas', 'Tools') }}</li>
198
+ <li class="commands-series">{{ t('Comandos', 'Commands') }}</li>
199
+ </ul>
200
+ <svg class="trend-chart" viewBox="0 0 720 180" role="img" aria-labelledby="trend-title trend-desc">
201
+ <title id="trend-title">{{ t('Conteos diarios por categoría', 'Daily counts by category') }}</title>
202
+ <desc id="trend-desc">{{ t(`Gráfico de barras con ${trend.length} días registrados en UTC; no hay historial anterior a la instrumentación.`, `Bar chart with ${trend.length} recorded days in UTC; there is no history before instrumentation.`) }}</desc>
203
+ <line x1="28" y1="152" x2="704" y2="152" class="chart-axis" />
204
+ <rect v-for="bar in bars" :key="`${bar.date}-${bar.key}`" :class="`${bar.key}-series`"
205
+ :x="bar.x" :y="bar.y" :width="bar.width" :height="bar.height">
206
+ <title>{{ `${bar.date} · ${reportTitle(bar.key)}: ${bar.count}` }}</title>
207
+ </rect>
208
+ <text :x="trend.length === 1 ? 360 : 28" y="173" :text-anchor="trend.length === 1 ? 'middle' : 'start'">{{ trend[0].date }}</text>
209
+ <text v-if="trend.length > 1" x="704" y="173" text-anchor="end">{{ trend.at(-1).date }}</text>
210
+ </svg>
211
+ </template>
212
+ </div>
213
+ </section>
214
+
215
+ <section id="checkpoints" v-show="activeView === 'checkpoints'" aria-labelledby="checkpoints-heading">
216
+ <h2 id="checkpoints-heading">{{ t('Puntos guardados', 'Saved checkpoints') }}</h2>
217
+ <section class="stat-strip checkpoint-summary" :aria-label="t('Resumen de la memoria', 'Memory overview')">
218
+ <article class="stat-card">
219
+ <span>{{ t('Puntos guardados', 'Saved checkpoints') }}</span>
220
+ <strong>{{ data.checkpoints.length }}</strong>
221
+ </article>
222
+ </section>
223
+ <p v-if="!scopes.length">{{ t('No hay puntos guardados.', 'No checkpoints saved.') }}</p>
224
+ <div v-else class="scope-list">
225
+ <details v-for="scope in scopes" :key="scope.key" class="scope">
226
+ <summary class="project-summary">
227
+ <div class="scope-provenance">
228
+ <span class="scope-caption">{{ t('Directorio', 'Directory') }} <strong class="scope-directory">{{ projectDirectoryLabel(scope, language) }}</strong></span>
229
+ <span class="scope-caption">{{ t('Proyecto', 'Project') }} · {{ scope.items.length }} {{ scope.items.length === 1 ? t('punto', 'checkpoint') : t('puntos', 'checkpoints') }}</span>
230
+ <code>{{ scope.project }}</code>
231
+ </div>
232
+ </summary>
233
+ <div class="project-checkpoints">
234
+ <div v-for="item in scope.items" :key="item.id" class="entry">
235
+ <div class="entry-content">
236
+ <h3 class="entry-title">{{ checkpointTitle(item, language) }}</h3>
237
+ <p class="entry-summary">{{ item.summary }}</p>
238
+ <p class="entry-context"><span>{{ t('Contexto', 'Context') }}</span> <code>{{ item.context }}</code></p>
239
+ <time :datetime="item.createdAt">{{ new Date(item.createdAt).toLocaleString(language) }}</time>
240
+ </div>
241
+ <div class="entry-actions">
242
+ <button type="button" class="action-edit" :disabled="busy"
243
+ :aria-label="`${t('Editar punto guardado', 'Edit checkpoint')}: ${item.summary}`"
244
+ @click="edit(item)">{{ t('Editar', 'Edit') }}</button>
245
+ <button type="button" class="action-delete" :disabled="busy"
246
+ :aria-label="`${t('Eliminar punto guardado', 'Delete checkpoint')}: ${item.summary}`"
247
+ @click="removeCheckpoint(item)">{{ t('Eliminar', 'Delete') }}</button>
248
+ </div>
249
+ </div>
250
+ </div>
251
+ </details>
252
+ </div>
253
+ </section>
254
+
255
+ <section id="todos" v-show="activeView === 'todos'" aria-labelledby="todos-heading">
256
+ <header class="report-heading todo-heading">
257
+ <div>
258
+ <h2 id="todos-heading">{{ t('TODOs por proyecto', 'TODOs by project') }}</h2>
259
+ <p class="section-note">{{ t('Tareas persistentes agrupadas por proyecto y estado.', 'Persistent tasks grouped by project and status.') }}</p>
260
+ </div>
261
+ <label class="include-archived">
262
+ <input v-model="includeArchived" type="checkbox">
263
+ <span>{{ t('Incluir archivados', 'Include archived') }}</span>
264
+ </label>
265
+ </header>
266
+ <p v-if="!todoGroups.length" class="empty-report">
267
+ {{ includeArchived ? t('No hay TODOs guardados.', 'No TODOs saved.') : t('No hay TODOs activos. Incluye los archivados para verlos.', 'No active TODOs. Include archived items to see them.') }}
268
+ </p>
269
+ <div v-else class="scope-list">
270
+ <details v-for="group in todoGroups" :key="group.key" class="scope todo-project">
271
+ <summary class="project-summary">
272
+ <div class="scope-provenance">
273
+ <span class="scope-caption">{{ t('Directorio', 'Directory') }} <strong class="scope-directory">{{ projectDirectoryLabel(group, language) }}</strong></span>
274
+ <span class="scope-caption">{{ t('Proyecto', 'Project') }} · {{ group.total }} {{ group.total === 1 ? t('tarea', 'task') : t('tareas', 'tasks') }}</span>
275
+ <code>{{ group.project }}</code>
276
+ </div>
277
+ </summary>
278
+ <div class="todo-status-list">
279
+ <template v-for="status in todoStatuses" :key="status">
280
+ <section v-if="group[status].length" class="todo-status">
281
+ <h3 class="todo-status-heading"><span>{{ todoStatusLabel(status) }}</span><span>{{ group[status].length }}</span></h3>
282
+ <ul class="todo-items">
283
+ <li v-for="todo in group[status]" :key="todo.id">{{ todo.task }}</li>
284
+ </ul>
285
+ </section>
286
+ </template>
287
+ </div>
288
+ </details>
289
+ </div>
290
+ </section>
291
+ </template>
292
+ </main>
293
+ </div>
294
+ </div>
295
+ </template>
package/src/mcp.js ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from "node:readline"
3
+ import { getData, listCheckpoints, listCheckpointContexts, saveCheckpoint, deleteCheckpoint, clearCheckpoints } from "./store.js"
4
+
5
+ const tools = [
6
+ { name: "habit_memory_get", description: "Get aggregated habit counts only.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
7
+ { name: "checkpoint_list", description: "List checkpoints for a project and context.", inputSchema: { type: "object", properties: { project: { type: "string", maxLength: 128 }, context: { type: "string", maxLength: 128 } }, required: ["project", "context"], additionalProperties: false } },
8
+ { name: "checkpoint_projects", description: "List project scope hashes already present, for sharing plugin checkpoints across clients.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
9
+ { name: "checkpoint_contexts", description: "List checkpoint contexts for a project scope.", inputSchema: { type: "object", properties: { project: { type: "string", maxLength: 128 } }, required: ["project"], additionalProperties: false } },
10
+ { name: "checkpoint_save", description: "Save an explicit short checkpoint; never pass prompts or transcripts.", inputSchema: { type: "object", properties: { project: { type: "string", maxLength: 128 }, context: { type: "string", maxLength: 128 }, summary: { type: "string", maxLength: 240 } }, required: ["project", "context", "summary"], additionalProperties: false } },
11
+ { name: "checkpoint_delete", description: "Delete one checkpoint by ID.", inputSchema: { type: "object", properties: { project: { type: "string", maxLength: 128 }, context: { type: "string", maxLength: 128 }, id: { type: "string", maxLength: 80 } }, required: ["project", "context", "id"], additionalProperties: false } },
12
+ { name: "checkpoint_clear", description: "Explicitly delete all checkpoints for one project/context.", inputSchema: { type: "object", properties: { project: { type: "string", maxLength: 128 }, context: { type: "string", maxLength: 128 }, confirm: { type: "boolean", const: true } }, required: ["project", "context", "confirm"], additionalProperties: false } },
13
+ ]
14
+ const reply = (id, result, error) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, ...(error ? { error: { code: -32603, message: error.message } } : { result }) })}\n`)
15
+ async function call(name, a) {
16
+ if (!a || Object.keys(a).some((k) => !({ habit_memory_get: [], checkpoint_list: ["project","context"], checkpoint_projects: [], checkpoint_contexts: ["project"], checkpoint_save: ["project","context","summary"], checkpoint_delete: ["project","context","id"], checkpoint_clear: ["project","context","confirm"] }[name] ?? []).includes(k))) throw new Error("Invalid tool arguments")
17
+ if (name === "habit_memory_get" && Object.keys(a).length === 0) return (await getData()).habits
18
+ if (name === "checkpoint_projects" && Object.keys(a).length === 0) return [...new Set((await getData()).checkpoints.map((c) => c.project))]
19
+ if (name === "checkpoint_contexts") return listCheckpointContexts(a.project)
20
+ if (name === "checkpoint_list") return (await listCheckpoints(a.project, a.context)).map(({ id, project, context, summary, createdAt }) => ({ id, project, context, summary, createdAt }))
21
+ if (name === "checkpoint_save") return saveCheckpoint(a.project, a.context, a.summary)
22
+ if (name === "checkpoint_delete") return deleteCheckpoint(a.project, a.context, a.id)
23
+ if (name === "checkpoint_clear" && a.confirm === true) return clearCheckpoints(a.project, a.context)
24
+ throw new Error("Unknown tool or explicit confirmation required")
25
+ }
26
+ const rl = createInterface({ input: process.stdin })
27
+ for await (const line of rl) {
28
+ try {
29
+ const msg = JSON.parse(line)
30
+ if (msg.method === "initialize") reply(msg.id, { protocolVersion: msg.params?.protocolVersion ?? "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "habit-memory", version: "1.0.0" } })
31
+ else if (msg.method === "notifications/initialized" || msg.id === undefined) continue
32
+ else if (msg.method === "ping") reply(msg.id, {})
33
+ else if (msg.method === "tools/list") reply(msg.id, { tools })
34
+ else if (msg.method === "tools/call") {
35
+ try { const result = await call(msg.params?.name, msg.params?.arguments ?? {}); reply(msg.id, { content: [{ type: "text", text: JSON.stringify(result) }] }) }
36
+ catch (error) { reply(msg.id, { content: [{ type: "text", text: error.message }], isError: true }) }
37
+ } else reply(msg.id, null, new Error("Method not found"))
38
+ } catch (error) { reply(null, null, error) }
39
+ }
package/src/store.js ADDED
@@ -0,0 +1,281 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { mkdir, open, readFile, rename, stat, unlink } from "node:fs/promises"
3
+ import { dirname, join } from "node:path"
4
+ import { homedir } from "node:os"
5
+
6
+ const maxBytes = 2_000_000
7
+ const categories = new Set([
8
+ ...["bash", "read", "write", "edit", "patch", "glob", "grep", "task", "subagent", "websearch", "webfetch"].flatMap((n) => [`tool:${n}`, `request:tool:${n}`]),
9
+ ...["git", "php", "python", "python3", "pytest", "composer", "npm", "pnpm", "yarn", "bun", "docker", "opencode", "codesearch"].flatMap((n) => [`cli:${n}`, `request:cli:${n}`]),
10
+ ...["build", "plan", "code", "explore", "general", "orquestador", "01-debug", "02-agente-plan", "03-fix", "04-test", "agente-debugger", "cambio-quirurgico", "ciberseguridad", "django-expert", "docker", "docker-expert", "frontend-designer", "git", "investigador-cientifico", "investigador-secundario", "linux-expert", "minecraft", "versionador"].map((n) => `request:agent:${n}`),
11
+ ])
12
+ const root = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
13
+ export const storePath = join(root, "habit-memory-plugin", "store-v1.json")
14
+ const lockPath = `${storePath}.lock`
15
+ const recoveryPath = `${lockPath}.recovery`
16
+ const staleLockMs = 120_000
17
+ const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino
18
+ async function unlinkIfSame(path, identity) {
19
+ try { if (sameFile(identity, await stat(path))) await unlink(path) } catch {}
20
+ }
21
+ async function hasRecoveryClaim() {
22
+ try { await stat(recoveryPath); return true } catch (error) {
23
+ if (error.code === "ENOENT") return false
24
+ throw error
25
+ }
26
+ }
27
+ async function lockSnapshot() { return { stat: await stat(lockPath), contents: await readFile(lockPath, "utf8") } }
28
+ function sameLock(a, b) {
29
+ return sameFile(a.stat, b.stat) && a.stat.size === b.stat.size && a.stat.mtimeMs === b.stat.mtimeMs && a.stat.ctimeMs === b.stat.ctimeMs && a.contents === b.contents
30
+ }
31
+ async function recoverStaleLock() {
32
+ let initial
33
+ try {
34
+ initial = await lockSnapshot()
35
+ if (Date.now() - initial.stat.mtimeMs <= staleLockMs) return
36
+ } catch { return }
37
+ const pid = Number(initial.contents), validPid = Number.isSafeInteger(pid) && pid > 0
38
+ if (validPid) {
39
+ try { process.kill(pid, 0); return } catch (error) { if (error.code !== "ESRCH") return }
40
+ }
41
+ let claim
42
+ try { claim = await open(recoveryPath, "wx", 0o600) } catch { return }
43
+ try {
44
+ const before = await lockSnapshot()
45
+ if (!sameLock(initial, before) || Date.now() - before.stat.mtimeMs <= staleLockMs) return
46
+ if (validPid) {
47
+ try { process.kill(pid, 0); return } catch (error) { if (error.code !== "ESRCH") return }
48
+ }
49
+ const current = await lockSnapshot()
50
+ if (!sameLock(before, current) || Date.now() - current.stat.mtimeMs <= staleLockMs) return
51
+ if (validPid) {
52
+ try { process.kill(pid, 0); return } catch (error) { if (error.code !== "ESRCH") return }
53
+ }
54
+ await unlink(lockPath)
55
+ } catch {
56
+ // Any uncertainty leaves the existing lock in place.
57
+ } finally {
58
+ await claim.close().catch(() => {})
59
+ await unlink(recoveryPath).catch(() => {})
60
+ }
61
+ }
62
+
63
+ const empty = () => ({ version: 1, habits: { version: 1, counts: {}, daily: {} }, checkpoints: [], todos: [] })
64
+ const text = (v, max = 240) => typeof v === "string" && v.length > 0 && v.length <= max
65
+ const basenameValue = (v) => typeof v === "string" && v.length > 0 && v.length <= 128 && v === v.trim() && v !== "." && v !== ".." && !/[\\/\u0000-\u001f\u007f]/.test(v) ? v : undefined
66
+ const validDate = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10) === value
67
+ function valid(data) {
68
+ if (!data || Object.keys(data).some((k) => !["version", "habits", "checkpoints", "todos"].includes(k)) || data.version !== 1 || !data.habits || Object.keys(data.habits).some((k) => !["version", "counts", "daily", "legacyImported"].includes(k)) || data.habits.version !== 1 || !data.habits.counts || (data.habits.legacyImported !== undefined && typeof data.habits.legacyImported !== "boolean") || !Array.isArray(data.checkpoints) || (data.todos !== undefined && !Array.isArray(data.todos))) throw new Error("Invalid habit-memory store schema")
69
+ for (const [key, count] of Object.entries(data.habits.counts)) if (!categories.has(key) || !Number.isSafeInteger(count) || count < 1 || count > 999) throw new Error("Invalid habits data")
70
+ const daily = data.habits.daily === undefined ? {} : data.habits.daily
71
+ if (!daily || typeof daily !== "object" || Array.isArray(daily) || Object.keys(daily).length > 365) throw new Error("Invalid daily habit data")
72
+ for (const [date, counts] of Object.entries(daily)) {
73
+ if (!validDate(date) || !counts || typeof counts !== "object" || Array.isArray(counts) || !Object.keys(counts).length) throw new Error("Invalid daily habit data")
74
+ for (const [key, count] of Object.entries(counts)) if (!categories.has(key) || !Number.isSafeInteger(count) || count < 1) throw new Error("Invalid daily habit data")
75
+ }
76
+ data.habits.daily = daily
77
+ if (data.checkpoints.length > 10000 || data.checkpoints.some((c) => !c || Object.keys(c).some((k) => !["id", "project", "context", "summary", "createdAt", "sessionTitle", "projectDirectoryName"].includes(k)) || !text(c.id, 80) || !text(c.project, 128) || !text(c.context, 128) || !text(c.summary) || !text(c.createdAt, 40) || (c.sessionTitle !== undefined && !text(c.sessionTitle, 200)) || (c.projectDirectoryName !== undefined && !basenameValue(c.projectDirectoryName)))) throw new Error("Invalid checkpoint data")
78
+ data.todos ??= []
79
+ if (data.todos.length > 10000 || data.todos.some((todo) => !todo || Object.keys(todo).some((k) => !["id", "project", "task", "status", "createdAt", "updatedAt", "projectDirectoryName"].includes(k)) || !text(todo.id, 80) || !text(todo.project, 128) || !text(todo.task) || todo.task !== todo.task.trim() || !["pending", "completed", "archived"].includes(todo.status) || !text(todo.createdAt, 40) || !text(todo.updatedAt, 40) || (todo.projectDirectoryName !== undefined && !basenameValue(todo.projectDirectoryName)))) throw new Error("Invalid TODO data")
80
+ return data
81
+ }
82
+ async function read() {
83
+ try {
84
+ const raw = await readFile(storePath)
85
+ if (raw.length > maxBytes) throw new Error("Storage exceeds size limit")
86
+ return valid(JSON.parse(raw))
87
+ } catch (error) {
88
+ if (error.code === "ENOENT") return empty()
89
+ throw error
90
+ }
91
+ }
92
+ async function locked(fn) {
93
+ await mkdir(dirname(storePath), { recursive: true, mode: 0o700 })
94
+ const deadline = Date.now() + 5000
95
+ let handle, identity
96
+ while (!handle) {
97
+ try {
98
+ const candidate = await open(lockPath, "wx", 0o600)
99
+ let candidateIdentity
100
+ try {
101
+ candidateIdentity = await candidate.stat()
102
+ if (await hasRecoveryClaim()) {
103
+ await candidate.close()
104
+ await unlinkIfSame(lockPath, candidateIdentity)
105
+ } else {
106
+ handle = candidate
107
+ identity = candidateIdentity
108
+ }
109
+ } catch (error) {
110
+ await candidate.close().catch(() => {})
111
+ if (candidateIdentity) await unlinkIfSame(lockPath, candidateIdentity)
112
+ throw error
113
+ }
114
+ }
115
+ catch (error) {
116
+ if (error.code !== "EEXIST") throw error
117
+ await recoverStaleLock()
118
+ }
119
+ if (!handle) {
120
+ if (Date.now() >= deadline) throw new Error("Storage is locked")
121
+ await new Promise((resolve) => setTimeout(resolve, 25))
122
+ }
123
+ }
124
+ try {
125
+ await handle.writeFile(String(process.pid))
126
+ return await fn()
127
+ } finally {
128
+ await handle.close()
129
+ await unlinkIfSame(lockPath, identity)
130
+ }
131
+ }
132
+ async function write(data) {
133
+ valid(data)
134
+ const serialized = JSON.stringify(data)
135
+ if (Buffer.byteLength(serialized) > maxBytes) throw new Error("Storage exceeds size limit")
136
+ const temp = `${storePath}.${process.pid}.${randomUUID()}.tmp`
137
+ const handle = await open(temp, "wx", 0o600)
138
+ try {
139
+ await handle.writeFile(serialized)
140
+ await handle.sync()
141
+ await handle.close()
142
+ await rename(temp, storePath)
143
+ } catch (error) {
144
+ await handle.close().catch(() => {})
145
+ await unlink(temp).catch(() => {})
146
+ throw error
147
+ }
148
+ }
149
+ const scope = (project, context) => {
150
+ if (!text(project, 128) || !text(context, 128)) throw new Error("project and context are required (max 128 chars)")
151
+ }
152
+ export async function getData() { return read() }
153
+ export async function saveHabitCounts(counts) {
154
+ return locked(async () => { const data = await read(); data.habits = { ...data.habits, version: 1, counts }; await write(data) })
155
+ }
156
+ export async function incrementHabitCounts(keys) {
157
+ return locked(async () => {
158
+ const data = await read()
159
+ const accepted = [...new Set(keys.filter((key) => categories.has(key)))]
160
+ if (!accepted.length) return
161
+ const today = new Date().toISOString().slice(0, 10)
162
+ const cutoff = new Date(`${today}T00:00:00.000Z`)
163
+ cutoff.setUTCDate(cutoff.getUTCDate() - 364)
164
+ for (const date of Object.keys(data.habits.daily)) if (date < cutoff.toISOString().slice(0, 10) || date > today) delete data.habits.daily[date]
165
+ const daily = data.habits.daily[today] ??= {}
166
+ for (const key of accepted) {
167
+ data.habits.counts[key] = Math.min(999, (data.habits.counts[key] ?? 0) + 1)
168
+ daily[key] = Math.min(Number.MAX_SAFE_INTEGER, (daily[key] ?? 0) + 1)
169
+ }
170
+ await write(data)
171
+ })
172
+ }
173
+ export async function clearHabits() {
174
+ return locked(async () => {
175
+ const data = await read()
176
+ data.habits = { version: 1, counts: {}, daily: {}, ...(data.habits.legacyImported === undefined ? {} : { legacyImported: data.habits.legacyImported }) }
177
+ await write(data)
178
+ })
179
+ }
180
+ export async function saveCheckpoint(project, context, summary, sessionTitle, projectDirectoryName) {
181
+ scope(project, context)
182
+ if (!text(summary)) throw new Error("summary is required (max 240 chars)")
183
+ return locked(async () => {
184
+ const data = await read()
185
+ const title = typeof sessionTitle === "string" ? sessionTitle.trim() : ""
186
+ const directory = basenameValue(typeof projectDirectoryName === "string" ? projectDirectoryName.trim() : projectDirectoryName)
187
+ const record = { id: randomUUID(), project, context, summary, createdAt: new Date().toISOString(), ...(title && title.length <= 200 ? { sessionTitle: title } : {}), ...(directory ? { projectDirectoryName: directory } : {}) }
188
+ data.checkpoints.push(record)
189
+ await write(data); return record
190
+ })
191
+ }
192
+ export async function listCheckpoints(project, context) {
193
+ scope(project, context)
194
+ return (await read()).checkpoints.filter((c) => c.project === project && c.context === context).sort((a,b) => b.createdAt.localeCompare(a.createdAt))
195
+ }
196
+ export async function listCheckpointContexts(project) {
197
+ if (!text(project, 128)) throw new Error("project is required (max 128 chars)")
198
+ return [...new Set((await read()).checkpoints.filter((c) => c.project === project).map((c) => c.context))].sort()
199
+ }
200
+ export async function deleteCheckpoint(project, context, id) {
201
+ scope(project, context)
202
+ if (!text(id, 80)) throw new Error("id is required (max 80 chars)")
203
+ return locked(async () => { const data = await read(), before = data.checkpoints.length; data.checkpoints = data.checkpoints.filter((c) => !(c.project === project && c.context === context && c.id === id)); if (before !== data.checkpoints.length) await write(data); return before !== data.checkpoints.length })
204
+ }
205
+ export async function updateCheckpoint(project, context, id, summary) {
206
+ scope(project, context)
207
+ if (!text(id, 80) || !text(summary)) throw new Error("id and summary are required (max 240 chars)")
208
+ return locked(async () => {
209
+ const data = await read(), checkpoint = data.checkpoints.find((c) => c.project === project && c.context === context && c.id === id)
210
+ if (!checkpoint) return false
211
+ checkpoint.summary = summary
212
+ await write(data)
213
+ return true
214
+ })
215
+ }
216
+ export async function clearCheckpoints(project, context) {
217
+ scope(project, context)
218
+ return locked(async () => { const data = await read(), before = data.checkpoints.length; data.checkpoints = data.checkpoints.filter((c) => c.project !== project || c.context !== context); if (before !== data.checkpoints.length) await write(data); return before - data.checkpoints.length })
219
+ }
220
+ export async function createTodo(project, taskValue, directoryNameValue) {
221
+ if (!text(project, 128)) throw new Error("project is required (max 128 chars)")
222
+ const task = typeof taskValue === "string" ? taskValue.trim() : ""
223
+ if (!text(task)) throw new Error("task is required (max 240 chars)")
224
+ const directoryName = basenameValue(typeof directoryNameValue === "string" ? directoryNameValue.trim() : directoryNameValue)
225
+ return locked(async () => {
226
+ const data = await read()
227
+ if (data.todos.length >= 10000) throw new Error("TODO limit reached")
228
+ const now = new Date().toISOString()
229
+ const todo = { id: randomUUID(), project, task, status: "pending", createdAt: now, updatedAt: now, ...(directoryName ? { projectDirectoryName: directoryName } : {}) }
230
+ data.todos.push(todo)
231
+ await write(data)
232
+ return todo
233
+ })
234
+ }
235
+ export async function listTodos(project, includeArchived = false) {
236
+ if (!text(project, 128) || typeof includeArchived !== "boolean") throw new Error("project and a boolean archive filter are required")
237
+ return (await read()).todos.filter((todo) => todo.project === project && (includeArchived || todo.status !== "archived"))
238
+ }
239
+ export async function updateTodo(project, id, taskValue, status) {
240
+ if (!text(project, 128) || !text(id, 80)) throw new Error("project and id are required")
241
+ const task = taskValue === undefined ? undefined : typeof taskValue === "string" ? taskValue.trim() : ""
242
+ if ((task !== undefined && !text(task)) || (status !== undefined && !["pending", "completed"].includes(status)) || (task === undefined && status === undefined)) throw new Error("a non-empty task or pending/completed status is required")
243
+ return locked(async () => {
244
+ const data = await read(), todo = data.todos.find((item) => item.project === project && item.id === id && item.status !== "archived")
245
+ if (!todo) return false
246
+ if (task !== undefined) todo.task = task
247
+ if (status !== undefined) todo.status = status
248
+ todo.updatedAt = new Date().toISOString()
249
+ await write(data)
250
+ return true
251
+ })
252
+ }
253
+ export async function archiveTodo(project, id) {
254
+ if (!text(project, 128) || !text(id, 80)) throw new Error("project and id are required")
255
+ return locked(async () => {
256
+ const data = await read(), todo = data.todos.find((item) => item.project === project && item.id === id && item.status !== "archived")
257
+ if (!todo) return false
258
+ todo.status = "archived"
259
+ todo.updatedAt = new Date().toISOString()
260
+ await write(data)
261
+ return true
262
+ })
263
+ }
264
+ export async function importLegacy(profile, checkpoint) {
265
+ return locked(async () => {
266
+ const data = await read()
267
+ let changed = false
268
+ if (!data.habits.legacyImported) {
269
+ const counts = profile?.version === 1 && profile.counts
270
+ ? Object.fromEntries(Object.entries(profile.counts).filter(([k,v]) => categories.has(k) && Number.isSafeInteger(v) && v > 0).map(([k,v]) => [k, Math.min(v,999)]))
271
+ : data.habits.counts
272
+ data.habits = { ...data.habits, version: 1, counts, legacyImported: true }
273
+ changed = true
274
+ }
275
+ if (checkpoint?.version === 1 && text(checkpoint.summary) && text(checkpoint.project, 128) && !data.checkpoints.some((c) => c.id === `legacy-${checkpoint.project}`)) {
276
+ data.checkpoints.push({ id: `legacy-${checkpoint.project}`, project: checkpoint.project, context: "legacy", summary: checkpoint.summary, createdAt: new Date(0).toISOString() })
277
+ changed = true
278
+ }
279
+ if (changed) await write(data)
280
+ })
281
+ }