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/LICENSE +21 -0
- package/README.md +68 -2
- package/dist/assets/index-CeYHNSfT.css +1 -0
- package/dist/assets/index-Nel_NS0n.js +21 -0
- package/dist/index.html +4 -0
- package/docs/mcp-setup.md +32 -0
- package/index.js +465 -0
- package/package.json +51 -4
- package/src/App.vue +295 -0
- package/src/mcp.js +39 -0
- package/src/store.js +281 -0
- package/src/ui-model.js +108 -0
- package/src/ui-navigation.js +13 -0
- package/src/ui-server.js +273 -0
- package/src/ui.css +166 -0
- package/src/ui.js +5 -0
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
|
+
}
|