@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,95 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import type { MaestroMemoryStore } from '../memory/store.ts'
|
|
3
|
+
import { entryHeadPrefix, parseEntrySummary } from '../storage/legacy-format.ts'
|
|
4
|
+
|
|
5
|
+
export interface SnapshotContext {
|
|
6
|
+
cwd: string | null
|
|
7
|
+
branch?: string
|
|
8
|
+
sessionId?: string
|
|
9
|
+
sessionName?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Default per-section byte budgets for the snapshot prompt. */
|
|
13
|
+
export const SNAPSHOT_SECTION_CAPS = { memory: 2048, user: 4096, key: 6144 } as const
|
|
14
|
+
|
|
15
|
+
export type SnapshotSectionKey = keyof typeof SNAPSHOT_SECTION_CAPS
|
|
16
|
+
|
|
17
|
+
export interface SnapshotRenderOpts {
|
|
18
|
+
/** Partial override of {@link SNAPSHOT_SECTION_CAPS}; unspecified sections keep defaults. */
|
|
19
|
+
caps?: Partial<Record<SnapshotSectionKey, number>>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SECTION_SEP = '\n---\n'
|
|
23
|
+
|
|
24
|
+
/** Compact an oversize entry to `head + [summary:…]` when it carries a summary tag; otherwise keep whole. */
|
|
25
|
+
function compactToHead(entry: string): string {
|
|
26
|
+
const summary = parseEntrySummary(entry)
|
|
27
|
+
if (summary === null) return entry
|
|
28
|
+
return `${entryHeadPrefix(entry)}[summary:${summary}]`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Keep the newest entries whose combined UTF-8 size (with separators) fits `cap`.
|
|
33
|
+
* The newest entry is always kept — compacted to its summary head when oversized
|
|
34
|
+
* and tagged; untagged oversize entries stay whole rather than vanishing.
|
|
35
|
+
*/
|
|
36
|
+
function fitSection(entries: string[], cap: number): string[] {
|
|
37
|
+
if (entries.length === 0) return []
|
|
38
|
+
const keptDesc: string[] = []
|
|
39
|
+
let used = 0
|
|
40
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
41
|
+
const isNewest = keptDesc.length === 0
|
|
42
|
+
let candidate = entries[i]
|
|
43
|
+
if (isNewest && Buffer.byteLength(candidate, 'utf8') > cap) candidate = compactToHead(candidate)
|
|
44
|
+
const cost = Buffer.byteLength(candidate, 'utf8') + (keptDesc.length ? SECTION_SEP.length : 0)
|
|
45
|
+
if (!isNewest && used + cost > cap) break
|
|
46
|
+
keptDesc.push(candidate)
|
|
47
|
+
used += cost
|
|
48
|
+
}
|
|
49
|
+
return keptDesc.reverse()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bounded snapshot renderer — contract từ README § System Prompt Snapshot:
|
|
54
|
+
* Header (sessionId/sessionName) + USER + global MEMORY + current-project KEY
|
|
55
|
+
* (branch-filtered) + end-of-turn discipline note.
|
|
56
|
+
* daily và project KHÔNG inject.
|
|
57
|
+
*/
|
|
58
|
+
export function renderSnapshot(
|
|
59
|
+
store: MaestroMemoryStore,
|
|
60
|
+
ctx: SnapshotContext,
|
|
61
|
+
opts: SnapshotRenderOpts = {},
|
|
62
|
+
): string {
|
|
63
|
+
const caps = { ...SNAPSHOT_SECTION_CAPS, ...opts.caps }
|
|
64
|
+
const parts: string[] = []
|
|
65
|
+
|
|
66
|
+
// Header
|
|
67
|
+
if (ctx.sessionId || ctx.sessionName) {
|
|
68
|
+
const header = [
|
|
69
|
+
ctx.sessionId ? `sessionId: ${ctx.sessionId}` : null,
|
|
70
|
+
ctx.sessionName ? `sessionName: ${ctx.sessionName}` : null,
|
|
71
|
+
].filter(Boolean).join(' | ')
|
|
72
|
+
if (header) parts.push(`# Session\n${header}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Bounded memory sections — delegate branch filtering to store.list, then enforce byte caps
|
|
76
|
+
const mem = fitSection(store.list('memory'), caps.memory)
|
|
77
|
+
const user = fitSection(store.list('user'), caps.user)
|
|
78
|
+
const key = ctx.cwd
|
|
79
|
+
? fitSection(store.list('key', ctx.cwd, ctx.branch ? { branch: ctx.branch } : {}), caps.key)
|
|
80
|
+
: []
|
|
81
|
+
|
|
82
|
+
if (mem.length) parts.push(`# Global Memory\n${mem.join('\n---\n')}`)
|
|
83
|
+
if (user.length) parts.push(`# User Memory\n${user.join('\n---\n')}`)
|
|
84
|
+
if (key.length) parts.push(`# Project Key Memory\n${key.join('\n---\n')}`)
|
|
85
|
+
|
|
86
|
+
// End-of-turn discipline note — verbatim contract (hardened: exactly once, always last)
|
|
87
|
+
const discipline =
|
|
88
|
+
`---\nEnd of every turn you must: 1. Write daily+project via memory entries (daily+project in one call) 2. Check dtodo list (bounded, max 8)`
|
|
89
|
+
// Defensive: strip any pre-existing discipline entry (should never occur — parts is fresh per call)
|
|
90
|
+
// then append exactly once so the note is guaranteed last even for empty stores or repeated calls.
|
|
91
|
+
const deduped = parts.filter((p) => p !== discipline)
|
|
92
|
+
deduped.push(discipline)
|
|
93
|
+
|
|
94
|
+
return deduped.join('\n\n')
|
|
95
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* review/queue.ts — SuggestionQueue for M2-PR-B confirmation queue
|
|
3
|
+
* Durable JSONL queue SUGGESTIONS.jsonl, gated memory_suggest and explicit RPC decisions.
|
|
4
|
+
* Handles append/dedupe/edited approval/reject/archive/malformed JSONL/recovery.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
import { withLockSync } from '../storage/atomic-store.ts'
|
|
10
|
+
import { resolveMemoryRoot, suggestionsPath } from '../storage/layout.ts'
|
|
11
|
+
|
|
12
|
+
export interface SuggestionEntry {
|
|
13
|
+
time: string
|
|
14
|
+
target: string
|
|
15
|
+
content: string
|
|
16
|
+
reason: string
|
|
17
|
+
cwd?: string | null
|
|
18
|
+
sessionId?: string | null
|
|
19
|
+
hits?: number
|
|
20
|
+
firstSeen?: string
|
|
21
|
+
lastSeen?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function nowIso(): string {
|
|
25
|
+
return new Date().toISOString()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function normalizeWhitespace(text: string): string {
|
|
29
|
+
return String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* SuggestionQueue — durable JSONL queue with malformed recovery and dedupe.
|
|
34
|
+
*/
|
|
35
|
+
export class SuggestionQueue {
|
|
36
|
+
constructor(private readonly file: string) {}
|
|
37
|
+
|
|
38
|
+
static fromRoot(memoryDir: string | null, file?: string): SuggestionQueue {
|
|
39
|
+
const root = resolveMemoryRoot(memoryDir)
|
|
40
|
+
return new SuggestionQueue(file ?? suggestionsPath(root))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read all suggestions; missing file -> [], malformed lines skipped. */
|
|
44
|
+
read(): SuggestionEntry[] {
|
|
45
|
+
try {
|
|
46
|
+
const text = readFileSync(this.file, 'utf8')
|
|
47
|
+
const lines = text.split('\n').filter((l) => l.trim().length > 0)
|
|
48
|
+
const out: SuggestionEntry[] = []
|
|
49
|
+
for (const line of lines) {
|
|
50
|
+
try {
|
|
51
|
+
const obj = JSON.parse(line)
|
|
52
|
+
if (obj && typeof obj.target === 'string' && typeof obj.content === 'string') {
|
|
53
|
+
out.push(obj as SuggestionEntry)
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// skip malformed line (recovery: not crashing, next write cleans)
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
} catch (e: any) {
|
|
62
|
+
if (e?.code === 'ENOENT') return []
|
|
63
|
+
throw e
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Atomically write full list (same-directory lock, temp+rename). */
|
|
68
|
+
write(entries: SuggestionEntry[]): void {
|
|
69
|
+
const dir = dirname(this.file)
|
|
70
|
+
mkdirSync(dir, { recursive: true })
|
|
71
|
+
withLockSync(dir, () => {
|
|
72
|
+
const tmp = join(dir, `.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`)
|
|
73
|
+
const content = entries.map((e) => JSON.stringify(e)).join('\n') + (entries.length > 0 ? '\n' : '')
|
|
74
|
+
writeFileSync(tmp, content, 'utf8')
|
|
75
|
+
renameSync(tmp, this.file)
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Append one suggestion with dedupe (same target+normalized content bumps hits). */
|
|
80
|
+
append(entry: SuggestionEntry): { ok: true; queued: number; deduped?: boolean; hits?: number } {
|
|
81
|
+
const dir = dirname(this.file)
|
|
82
|
+
mkdirSync(dir, { recursive: true })
|
|
83
|
+
return withLockSync(dir, () => {
|
|
84
|
+
const entries = this.readUnsafe()
|
|
85
|
+
const normalized = normalizeWhitespace(entry.content)
|
|
86
|
+
const existing = entries.find(
|
|
87
|
+
(e) => e.target === entry.target && normalizeWhitespace(e.content) === normalized,
|
|
88
|
+
)
|
|
89
|
+
if (existing) {
|
|
90
|
+
existing.hits = (existing.hits ?? 1) + 1
|
|
91
|
+
existing.lastSeen = nowIso()
|
|
92
|
+
if (entry.reason) existing.reason = entry.reason
|
|
93
|
+
this.writeUnsafe(entries)
|
|
94
|
+
return { ok: true as const, queued: entries.length, deduped: true, hits: existing.hits }
|
|
95
|
+
}
|
|
96
|
+
const toPush: SuggestionEntry = {
|
|
97
|
+
time: entry.time ?? nowIso(),
|
|
98
|
+
target: entry.target,
|
|
99
|
+
content: entry.content,
|
|
100
|
+
reason: entry.reason,
|
|
101
|
+
cwd: entry.cwd ?? null,
|
|
102
|
+
sessionId: entry.sessionId ?? null,
|
|
103
|
+
hits: 1,
|
|
104
|
+
firstSeen: nowIso(),
|
|
105
|
+
lastSeen: nowIso(),
|
|
106
|
+
}
|
|
107
|
+
entries.push(toPush)
|
|
108
|
+
this.writeUnsafe(entries)
|
|
109
|
+
return { ok: true as const, queued: entries.length }
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Mutate under lock; fn may edit entries in place. Writes back after fn. */
|
|
114
|
+
mutate<T>(fn: (entries: SuggestionEntry[]) => T): T {
|
|
115
|
+
const dir = dirname(this.file)
|
|
116
|
+
mkdirSync(dir, { recursive: true })
|
|
117
|
+
return withLockSync(dir, () => {
|
|
118
|
+
const entries = this.readUnsafe()
|
|
119
|
+
const result = fn(entries)
|
|
120
|
+
this.writeUnsafe(entries)
|
|
121
|
+
return result
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Alias for read */
|
|
126
|
+
list(): SuggestionEntry[] {
|
|
127
|
+
return this.read()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// internal read without lock (caller holds lock)
|
|
131
|
+
private readUnsafe(): SuggestionEntry[] {
|
|
132
|
+
try {
|
|
133
|
+
const text = readFileSync(this.file, 'utf8')
|
|
134
|
+
const lines = text.split('\n').filter((l) => l.trim().length > 0)
|
|
135
|
+
const out: SuggestionEntry[] = []
|
|
136
|
+
for (const line of lines) {
|
|
137
|
+
try {
|
|
138
|
+
const obj = JSON.parse(line)
|
|
139
|
+
if (obj && typeof obj.target === 'string' && typeof obj.content === 'string') out.push(obj as SuggestionEntry)
|
|
140
|
+
} catch {
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
} catch (e: any) {
|
|
146
|
+
if (e?.code === 'ENOENT') return []
|
|
147
|
+
throw e
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private writeUnsafe(entries: SuggestionEntry[]): void {
|
|
152
|
+
const dir = dirname(this.file)
|
|
153
|
+
mkdirSync(dir, { recursive: true })
|
|
154
|
+
const tmp = join(dir, `.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`)
|
|
155
|
+
const content = entries.map((e) => JSON.stringify(e)).join('\n') + (entries.length > 0 ? '\n' : '')
|
|
156
|
+
writeFileSync(tmp, content, 'utf8')
|
|
157
|
+
renameSync(tmp, this.file)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Enqueue helper with dedupe, used by memory_suggest tool */
|
|
162
|
+
export function enqueueSuggestion(
|
|
163
|
+
queue: SuggestionQueue,
|
|
164
|
+
target: string,
|
|
165
|
+
content: string,
|
|
166
|
+
reason: string,
|
|
167
|
+
agent?: { id?: string; session?: { header?: { cwd?: string | null } } },
|
|
168
|
+
): { ok: boolean; message?: string; queued: number; hits?: number } {
|
|
169
|
+
const normalized = normalizeWhitespace(content)
|
|
170
|
+
if (!normalized) return { ok: false, message: 'empty content', queued: queue.read().length }
|
|
171
|
+
if (!reason?.trim()) return { ok: false, message: 'empty reason', queued: queue.read().length }
|
|
172
|
+
const res = queue.mutate((entries) => {
|
|
173
|
+
const existing = entries.find(
|
|
174
|
+
(e) => e.target === target && normalizeWhitespace(e.content) === normalized,
|
|
175
|
+
)
|
|
176
|
+
if (existing) {
|
|
177
|
+
existing.hits = (existing.hits ?? 1) + 1
|
|
178
|
+
existing.lastSeen = nowIso()
|
|
179
|
+
if (reason) existing.reason = reason
|
|
180
|
+
return { deduped: true, hits: existing.hits, queued: entries.length }
|
|
181
|
+
}
|
|
182
|
+
entries.push({
|
|
183
|
+
time: nowIso(),
|
|
184
|
+
sessionId: agent?.id ?? null,
|
|
185
|
+
cwd: (agent?.session?.header?.cwd as string) ?? null,
|
|
186
|
+
target,
|
|
187
|
+
content,
|
|
188
|
+
reason,
|
|
189
|
+
hits: 1,
|
|
190
|
+
firstSeen: nowIso(),
|
|
191
|
+
lastSeen: nowIso(),
|
|
192
|
+
})
|
|
193
|
+
return { deduped: false, queued: entries.length }
|
|
194
|
+
})
|
|
195
|
+
if ((res as any).deduped) {
|
|
196
|
+
return { ok: true, message: `deduped (hits=${(res as any).hits})`, queued: (res as any).queued, hits: (res as any).hits }
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, queued: (res as any).queued }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Approve suggestions by 1-based indices, supports edited content and target override */
|
|
202
|
+
export function approveSuggestions(
|
|
203
|
+
store: any,
|
|
204
|
+
todoStore: any,
|
|
205
|
+
queue: SuggestionQueue,
|
|
206
|
+
indices: number[],
|
|
207
|
+
agent?: any,
|
|
208
|
+
edits?: Map<number, string>,
|
|
209
|
+
targets?: Map<number, string>,
|
|
210
|
+
options: { isTodoEnabled?: () => boolean } = {},
|
|
211
|
+
): { lines: string[]; remaining: number } {
|
|
212
|
+
const isTodoEnabled = options.isTodoEnabled ?? (() => true)
|
|
213
|
+
return queue.mutate((entries) => {
|
|
214
|
+
const kept: SuggestionEntry[] = []
|
|
215
|
+
const lines: string[] = []
|
|
216
|
+
entries.forEach((entry, idx) => {
|
|
217
|
+
const number = idx + 1
|
|
218
|
+
if (!indices.includes(number)) {
|
|
219
|
+
kept.push(entry)
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
const isTodo = entry.target.startsWith('todo-')
|
|
223
|
+
const target = isTodo ? entry.target : (targets?.get(number) ?? entry.target)
|
|
224
|
+
const edited = edits?.get(number)?.trim()
|
|
225
|
+
const content = edited ? edited : entry.content
|
|
226
|
+
if (!content?.trim()) {
|
|
227
|
+
lines.push(`✗ #${number} [${target}] empty content, kept`)
|
|
228
|
+
kept.push(entry)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
if (isTodo && !isTodoEnabled()) {
|
|
232
|
+
lines.push(`✗ #${number} [${target}] TODO_DISABLED: Todo feature is not enabled`)
|
|
233
|
+
kept.push(entry)
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
let outcome: any
|
|
237
|
+
if (isTodo) {
|
|
238
|
+
// todoStore may be minimal; try addTodo or add
|
|
239
|
+
if (todoStore?.addTodo) outcome = todoStore.addTodo(target.slice(5), content, {}, entry.cwd ?? agent?.session?.header?.cwd)
|
|
240
|
+
else if (todoStore?.add) outcome = todoStore.add(target.slice(5), { content }, entry.cwd ?? agent?.session?.header?.cwd)
|
|
241
|
+
else outcome = { ok: false, message: 'todo store not available' }
|
|
242
|
+
} else {
|
|
243
|
+
const cwdForStore = (entry.cwd as string) ?? agent?.session?.header?.cwd ?? null
|
|
244
|
+
try {
|
|
245
|
+
if (store?.add) outcome = store.add(target, content, cwdForStore)
|
|
246
|
+
else outcome = { ok: false, message: 'store not available' }
|
|
247
|
+
} catch (e: any) {
|
|
248
|
+
outcome = { ok: false, message: e?.message ?? String(e) }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (outcome?.duplicate === true) {
|
|
252
|
+
lines.push(`#${number} [${target}] duplicate skipped`)
|
|
253
|
+
} else if (outcome?.ok) {
|
|
254
|
+
lines.push(`#${number} [${target}] approved`)
|
|
255
|
+
} else {
|
|
256
|
+
lines.push(`✗ #${number} [${target}] ${outcome?.message ?? outcome?.error ?? 'failed'}`)
|
|
257
|
+
kept.push(entry)
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
entries.length = 0
|
|
261
|
+
entries.push(...kept)
|
|
262
|
+
return { lines, remaining: kept.length }
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function rejectSuggestions(queue: SuggestionQueue, indices: number[]): { removed: number; remaining: number } {
|
|
267
|
+
return queue.mutate((entries) => {
|
|
268
|
+
const kept: SuggestionEntry[] = []
|
|
269
|
+
let removed = 0
|
|
270
|
+
entries.forEach((entry, idx) => {
|
|
271
|
+
if (indices.includes(idx + 1)) removed += 1
|
|
272
|
+
else kept.push(entry)
|
|
273
|
+
})
|
|
274
|
+
entries.length = 0
|
|
275
|
+
entries.push(...kept)
|
|
276
|
+
return { removed, remaining: kept.length }
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function archiveSuggestions(
|
|
281
|
+
archive: any,
|
|
282
|
+
queue: SuggestionQueue,
|
|
283
|
+
indices: number[],
|
|
284
|
+
): { lines: string[]; remaining: number } {
|
|
285
|
+
return queue.mutate((entries) => {
|
|
286
|
+
const kept: SuggestionEntry[] = []
|
|
287
|
+
const lines: string[] = []
|
|
288
|
+
entries.forEach((entry, idx) => {
|
|
289
|
+
const number = idx + 1
|
|
290
|
+
if (!indices.includes(number)) {
|
|
291
|
+
kept.push(entry)
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
const originTag = entry.target.startsWith('todo-') ? `\n(original track: ${entry.target})` : ''
|
|
295
|
+
const stamp = new Date().toISOString().slice(0, 10)
|
|
296
|
+
const stamped = `[${stamp}] ${entry.content}${originTag}${entry.reason ? `\n(archive reason: ${entry.reason})` : ''}`
|
|
297
|
+
// archive may be MaestroMemoryStore or ArchiveStore
|
|
298
|
+
let outcome: any
|
|
299
|
+
try {
|
|
300
|
+
if (archive?.append) {
|
|
301
|
+
// ArchiveStore path: needs cwd for key
|
|
302
|
+
if (entry.target === 'key' || entry.target.startsWith('todo-')) outcome = archive.append(entry.target, stamped, entry.cwd ?? undefined)
|
|
303
|
+
else outcome = archive.append(entry.target, stamped)
|
|
304
|
+
} else if (archive?.archive) {
|
|
305
|
+
outcome = archive.archive(entry.target, entry.content, entry.cwd)
|
|
306
|
+
} else {
|
|
307
|
+
outcome = { ok: false, message: 'archive not available' }
|
|
308
|
+
}
|
|
309
|
+
} catch (e: any) {
|
|
310
|
+
outcome = { ok: false, message: e?.message ?? String(e) }
|
|
311
|
+
}
|
|
312
|
+
// MaestroMemoryStore archive via atomic store uses raw string; treat any truthy ok
|
|
313
|
+
const ok = outcome?.ok === true || outcome === undefined
|
|
314
|
+
if (ok) {
|
|
315
|
+
lines.push(`#${number} [${entry.target}] archived`)
|
|
316
|
+
} else {
|
|
317
|
+
lines.push(`✗ #${number} [${entry.target}] ${outcome?.message ?? outcome?.error ?? 'archive failed'}`)
|
|
318
|
+
kept.push(entry)
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
entries.length = 0
|
|
322
|
+
entries.push(...kept)
|
|
323
|
+
return { lines, remaining: kept.length }
|
|
324
|
+
})
|
|
325
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host/skills-browser.ts — M6 read-first skills browser
|
|
3
|
+
*
|
|
4
|
+
* Goal: expose existing Maestro skills without taking ownership.
|
|
5
|
+
* - Confirm boundary with maestro-skills and list metadata/origin only.
|
|
6
|
+
* - Read-only: no mutation, no write API.
|
|
7
|
+
* - Model suggestions cannot change skills.
|
|
8
|
+
* - Browser cannot alter maestro-skills discovery behavior (no provider registration, no writes).
|
|
9
|
+
*
|
|
10
|
+
* If later approved to edit, mutation must require explicit user action and
|
|
11
|
+
* symlink/path containment tests before any write support. This file prepares
|
|
12
|
+
* the containment helper (isPathContained) and documents the guard, but does
|
|
13
|
+
* not implement mutation in M6.
|
|
14
|
+
*
|
|
15
|
+
* NOTE for future mutation (NOT implemented in M6):
|
|
16
|
+
* Any future mutation endpoint must require an explicit user action
|
|
17
|
+
* (UI click with confirmation) and must verify path containment via
|
|
18
|
+
* isPathContained(targetPath, allowedRoot) including realpath resolution
|
|
19
|
+
* for symlinks, rejecting any traversal or symlink escape. Tests for
|
|
20
|
+
* symlink/path containment must pass before mutation is enabled.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readdirSync, statSync, lstatSync, readFileSync } from 'node:fs'
|
|
24
|
+
import { join, resolve, relative } from 'node:path'
|
|
25
|
+
|
|
26
|
+
export interface SkillBrowseEntry {
|
|
27
|
+
name: string
|
|
28
|
+
description: string
|
|
29
|
+
origin: string
|
|
30
|
+
path: string
|
|
31
|
+
locator: string
|
|
32
|
+
metadata: Record<string, string>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ListSkillsOpts {
|
|
36
|
+
roots?: Array<{ dir: string; origin: string }>
|
|
37
|
+
skillsDir?: string
|
|
38
|
+
origin?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether child is contained inside parent (no traversal escape).
|
|
43
|
+
* Uses resolved absolute paths and checks relative path does not start with "..".
|
|
44
|
+
* This helper prepares symlink/path containment tests required before any
|
|
45
|
+
* future mutation support — explicit user action + containment check.
|
|
46
|
+
*/
|
|
47
|
+
export function isPathContained(child: string, parent: string): boolean {
|
|
48
|
+
const resolvedChild = resolve(child)
|
|
49
|
+
const resolvedParent = resolve(parent)
|
|
50
|
+
if (resolvedChild === resolvedParent) return true
|
|
51
|
+
const rel = relative(resolvedParent, resolvedChild)
|
|
52
|
+
// contained if rel does not start with ".." and is not absolute
|
|
53
|
+
return rel !== '' && !rel.startsWith('..') && !rel.startsWith('/')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseFrontmatter(rawContent: string): { metadata: Record<string, string>; body: string } {
|
|
57
|
+
if (!rawContent.startsWith('---')) return { metadata: {}, body: rawContent }
|
|
58
|
+
const endIdx = rawContent.indexOf('\n---', 3)
|
|
59
|
+
if (endIdx === -1) return { metadata: {}, body: rawContent }
|
|
60
|
+
const yamlLines = rawContent.slice(3, endIdx).split('\n')
|
|
61
|
+
const body = rawContent.slice(endIdx + 4).trim()
|
|
62
|
+
const metadata: Record<string, string> = {}
|
|
63
|
+
let blockKey: string | undefined
|
|
64
|
+
for (const line of yamlLines) {
|
|
65
|
+
const trimmed = line.trim()
|
|
66
|
+
const indent = line.length - trimmed.length
|
|
67
|
+
if (blockKey !== undefined) {
|
|
68
|
+
if (trimmed === '') {
|
|
69
|
+
blockKey = undefined
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (indent > 0) {
|
|
73
|
+
metadata[blockKey] = (metadata[blockKey] + ' ' + trimmed).replace(/\s+/g, ' ').trim()
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
blockKey = undefined
|
|
77
|
+
}
|
|
78
|
+
const colonIdx = trimmed.indexOf(':')
|
|
79
|
+
if (colonIdx <= 0) continue
|
|
80
|
+
const key = trimmed.slice(0, colonIdx).trim()
|
|
81
|
+
const val = trimmed.slice(colonIdx + 1).trim()
|
|
82
|
+
if (val.startsWith('|') || val.startsWith('>')) {
|
|
83
|
+
metadata[key] = ''
|
|
84
|
+
blockKey = key
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
metadata[key] = val.startsWith('"') && val.endsWith('"') ? val.slice(1, -1) : val
|
|
88
|
+
}
|
|
89
|
+
return { metadata, body }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* List skills from a single directory, read-only.
|
|
94
|
+
* - Scans <skillsDir>/* /SKILL.md
|
|
95
|
+
* - Parses frontmatter for name/description
|
|
96
|
+
* - Returns metadata/origin only, does not return body/content
|
|
97
|
+
* - Does not follow symlink escapes for future mutation; for listing we
|
|
98
|
+
* report the logical path, but future mutation will verify containment via
|
|
99
|
+
* isPathContained + realpath.
|
|
100
|
+
*/
|
|
101
|
+
export function listSkillsSync(skillsDir: string, origin = 'custom'): SkillBrowseEntry[] {
|
|
102
|
+
if (!skillsDir) return []
|
|
103
|
+
let entries: string[] = []
|
|
104
|
+
try {
|
|
105
|
+
entries = readdirSync(skillsDir)
|
|
106
|
+
} catch {
|
|
107
|
+
return []
|
|
108
|
+
}
|
|
109
|
+
const out: SkillBrowseEntry[] = []
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
const skillFolder = join(skillsDir, entry)
|
|
112
|
+
let st: any = null
|
|
113
|
+
try {
|
|
114
|
+
st = lstatSync(skillFolder)
|
|
115
|
+
} catch {
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
// allow directory or symlink to directory
|
|
119
|
+
const isDir = st.isDirectory() || st.isSymbolicLink()
|
|
120
|
+
if (!isDir) continue
|
|
121
|
+
// verify it is a directory (follow symlink)
|
|
122
|
+
try {
|
|
123
|
+
const follow = statSync(skillFolder)
|
|
124
|
+
if (!follow.isDirectory()) continue
|
|
125
|
+
} catch {
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
const skillFilePath = join(skillFolder, 'SKILL.md')
|
|
129
|
+
try {
|
|
130
|
+
const fileSt = statSync(skillFilePath)
|
|
131
|
+
if (!fileSt.isFile()) continue
|
|
132
|
+
} catch {
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
let rawContent = ''
|
|
136
|
+
try {
|
|
137
|
+
rawContent = readFileSync(skillFilePath, 'utf8')
|
|
138
|
+
} catch {
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
const { metadata } = parseFrontmatter(rawContent)
|
|
142
|
+
const skillName = metadata.name || entry
|
|
143
|
+
const description = metadata.description || `Skill for ${skillName}`
|
|
144
|
+
out.push({
|
|
145
|
+
name: skillName,
|
|
146
|
+
description,
|
|
147
|
+
origin,
|
|
148
|
+
path: skillFilePath,
|
|
149
|
+
locator: skillFilePath,
|
|
150
|
+
metadata,
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
return out
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Async variant aggregating multiple roots.
|
|
158
|
+
* Each root is { dir, origin }. If only skillsDir is supplied, use that.
|
|
159
|
+
*/
|
|
160
|
+
export async function listSkills(opts: ListSkillsOpts = {}): Promise<SkillBrowseEntry[]> {
|
|
161
|
+
if (opts.roots && opts.roots.length > 0) {
|
|
162
|
+
const all: SkillBrowseEntry[] = []
|
|
163
|
+
for (const r of opts.roots) {
|
|
164
|
+
const entries = listSkillsSync(r.dir, r.origin)
|
|
165
|
+
all.push(...entries)
|
|
166
|
+
}
|
|
167
|
+
return all
|
|
168
|
+
}
|
|
169
|
+
if (opts.skillsDir) {
|
|
170
|
+
return listSkillsSync(opts.skillsDir, opts.origin ?? 'custom')
|
|
171
|
+
}
|
|
172
|
+
// default: no dir => empty (caller should supply maestro-skills dir explicitly)
|
|
173
|
+
return []
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Resolve default maestro-skills directory for browser (read-only).
|
|
178
|
+
* Tries well-known checkout location; returns null if not present.
|
|
179
|
+
* This does NOT alter maestro-skills discovery — it only reads the directory
|
|
180
|
+
* if it exists, for metadata/origin listing.
|
|
181
|
+
*/
|
|
182
|
+
export function resolveDefaultMaestroSkillsDir(): string | null {
|
|
183
|
+
const candidates = [
|
|
184
|
+
'/home/kai/Work/htdocs/maestro-harness/maestro-skills/skills',
|
|
185
|
+
join(process.cwd(), '../maestro-skills/skills'),
|
|
186
|
+
]
|
|
187
|
+
for (const c of candidates) {
|
|
188
|
+
if (existsSync(c)) return c
|
|
189
|
+
}
|
|
190
|
+
return null
|
|
191
|
+
}
|