@ddtcorex/dsh-maestro-memory 1.0.1
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/CHANGELOG.md +96 -0
- package/LICENSE +21 -0
- package/README.md +448 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +756 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +627 -0
- package/lib/index.js.map +1 -0
- package/lib/memory/batch.d.ts +44 -0
- package/lib/memory/batch.d.ts.map +1 -0
- package/lib/memory/batch.js +75 -0
- package/lib/memory/batch.js.map +1 -0
- package/lib/memory/feedback.d.ts +18 -0
- package/lib/memory/feedback.d.ts.map +1 -0
- package/lib/memory/feedback.js +29 -0
- package/lib/memory/feedback.js.map +1 -0
- package/lib/memory/store.d.ts +154 -0
- package/lib/memory/store.d.ts.map +1 -0
- package/lib/memory/store.js +606 -0
- package/lib/memory/store.js.map +1 -0
- package/lib/migration/cli.d.ts +18 -0
- package/lib/migration/cli.d.ts.map +1 -0
- package/lib/migration/cli.js +143 -0
- package/lib/migration/cli.js.map +1 -0
- package/lib/migration/fixture.d.ts +45 -0
- package/lib/migration/fixture.d.ts.map +1 -0
- package/lib/migration/fixture.js +177 -0
- package/lib/migration/fixture.js.map +1 -0
- package/lib/migration/service.d.ts +75 -0
- package/lib/migration/service.d.ts.map +1 -0
- package/lib/migration/service.js +713 -0
- package/lib/migration/service.js.map +1 -0
- package/lib/prompt/snapshot.d.ts +26 -0
- package/lib/prompt/snapshot.d.ts.map +1 -0
- package/lib/prompt/snapshot.js +74 -0
- package/lib/prompt/snapshot.js.map +1 -0
- package/lib/review/queue.d.ts +72 -0
- package/lib/review/queue.d.ts.map +1 -0
- package/lib/review/queue.js +306 -0
- package/lib/review/queue.js.map +1 -0
- package/lib/skills-browser.d.ts +67 -0
- package/lib/skills-browser.d.ts.map +1 -0
- package/lib/skills-browser.js +185 -0
- package/lib/skills-browser.js.map +1 -0
- package/lib/storage/atomic-store.d.ts +211 -0
- package/lib/storage/atomic-store.d.ts.map +1 -0
- package/lib/storage/atomic-store.js +582 -0
- package/lib/storage/atomic-store.js.map +1 -0
- package/lib/storage/layout.d.ts +81 -0
- package/lib/storage/layout.d.ts.map +1 -0
- package/lib/storage/layout.js +162 -0
- package/lib/storage/layout.js.map +1 -0
- package/lib/storage/legacy-format.d.ts +104 -0
- package/lib/storage/legacy-format.d.ts.map +1 -0
- package/lib/storage/legacy-format.js +262 -0
- package/lib/storage/legacy-format.js.map +1 -0
- package/lib/sync/config.d.ts +22 -0
- package/lib/sync/config.d.ts.map +1 -0
- package/lib/sync/config.js +57 -0
- package/lib/sync/config.js.map +1 -0
- package/lib/sync/git.d.ts +97 -0
- package/lib/sync/git.d.ts.map +1 -0
- package/lib/sync/git.js +282 -0
- package/lib/sync/git.js.map +1 -0
- package/lib/sync/index.d.ts +6 -0
- package/lib/sync/index.d.ts.map +1 -0
- package/lib/sync/index.js +6 -0
- package/lib/sync/index.js.map +1 -0
- package/lib/sync/layout.d.ts +15 -0
- package/lib/sync/layout.d.ts.map +1 -0
- package/lib/sync/layout.js +33 -0
- package/lib/sync/layout.js.map +1 -0
- package/lib/sync/merge.d.ts +42 -0
- package/lib/sync/merge.d.ts.map +1 -0
- package/lib/sync/merge.js +159 -0
- package/lib/sync/merge.js.map +1 -0
- package/lib/sync/service.d.ts +70 -0
- package/lib/sync/service.d.ts.map +1 -0
- package/lib/sync/service.js +508 -0
- package/lib/sync/service.js.map +1 -0
- package/lib/todo/store.d.ts +142 -0
- package/lib/todo/store.d.ts.map +1 -0
- package/lib/todo/store.js +452 -0
- package/lib/todo/store.js.map +1 -0
- package/lib/types/client/index.d.ts +7 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/package.json +63 -0
- package/src/client/index.tsx +994 -0
- package/src/host/index.ts +591 -0
- package/src/host/memory/batch.ts +107 -0
- package/src/host/memory/feedback.ts +36 -0
- package/src/host/memory/store.ts +650 -0
- package/src/host/migration/cli.ts +139 -0
- package/src/host/migration/fixture.ts +218 -0
- package/src/host/migration/service.ts +786 -0
- package/src/host/prompt/snapshot.ts +95 -0
- package/src/host/review/queue.ts +325 -0
- package/src/host/skills-browser.ts +191 -0
- package/src/host/storage/atomic-store.ts +605 -0
- package/src/host/storage/layout.ts +191 -0
- package/src/host/storage/legacy-format.ts +308 -0
- package/src/host/sync/config.ts +67 -0
- package/src/host/sync/git.ts +280 -0
- package/src/host/sync/index.ts +5 -0
- package/src/host/sync/layout.ts +39 -0
- package/src/host/sync/merge.ts +188 -0
- package/src/host/sync/service.ts +494 -0
- package/src/host/todo/store.ts +480 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* storage/layout.ts — path resolution for five memory files, four todo files,
|
|
3
|
+
* archives, metadata, and legacy project hash.
|
|
4
|
+
* Pure, no Cordis import. Uses node:path + node:crypto.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
/** Resolve the canonical memories root: config.memoryDir or ~/.dsh/memories */
|
|
12
|
+
export function resolveMemoryRoot(memoryDir: string | null | undefined): string {
|
|
13
|
+
if (memoryDir) return memoryDir
|
|
14
|
+
return join(homedir(), '.dsh', 'memories')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 12-hex project hash for a cwd (sha1(cwd).slice(0,12)) */
|
|
18
|
+
export function projectHash(cwd: string): string {
|
|
19
|
+
if (!cwd) throw new Error('projectHash: cwd is required')
|
|
20
|
+
return createHash('sha1').update(cwd).digest('hex').slice(0, 12)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Legacy alias — same as projectHash, explicit for spec compliance. */
|
|
24
|
+
export const legacyProjectHash = projectHash
|
|
25
|
+
|
|
26
|
+
export function globalMemoryPath(root: string): string {
|
|
27
|
+
return join(root, 'MEMORY.md')
|
|
28
|
+
}
|
|
29
|
+
export function userMemoryPath(root: string): string {
|
|
30
|
+
return join(root, 'USER.md')
|
|
31
|
+
}
|
|
32
|
+
export function globalArchivePath(root: string): string {
|
|
33
|
+
return join(root, 'MEMORY-archive.md')
|
|
34
|
+
}
|
|
35
|
+
export function userArchivePath(root: string): string {
|
|
36
|
+
return join(root, 'USER-archive.md')
|
|
37
|
+
}
|
|
38
|
+
export function suggestionsPath(root: string): string {
|
|
39
|
+
return join(root, 'SUGGESTIONS.jsonl')
|
|
40
|
+
}
|
|
41
|
+
export function dailyDir(root: string): string {
|
|
42
|
+
return join(root, 'daily')
|
|
43
|
+
}
|
|
44
|
+
/** Reject non-YYYY-MM-DD values before they are interpolated into a path (path-traversal guard). */
|
|
45
|
+
function assertDate(date: string): void {
|
|
46
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
47
|
+
throw new Error(`invalid date "${date}" (expected YYYY-MM-DD)`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function dailyPath(root: string, date: string): string {
|
|
51
|
+
assertDate(date)
|
|
52
|
+
return join(root, 'daily', `${date}.md`)
|
|
53
|
+
}
|
|
54
|
+
export function dailyTodoPath(root: string, date: string): string {
|
|
55
|
+
assertDate(date)
|
|
56
|
+
return join(root, 'daily', `${date}.todo.md`)
|
|
57
|
+
}
|
|
58
|
+
export function projectDir(root: string, cwd: string): string {
|
|
59
|
+
return join(root, 'projects', projectHash(cwd))
|
|
60
|
+
}
|
|
61
|
+
export function projectMemoryPath(root: string, cwd: string): string {
|
|
62
|
+
return join(projectDir(root, cwd), 'MEMORY.md')
|
|
63
|
+
}
|
|
64
|
+
export function projectKeyPath(root: string, cwd: string): string {
|
|
65
|
+
return join(projectDir(root, cwd), 'KEY.md')
|
|
66
|
+
}
|
|
67
|
+
export function projectKeyArchivePath(root: string, cwd: string): string {
|
|
68
|
+
return join(projectDir(root, cwd), 'KEY-archive.md')
|
|
69
|
+
}
|
|
70
|
+
export function projectTodoPath(root: string, cwd: string): string {
|
|
71
|
+
return join(projectDir(root, cwd), 'TODOS.md')
|
|
72
|
+
}
|
|
73
|
+
export function lifeTodoPath(root: string): string {
|
|
74
|
+
return join(root, 'TODOS-life.md')
|
|
75
|
+
}
|
|
76
|
+
export function workTodoPath(root: string): string {
|
|
77
|
+
return join(root, 'TODOS-work.md')
|
|
78
|
+
}
|
|
79
|
+
export function maestroMetaDir(root: string): string {
|
|
80
|
+
return join(root, '.maestro-memory')
|
|
81
|
+
}
|
|
82
|
+
export function schemaPath(root: string): string {
|
|
83
|
+
return join(maestroMetaDir(root), 'schema.json')
|
|
84
|
+
}
|
|
85
|
+
export function journalPath(root: string): string {
|
|
86
|
+
return join(maestroMetaDir(root), 'migration-journal.jsonl')
|
|
87
|
+
}
|
|
88
|
+
export function backupsDir(root: string): string {
|
|
89
|
+
return join(maestroMetaDir(root), 'backups')
|
|
90
|
+
}
|
|
91
|
+
export function todoArchivePath(root: string): string {
|
|
92
|
+
return join(root, 'TODO-archive.md')
|
|
93
|
+
}
|
|
94
|
+
export function backupManifestPath(root: string, runId: string): string {
|
|
95
|
+
if (!runId) throw new Error('backupManifestPath: runId is required')
|
|
96
|
+
return join(backupsDir(root), runId, 'manifest.json')
|
|
97
|
+
}
|
|
98
|
+
export function backupFilesDirPath(root: string, runId: string): string {
|
|
99
|
+
if (!runId) throw new Error('backupFilesDirPath: runId is required')
|
|
100
|
+
return join(backupsDir(root), runId, 'files')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Aggregated pure resolvers — convenience for inspectors/migration
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve all five memory files at once (pure).
|
|
109
|
+
*/
|
|
110
|
+
export function allMemoryPaths(root: string, cwd: string, date: string): {
|
|
111
|
+
memory: string
|
|
112
|
+
user: string
|
|
113
|
+
daily: string
|
|
114
|
+
project: string
|
|
115
|
+
key: string
|
|
116
|
+
} {
|
|
117
|
+
return {
|
|
118
|
+
memory: globalMemoryPath(root),
|
|
119
|
+
user: userMemoryPath(root),
|
|
120
|
+
daily: dailyPath(root, date),
|
|
121
|
+
project: projectMemoryPath(root, cwd),
|
|
122
|
+
key: projectKeyPath(root, cwd),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve all four todo files at once (pure).
|
|
128
|
+
*/
|
|
129
|
+
export function allTodoPaths(root: string, cwd: string, date: string): {
|
|
130
|
+
life: string
|
|
131
|
+
work: string
|
|
132
|
+
project: string
|
|
133
|
+
daily: string
|
|
134
|
+
} {
|
|
135
|
+
return {
|
|
136
|
+
life: lifeTodoPath(root),
|
|
137
|
+
work: workTodoPath(root),
|
|
138
|
+
project: projectTodoPath(root, cwd),
|
|
139
|
+
daily: dailyTodoPath(root, date),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolve all archive files at once (pure).
|
|
145
|
+
*/
|
|
146
|
+
export function allArchivePaths(root: string, cwd: string): {
|
|
147
|
+
memory: string
|
|
148
|
+
user: string
|
|
149
|
+
key: string
|
|
150
|
+
todo: string
|
|
151
|
+
} {
|
|
152
|
+
return {
|
|
153
|
+
memory: globalArchivePath(root),
|
|
154
|
+
user: userArchivePath(root),
|
|
155
|
+
key: projectKeyArchivePath(root, cwd),
|
|
156
|
+
todo: todoArchivePath(root),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolve metadata files at once (pure).
|
|
162
|
+
*/
|
|
163
|
+
export function allMetadataPaths(root: string, runId?: string): {
|
|
164
|
+
metaDir: string
|
|
165
|
+
schema: string
|
|
166
|
+
journal: string
|
|
167
|
+
backupsDir: string
|
|
168
|
+
backupManifest: string | undefined
|
|
169
|
+
backupFilesDir: string | undefined
|
|
170
|
+
} {
|
|
171
|
+
const meta = maestroMetaDir(root)
|
|
172
|
+
const bDir = backupsDir(root)
|
|
173
|
+
return {
|
|
174
|
+
metaDir: meta,
|
|
175
|
+
schema: schemaPath(root),
|
|
176
|
+
journal: journalPath(root),
|
|
177
|
+
backupsDir: bDir,
|
|
178
|
+
backupManifest: runId ? backupManifestPath(root, runId) : undefined,
|
|
179
|
+
backupFilesDir: runId ? backupFilesDirPath(root, runId) : undefined,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Aliases for spec compatibility (different naming conventions)
|
|
184
|
+
export const resolveMemoryFiles = allMemoryPaths
|
|
185
|
+
export const resolveTodoFiles = allTodoPaths
|
|
186
|
+
export const resolveArchiveFiles = allArchivePaths
|
|
187
|
+
export const resolveMetadataFiles = allMetadataPaths
|
|
188
|
+
export const getMemoryPaths = allMemoryPaths
|
|
189
|
+
export const getTodoPaths = allTodoPaths
|
|
190
|
+
export const getArchivePaths = allArchivePaths
|
|
191
|
+
export const getMetadataPaths = allMetadataPaths
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* storage/legacy-format.ts — Pure §-entry and legacy todo-tag parsing/serialization.
|
|
3
|
+
*
|
|
4
|
+
* Pure module — no framework imports. All functions are deterministic and side-effect free.
|
|
5
|
+
* Covers:
|
|
6
|
+
* - § delimiter (ENTRY_DELIMITER) parse/serialize/canonical
|
|
7
|
+
* - branch tags [branch:...]
|
|
8
|
+
* - summaries [summary:...] (header-anchored)
|
|
9
|
+
* - DSH-only marker
|
|
10
|
+
* - legacy todo-tag grammar (first-line tags + multiline content)
|
|
11
|
+
*
|
|
12
|
+
* Behaviour is byte-compatible with legacy memory files
|
|
13
|
+
* (previous store.js + todo.js).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// § delimiter
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** Entry delimiter, byte-compatible with MEMORY.md / USER.md. */
|
|
21
|
+
export const ENTRY_DELIMITER = '\n§\n'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Split raw file text into trimmed, non-empty entries.
|
|
25
|
+
*/
|
|
26
|
+
export function parseEntries(text: string): string[] {
|
|
27
|
+
return String(text ?? '')
|
|
28
|
+
.split(ENTRY_DELIMITER)
|
|
29
|
+
.map((entry) => entry.trim())
|
|
30
|
+
.filter((entry) => entry.length > 0)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Serialize entries into canonical file text (entries joined by the
|
|
35
|
+
* delimiter plus a trailing newline).
|
|
36
|
+
*/
|
|
37
|
+
export function serializeEntries(entries: string[]): string {
|
|
38
|
+
return entries.join(ENTRY_DELIMITER) + '\n'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether raw text is the canonical serialization of its own entries.
|
|
43
|
+
* Blank text counts as canonical (an empty store).
|
|
44
|
+
*/
|
|
45
|
+
export function isCanonical(text: string): boolean {
|
|
46
|
+
return String(text ?? '').trim() === '' || serializeEntries(parseEntries(text)) === text
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Entry ID helpers (minimal, for header stripping)
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const ENTRY_ID_RE = /^\[id:\s*[0-9a-f]{8}\]\s*/i
|
|
54
|
+
|
|
55
|
+
function stripEntryId(entry: string): string {
|
|
56
|
+
return String(entry ?? '').replace(ENTRY_ID_RE, '')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Extract the YYYY-MM-DD date from an entry's stamp prefix; null when absent. */
|
|
60
|
+
export function extractEntryDate(entry: string): string | null {
|
|
61
|
+
const match = /^\[(\d{4}-\d{2}-\d{2})/.exec(stripEntryId(String(entry ?? '')))
|
|
62
|
+
return match ? match[1] : null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Branch tags
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Branch-scope tag inside a KEY entry: `[2026-08-06] [branch:main,dev] content`.
|
|
71
|
+
* Absent = visible in EVERY branch ("all").
|
|
72
|
+
*/
|
|
73
|
+
export const BRANCH_TAG_RE = /(?:^\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*)?\[branch:([^\]]*)\]\s*/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse the branch scope of one KEY entry.
|
|
77
|
+
*/
|
|
78
|
+
export function parseEntryBranches(entry: string): string[] | null {
|
|
79
|
+
const match = BRANCH_TAG_RE.exec(String(entry ?? ''))
|
|
80
|
+
if (match === null) return null
|
|
81
|
+
const branches = match[1].split(',').map((b) => b.trim()).filter(Boolean)
|
|
82
|
+
return branches.length > 0 ? branches : null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// DSH-only marker
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
export const DSH_ONLY_TAG = '[dsh-only]'
|
|
90
|
+
export const DSH_ONLY_RE = /\[dsh-only\]\s*/
|
|
91
|
+
|
|
92
|
+
export function parseEntryDshOnly(entry: string): boolean {
|
|
93
|
+
return DSH_ONLY_RE.test(String(entry ?? ''))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Summaries
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
export const SUMMARY_TAG_RE = /\[summary:([^\]]*)\]/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Regex for the entry header (program-metadata prefix): [id:...] -> timestamp
|
|
104
|
+
* -> [git ...] x N -> [branch:...] -> [dsh-only], matched in the known token
|
|
105
|
+
* order of splitEntryHead. Summary parsing and stripping are both anchored
|
|
106
|
+
* to this header.
|
|
107
|
+
*/
|
|
108
|
+
const ENTRY_HEAD_RE =
|
|
109
|
+
/^(?:\[id:\s*[0-9a-f]{8}\]\s*)?(?:\[\d{4}-\d{2}-\d{2}(?: \d{1,2}:\d{2}(?::\d{2})?)?\]\s*|\[\d{1,2}:\d{2}(?::\d{2})?\]\s*)?(?:\[git [^\]]+\]\s*)*(?:\[branch:[^\]]*\]\s*)?(?:\[dsh-only\]\s*)?/i
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Parse the summary tag of a memory entry (only recognizes [summary:...] at the header position).
|
|
113
|
+
*/
|
|
114
|
+
export function parseEntrySummary(entry: string): string | null {
|
|
115
|
+
const text = String(entry ?? '')
|
|
116
|
+
const head = ENTRY_HEAD_RE.exec(text)
|
|
117
|
+
if (head === null) return null
|
|
118
|
+
const match = /^\[summary:([^\]]*)\]\s*/.exec(text.slice(head[0].length))
|
|
119
|
+
return match ? match[1] : null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Return the program-metadata prefix ([id]/timestamp/[git]/[branch]/[dsh-only]) of an entry.
|
|
124
|
+
* Useful for compact renderings that keep the header and drop the body.
|
|
125
|
+
*/
|
|
126
|
+
export function entryHeadPrefix(entry: string): string {
|
|
127
|
+
const match = ENTRY_HEAD_RE.exec(String(entry ?? ''))
|
|
128
|
+
return match === null ? '' : match[0]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Strip the "summary" marker for display (only at header position).
|
|
133
|
+
*/
|
|
134
|
+
export function stripEntrySummary(entry: string): string {
|
|
135
|
+
const match = ENTRY_HEAD_RE.exec(String(entry ?? ''))
|
|
136
|
+
const prefix = match === null ? '' : match[0]
|
|
137
|
+
const rest = String(entry ?? '').slice(prefix.length)
|
|
138
|
+
return prefix + rest.replace(/^\[summary:[^\]]*\]\s*/, '')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Auto-generate a summary from the entry body (fallback when no explicit [summary:...] exists).
|
|
143
|
+
*/
|
|
144
|
+
export function autoSummary(entry: string, maxLen = 80): string {
|
|
145
|
+
let rest = String(entry ?? '').trim()
|
|
146
|
+
const head = ENTRY_HEAD_RE.exec(rest)
|
|
147
|
+
if (head !== null) rest = rest.slice(head[0].length)
|
|
148
|
+
rest = rest.replace(/^\[summary:[^\]]*\]\s*/, '')
|
|
149
|
+
const firstLine = rest.split('\n')[0].trim()
|
|
150
|
+
if (firstLine.length <= maxLen) return firstLine
|
|
151
|
+
return firstLine.slice(0, maxLen - 1) + '…'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// splitEntryHead
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Strip all prefix markers from an entry: timestamp + `[git ...]` + `[branch:...]` + `[dsh-only]` + `[summary:...]`,
|
|
160
|
+
* returning the prefix head and the body. Keeps same parsing as Memory Tab pretty view.
|
|
161
|
+
*/
|
|
162
|
+
export function splitEntryHead(
|
|
163
|
+
entry: string,
|
|
164
|
+
target: string,
|
|
165
|
+
): { head: string; body: string } {
|
|
166
|
+
let rest = String(entry ?? '').trim()
|
|
167
|
+
const timeRe =
|
|
168
|
+
target === 'project'
|
|
169
|
+
? /^\[(\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}(?::\d{2})?)\]\s*/
|
|
170
|
+
: target === 'daily'
|
|
171
|
+
? /^\[(\d{1,2}:\d{2}(?::\d{2})?)\]\s*/
|
|
172
|
+
: /^\[(\d{4}-\d{2}-\d{2})\]\s*/
|
|
173
|
+
const tokens: string[] = []
|
|
174
|
+
const idMatch = /^\[id:\s*([0-9a-f]{8})\]\s*/i.exec(rest)
|
|
175
|
+
if (idMatch !== null) {
|
|
176
|
+
tokens.push(idMatch[0])
|
|
177
|
+
rest = rest.slice(idMatch[0].length)
|
|
178
|
+
}
|
|
179
|
+
const timeMatch = timeRe.exec(rest)
|
|
180
|
+
if (timeMatch !== null) {
|
|
181
|
+
tokens.push(timeMatch[0])
|
|
182
|
+
rest = rest.slice(timeMatch[0].length)
|
|
183
|
+
}
|
|
184
|
+
for (;;) {
|
|
185
|
+
const gitMatch = /^\[git [^\]]+\]\s*/.exec(rest)
|
|
186
|
+
if (gitMatch === null) break
|
|
187
|
+
tokens.push(gitMatch[0])
|
|
188
|
+
rest = rest.slice(gitMatch[0].length)
|
|
189
|
+
}
|
|
190
|
+
const branchMatch = /^\[branch:[^\]]*\]\s*/.exec(rest)
|
|
191
|
+
if (branchMatch !== null) {
|
|
192
|
+
tokens.push(branchMatch[0])
|
|
193
|
+
rest = rest.slice(branchMatch[0].length)
|
|
194
|
+
}
|
|
195
|
+
const dshOnlyMatch = /^\[dsh-only\]\s*/.exec(rest)
|
|
196
|
+
if (dshOnlyMatch !== null) {
|
|
197
|
+
tokens.push(dshOnlyMatch[0])
|
|
198
|
+
rest = rest.slice(dshOnlyMatch[0].length)
|
|
199
|
+
}
|
|
200
|
+
const summaryMatch = /^\[summary:[^\]]*\]\s*/.exec(rest)
|
|
201
|
+
if (summaryMatch !== null) {
|
|
202
|
+
tokens.push(summaryMatch[0])
|
|
203
|
+
rest = rest.slice(summaryMatch[0].length)
|
|
204
|
+
}
|
|
205
|
+
if (target === 'daily') {
|
|
206
|
+
const tagMatch = /^\[([^\]]+)\]\s*/.exec(rest)
|
|
207
|
+
if (tagMatch !== null) {
|
|
208
|
+
tokens.push(tagMatch[0])
|
|
209
|
+
rest = rest.slice(tagMatch[0].length)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { head: tokens.join(''), body: rest }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Legacy todo grammar
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
export const TODO_HEADER = `<!--
|
|
220
|
+
Todo entry format (auto-maintained by the program, do not edit the structure manually):
|
|
221
|
+
- Entries are delimited by §; the comment block before the first § is the format note, not a todo
|
|
222
|
+
- The first line of each todo is the metadata tag line (fixed order, optional parts may be omitted):
|
|
223
|
+
[created time] auto-stamped by the program (e.g. [2026-08-06 21:30])
|
|
224
|
+
[id: 8-hex] unique identifier for the entry, operated by the dtodo tool
|
|
225
|
+
[q1] important & urgent [q2] important not urgent [q3] urgent not important [q4] not important not urgent (default = unclassified)
|
|
226
|
+
[due: YYYY-MM-DD] due date (default = none)
|
|
227
|
+
[status: pending|doing|done|blocked|cancelled] status (default pending)
|
|
228
|
+
[done: YYYY-MM-DD HH:MM] completion time (auto-stamped, only for done status)
|
|
229
|
+
[cat: category] optional (life/work/study...)
|
|
230
|
+
- Todo content follows the first tag line and may span multiple lines
|
|
231
|
+
-->
|
|
232
|
+
`
|
|
233
|
+
|
|
234
|
+
export const TODO_TARGETS = ['life', 'work', 'project', 'daily'] as const
|
|
235
|
+
export const TODO_STATUSES = ['pending', 'doing', 'done', 'blocked', 'cancelled'] as const
|
|
236
|
+
|
|
237
|
+
const TODO_TIME_RE = /^\[(\d{4}-\d{2}-\d{2}(?:\s\d{2}:\d{2})?)\]\s*/
|
|
238
|
+
const TODO_ID_RE = /\[id:\s*([0-9a-f]{8})\]/i
|
|
239
|
+
const TODO_QUAD_RE = /\[q([1-4])\]/i
|
|
240
|
+
const TODO_DUE_RE = /\[due:\s*(\d{4}-\d{2}-\d{2})\]/i
|
|
241
|
+
const TODO_STATUS_RE = /\[status:\s*(pending|doing|done|blocked|cancelled)\]/i
|
|
242
|
+
const TODO_DONE_RE = /\[done:\s*(\d{4}-\d{2}-\d{2}(?:\s\d{2}:\d{2})?)\]/i
|
|
243
|
+
const TODO_CAT_RE = /\[cat:\s*([^\]]+)\]/i
|
|
244
|
+
|
|
245
|
+
export interface TodoEntry {
|
|
246
|
+
id: string | null
|
|
247
|
+
time: string
|
|
248
|
+
quadrant: string | null
|
|
249
|
+
due: string | null
|
|
250
|
+
status: string
|
|
251
|
+
doneAt: string | null
|
|
252
|
+
cat: string | null
|
|
253
|
+
text: string
|
|
254
|
+
raw: string
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface TodoMeta {
|
|
258
|
+
time: string
|
|
259
|
+
id: string
|
|
260
|
+
quadrant: string | null
|
|
261
|
+
due: string | null
|
|
262
|
+
status: string
|
|
263
|
+
cat: string | null
|
|
264
|
+
doneAt: string | null
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Parse one raw todo entry into its structured form. The first line is the
|
|
269
|
+
* tag line (time stamp + tags); the rest is the todo's text (may be empty).
|
|
270
|
+
* Returns null when it has no timestamp (not a valid entry).
|
|
271
|
+
*/
|
|
272
|
+
export function parseTodoEntry(raw: string): TodoEntry | null {
|
|
273
|
+
const text = String(raw ?? '')
|
|
274
|
+
const first = text.split('\n', 1)[0]
|
|
275
|
+
const time = TODO_TIME_RE.exec(first)
|
|
276
|
+
if (time === null) return null
|
|
277
|
+
const idMatch = TODO_ID_RE.exec(first)
|
|
278
|
+
const quadMatch = TODO_QUAD_RE.exec(first)
|
|
279
|
+
const dueMatch = TODO_DUE_RE.exec(first)
|
|
280
|
+
const statusMatch = TODO_STATUS_RE.exec(first)
|
|
281
|
+
const doneMatch = TODO_DONE_RE.exec(first)
|
|
282
|
+
const catMatch = TODO_CAT_RE.exec(first)
|
|
283
|
+
const body = text.slice(first.length).trim()
|
|
284
|
+
return {
|
|
285
|
+
id: idMatch?.[1]?.toLowerCase() ?? null,
|
|
286
|
+
time: time[1],
|
|
287
|
+
quadrant: quadMatch?.[1] ? `q${quadMatch[1].toLowerCase()}` : null,
|
|
288
|
+
due: dueMatch?.[1] ?? null,
|
|
289
|
+
status: statusMatch?.[1]?.toLowerCase() ?? 'pending',
|
|
290
|
+
doneAt: doneMatch?.[1] ?? null,
|
|
291
|
+
cat: catMatch?.[1]?.trim() || null,
|
|
292
|
+
text: body,
|
|
293
|
+
raw: text,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Build one entry's tag line + content.
|
|
299
|
+
*/
|
|
300
|
+
export function stampTodoLine(meta: TodoMeta, content: string): string {
|
|
301
|
+
const parts: string[] = [`[${meta.time}]`, `[id: ${meta.id}]`]
|
|
302
|
+
if (meta.quadrant) parts.push(`[${meta.quadrant}]`)
|
|
303
|
+
if (meta.due) parts.push(`[due: ${meta.due}]`)
|
|
304
|
+
parts.push(`[status: ${meta.status ?? 'pending'}]`)
|
|
305
|
+
if (meta.cat) parts.push(`[cat: ${meta.cat}]`)
|
|
306
|
+
if (meta.doneAt) parts.push(`[done: ${meta.doneAt}]`)
|
|
307
|
+
return `${parts.join(' ')}\n${String(content).trim()}`
|
|
308
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { syncConfigPath, syncDir, syncMetaPath } from './layout.ts'
|
|
4
|
+
|
|
5
|
+
export interface SyncConfig {
|
|
6
|
+
enabled: boolean
|
|
7
|
+
remoteUrl: string
|
|
8
|
+
branch: string
|
|
9
|
+
// optional override identity not used in MVP
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SyncMeta {
|
|
13
|
+
hash: string
|
|
14
|
+
cwd: string
|
|
15
|
+
branch: string
|
|
16
|
+
remoteUrl: string
|
|
17
|
+
pushedAt: string
|
|
18
|
+
// snapshot of ids per track at last sync
|
|
19
|
+
entryIds: Record<string, string[]>
|
|
20
|
+
version: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readConfig(root: string, hash: string): SyncConfig | null {
|
|
24
|
+
const p = syncConfigPath(root, hash)
|
|
25
|
+
if (!existsSync(p)) return null
|
|
26
|
+
try {
|
|
27
|
+
const data = JSON.parse(readFileSync(p, 'utf8'))
|
|
28
|
+
if (data.enabled !== true) return null
|
|
29
|
+
if (typeof data.remoteUrl !== 'string' || typeof data.branch !== 'string') return null
|
|
30
|
+
return data as SyncConfig
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isEnabled(root: string, hash: string): boolean {
|
|
37
|
+
const cfg = readConfig(root, hash)
|
|
38
|
+
return cfg !== null && cfg.enabled === true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function writeConfig(root: string, hash: string, cfg: SyncConfig): void {
|
|
42
|
+
const p = syncConfigPath(root, hash)
|
|
43
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
44
|
+
writeFileSync(p, JSON.stringify(cfg, null, 2), 'utf8')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function clearConfig(root: string, hash: string): void {
|
|
48
|
+
const dir = syncDir(root, hash)
|
|
49
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function readMeta(root: string, hash: string): SyncMeta | null {
|
|
53
|
+
const p = syncMetaPath(root, hash)
|
|
54
|
+
if (!existsSync(p)) return null
|
|
55
|
+
try { return JSON.parse(readFileSync(p, 'utf8')) as SyncMeta } catch { return null }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function writeMeta(root: string, hash: string, meta: SyncMeta): void {
|
|
59
|
+
const p = syncMetaPath(root, hash)
|
|
60
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
61
|
+
writeFileSync(p, JSON.stringify(meta, null, 2), 'utf8')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function baseIdsFromMeta(meta: SyncMeta | null, track: string): Set<string> {
|
|
65
|
+
if (!meta || !meta.entryIds || !meta.entryIds[track]) return new Set()
|
|
66
|
+
return new Set(meta.entryIds[track])
|
|
67
|
+
}
|