@ddtcorex/dsh-maestro-memory 1.0.1 → 1.1.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 +17 -0
- package/README.md +39 -397
- package/lib/auto-memory.d.ts +21 -0
- package/lib/auto-memory.d.ts.map +1 -0
- package/lib/auto-memory.js +105 -0
- package/lib/auto-memory.js.map +1 -0
- package/lib/client.js +57 -2
- package/lib/health-score.d.ts +23 -0
- package/lib/health-score.d.ts.map +1 -0
- package/lib/health-score.js +28 -0
- package/lib/health-score.js.map +1 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +123 -0
- package/lib/index.js.map +1 -1
- package/lib/memory/sanitize.d.ts +25 -0
- package/lib/memory/sanitize.d.ts.map +1 -0
- package/lib/memory/sanitize.js +44 -0
- package/lib/memory/sanitize.js.map +1 -0
- package/lib/memory/store.d.ts +4 -1
- package/lib/memory/store.d.ts.map +1 -1
- package/lib/memory/store.js +88 -11
- package/lib/memory/store.js.map +1 -1
- package/lib/prompt/snapshot.d.ts +6 -3
- package/lib/prompt/snapshot.d.ts.map +1 -1
- package/lib/prompt/snapshot.js +43 -5
- package/lib/prompt/snapshot.js.map +1 -1
- package/lib/skills-browser.d.ts.map +1 -1
- package/lib/skills-browser.js +4 -2
- package/lib/skills-browser.js.map +1 -1
- package/lib/storage/layout.d.ts +3 -0
- package/lib/storage/layout.d.ts.map +1 -1
- package/lib/storage/layout.js +11 -0
- package/lib/storage/layout.js.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/client/index.tsx +87 -3
- package/src/host/auto-memory.ts +97 -0
- package/src/host/health-score.ts +47 -0
- package/src/host/index.ts +111 -1
- package/src/host/memory/sanitize.ts +43 -0
- package/src/host/memory/store.ts +80 -10
- package/src/host/prompt/snapshot.ts +39 -5
- package/src/host/skills-browser.ts +5 -3
- package/src/host/storage/layout.ts +10 -0
package/src/host/index.ts
CHANGED
|
@@ -8,24 +8,33 @@ import { buildFeedbackLine } from './memory/feedback.ts'
|
|
|
8
8
|
import { TodoStore, resolveQuadrant, DEFAULT_VIEW_LIMIT } from './todo/store.ts'
|
|
9
9
|
import { TODO_TARGETS, TODO_STATUSES } from './storage/legacy-format.ts'
|
|
10
10
|
import { SuggestionQueue, enqueueSuggestion, approveSuggestions, rejectSuggestions } from './review/queue.ts'
|
|
11
|
-
import { resolveMemoryRoot, suggestionsPath, globalArchivePath, userArchivePath, projectKeyArchivePath, todoArchivePath } from './storage/layout.ts'
|
|
11
|
+
import { resolveMemoryRoot, suggestionsPath, globalArchivePath, userArchivePath, projectKeyArchivePath, projectArchivePath, projectMemoryPath, dailyPath, todoArchivePath } from './storage/layout.ts'
|
|
12
12
|
import { appendEntryAtomicSync } from './storage/atomic-store.ts'
|
|
13
13
|
import * as migration from './migration/service.ts'
|
|
14
14
|
import { SyncService } from './sync/service.ts'
|
|
15
15
|
import { RealGitAdapter } from './sync/git.ts'
|
|
16
16
|
import { listSkillsSync, resolveDefaultMaestroSkillsDir } from './skills-browser.ts'
|
|
17
17
|
import { renderSnapshot } from './prompt/snapshot.ts'
|
|
18
|
+
import { installAutoMemoryHooks, DEFAULT_AUTO_MEMORY, type AutoMemoryOptions } from './auto-memory.ts'
|
|
19
|
+
import { computeFiveDim } from './health-score.ts'
|
|
18
20
|
|
|
19
21
|
export const inject = ['tools', 'systemPrompt', 'connection'] as const
|
|
20
22
|
|
|
21
23
|
export interface MaestroMemoryConfig {
|
|
22
24
|
memoryDir?: string | null
|
|
23
25
|
snapshotOrder?: number
|
|
26
|
+
autoMemory?: Partial<AutoMemoryOptions>
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
export const DEFAULTS: Required<MaestroMemoryConfig> = {
|
|
27
30
|
memoryDir: null,
|
|
28
31
|
snapshotOrder: 500,
|
|
32
|
+
autoMemory: { ...DEFAULT_AUTO_MEMORY },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const READ_ACTIONS = new Set(['list', 'expand'])
|
|
36
|
+
export function isMemoryConcurrencySafe(args: any): boolean {
|
|
37
|
+
return READ_ACTIONS.has(String(args?.action ?? ''))
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
// Extended unions for memory tool (M2-PR-A + M2-PR-B queue)
|
|
@@ -49,6 +58,15 @@ export function apply(ctx: any, config: MaestroMemoryConfig = {}): void {
|
|
|
49
58
|
const queue = new SuggestionQueue(suggestionsPath(root))
|
|
50
59
|
const syncService = new SyncService(config.memoryDir ?? null, new RealGitAdapter())
|
|
51
60
|
|
|
61
|
+
// Auto-memory (opt-in, default disabled) — session/event → store
|
|
62
|
+
ctx.effect(() => {
|
|
63
|
+
const am: AutoMemoryOptions = { ...DEFAULT_AUTO_MEMORY, ...(config.autoMemory ?? {}) }
|
|
64
|
+
const dispose = installAutoMemoryHooks(ctx, store, am)
|
|
65
|
+
return () => {
|
|
66
|
+
if (typeof dispose === 'function') dispose()
|
|
67
|
+
}
|
|
68
|
+
}, 'maestro-memory: auto-memory')
|
|
69
|
+
|
|
52
70
|
ctx.effect(() => {
|
|
53
71
|
const dispose = ctx.systemPrompt.context({
|
|
54
72
|
name: 'memory:snapshot',
|
|
@@ -98,7 +116,9 @@ export function apply(ctx: any, config: MaestroMemoryConfig = {}): void {
|
|
|
98
116
|
date: { type: 'string', description: 'Date YYYY-MM-DD for daily track (add/list/replace/remove)' },
|
|
99
117
|
},
|
|
100
118
|
output: CONTENT_OUTPUT,
|
|
119
|
+
isConcurrencySafe: (args: any) => isMemoryConcurrencySafe(args),
|
|
101
120
|
execute: async (args: any, exec: any) => {
|
|
121
|
+
if (exec?.signal?.aborted) throw new Error('memory aborted')
|
|
102
122
|
const target = args.target as MemoryTarget
|
|
103
123
|
const action = args.action as MemoryAction
|
|
104
124
|
const cwd: string | undefined = args.cwd ?? exec?.agent?.session?.header?.cwd
|
|
@@ -107,6 +127,9 @@ export function apply(ctx: any, config: MaestroMemoryConfig = {}): void {
|
|
|
107
127
|
case 'add': {
|
|
108
128
|
// Batch path: entries[] takes precedence over the single target/content form.
|
|
109
129
|
if (Array.isArray(args.entries)) {
|
|
130
|
+
if (exec?.agent && args.entries.some((e: any) => String(e.target ?? '').trim() === 'key')) {
|
|
131
|
+
return { content: [{ type: 'text', text: 'key is gated — use memory_suggest target=key with reason (batch contains key)' }] }
|
|
132
|
+
}
|
|
110
133
|
if (!args.target && !args.content) {
|
|
111
134
|
// Inject the session cwd as per-entry fallback, mirroring the
|
|
112
135
|
// single-add path — otherwise project/key entries without an
|
|
@@ -121,6 +144,10 @@ export function apply(ctx: any, config: MaestroMemoryConfig = {}): void {
|
|
|
121
144
|
} else if (!target) {
|
|
122
145
|
return { content: [{ type: 'text', text: 'add failed: target is required for single add (or pass entries[])' }] }
|
|
123
146
|
}
|
|
147
|
+
// G1: gate key via agent — direct memory add for key must go through memory_suggest
|
|
148
|
+
if (target === 'key' && exec?.agent) {
|
|
149
|
+
return { content: [{ type: 'text', text: 'key is gated — use memory_suggest target=key with reason (direct memory add for key is CLI-only)' }] }
|
|
150
|
+
}
|
|
124
151
|
let entryText = args.content ?? ''
|
|
125
152
|
if (args.sentiment !== undefined) {
|
|
126
153
|
entryText = `${entryText.trimEnd()} ${buildFeedbackLine({
|
|
@@ -192,6 +219,7 @@ export function apply(ctx: any, config: MaestroMemoryConfig = {}): void {
|
|
|
192
219
|
const tool = defineTool({
|
|
193
220
|
name: 'memory_suggest',
|
|
194
221
|
description: 'Propose memory/todo for confirmation queue (gated, requires user approve). Targets: memory/user/key/todo-*',
|
|
222
|
+
isConcurrencySafe: () => false,
|
|
195
223
|
parameters: {
|
|
196
224
|
target: { type: 'string', required: true, enum: ['memory', 'user', 'key', 'todo-life', 'todo-work', 'todo-project', 'todo-daily'] },
|
|
197
225
|
content: { type: 'string', required: true },
|
|
@@ -199,6 +227,7 @@ export function apply(ctx: any, config: MaestroMemoryConfig = {}): void {
|
|
|
199
227
|
},
|
|
200
228
|
output: CONTENT_OUTPUT,
|
|
201
229
|
execute: async (args: any, exec: any) => {
|
|
230
|
+
if (exec?.signal?.aborted) throw new Error('memory_suggest aborted')
|
|
202
231
|
const target = String(args.target ?? '').trim()
|
|
203
232
|
const content = String(args.content ?? '').trim()
|
|
204
233
|
const reason = String(args.reason ?? '').trim()
|
|
@@ -223,6 +252,7 @@ export function apply(ctx: any, config: MaestroMemoryConfig = {}): void {
|
|
|
223
252
|
ctx.effect(() => {
|
|
224
253
|
const tool = defineTool({
|
|
225
254
|
name: 'dtodo',
|
|
255
|
+
isConcurrencySafe: (args: any) => String(args?.action ?? '') === 'list',
|
|
226
256
|
description: 'Todos: life/work/project/daily with IDs, status/due/quadrant, smart view (overdue/today/project/Q1-Q2, limit 8), historical daily lookup',
|
|
227
257
|
parameters: {
|
|
228
258
|
action: { type: 'string', required: true, enum: ['add', 'list', 'done', 'update', 'remove'] },
|
|
@@ -243,6 +273,7 @@ export function apply(ctx: any, config: MaestroMemoryConfig = {}): void {
|
|
|
243
273
|
},
|
|
244
274
|
output: CONTENT_OUTPUT,
|
|
245
275
|
execute: async (args: any, exec: any) => {
|
|
276
|
+
if (exec?.signal?.aborted) throw new Error('dtodo aborted')
|
|
246
277
|
const action = args.action as string
|
|
247
278
|
const cwd: string | undefined = args.cwd ?? exec?.agent?.session?.header?.cwd
|
|
248
279
|
const dateArg = (v: any) => (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : undefined)
|
|
@@ -588,4 +619,83 @@ export function apply(ctx: any, config: MaestroMemoryConfig = {}): void {
|
|
|
588
619
|
if (typeof dispose === 'function') dispose()
|
|
589
620
|
}
|
|
590
621
|
}, 'maestro-memory: rpc')
|
|
622
|
+
|
|
623
|
+
// Health dashboard handler (Task6) — loopback only, returns coverage + daily counts
|
|
624
|
+
ctx.effect(() => {
|
|
625
|
+
const conn2 = (ctx as any).connection ?? (ctx.get && ctx.get('connection'))
|
|
626
|
+
if (!conn2?.rpc?.handle) return () => {}
|
|
627
|
+
const healthChannel = '/dsh-maestro-memory-health'
|
|
628
|
+
const healthHandler = async (endpoint: string, payload: any) => {
|
|
629
|
+
try {
|
|
630
|
+
const cwdRaw = (payload && typeof payload.cwd === 'string' && payload.cwd.trim()) ? payload.cwd.trim() : ''
|
|
631
|
+
// Health requires explicit cwd; if missing, return empty (client should pass sessionCwd)
|
|
632
|
+
if (!cwdRaw) {
|
|
633
|
+
return { ok: true, value: { project: { total: 0, withSummary: 0, coverage: 100 }, daily: { counts: [0,0,0,0,0,0,0] }, longest: [] } }
|
|
634
|
+
}
|
|
635
|
+
const cwd = cwdRaw
|
|
636
|
+
const projectEntries = store.list('project', cwd)
|
|
637
|
+
const total = projectEntries.length
|
|
638
|
+
const withSummary = projectEntries.filter((e: string) => /\[summary:/.test(e)).length
|
|
639
|
+
const coverage = total ? (withSummary / total) * 100 : 100
|
|
640
|
+
// daily last 7 days
|
|
641
|
+
const dailyCounts: number[] = []
|
|
642
|
+
for (let i = 6; i >= 0; i--) {
|
|
643
|
+
const d = new Date()
|
|
644
|
+
d.setDate(d.getDate() - i)
|
|
645
|
+
const ds = d.toISOString().slice(0, 10)
|
|
646
|
+
try {
|
|
647
|
+
const list = store.list('daily', undefined, { date: ds } as any)
|
|
648
|
+
dailyCounts.push(Array.isArray(list) ? list.length : 0)
|
|
649
|
+
} catch { dailyCounts.push(0) }
|
|
650
|
+
}
|
|
651
|
+
const longest = [...projectEntries].sort((a, b) => b.length - a.length).slice(0, 5).map((e) => ({ len: e.length, preview: e.slice(0, 80).replace(/\n/g, ' ') }))
|
|
652
|
+
const fiveDim = computeFiveDim({
|
|
653
|
+
projectTotal: total,
|
|
654
|
+
withSummary,
|
|
655
|
+
dailyCounts,
|
|
656
|
+
longestLen: longest[0]?.len ?? 0,
|
|
657
|
+
hasAutoRecall: true,
|
|
658
|
+
hasSanitize: true,
|
|
659
|
+
hasGatedQueue: true,
|
|
660
|
+
})
|
|
661
|
+
const health = { project: { total, withSummary, coverage }, daily: { counts: dailyCounts }, longest, fiveDim }
|
|
662
|
+
return { ok: true, value: health }
|
|
663
|
+
} catch (e: any) {
|
|
664
|
+
return { ok: false, error: e?.message ?? String(e) }
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const h = async (ep: string, pl: unknown, _s: AbortSignal) => {
|
|
668
|
+
const res: any = await healthHandler(ep, pl)
|
|
669
|
+
if (res.ok) return { ok: true as const, value: res.value }
|
|
670
|
+
return { ok: false as const, error: { message: res.error } }
|
|
671
|
+
}
|
|
672
|
+
const dispose2 = conn2.rpc.handle(healthChannel, h, { authority: 'loopback' })
|
|
673
|
+
return () => { if (typeof dispose2 === 'function') dispose2() }
|
|
674
|
+
}, 'maestro-memory: health')
|
|
675
|
+
|
|
676
|
+
// Propose handler for Health → queue (Task4) — loopback only
|
|
677
|
+
ctx.effect(() => {
|
|
678
|
+
const conn3 = (ctx as any).connection ?? (ctx.get && ctx.get('connection'))
|
|
679
|
+
if (!conn3?.rpc?.handle) return () => {}
|
|
680
|
+
const proposeHandler = async (endpoint: string, payload: any) => {
|
|
681
|
+
try {
|
|
682
|
+
const content = String(payload?.content ?? '').trim()
|
|
683
|
+
const reason = String(payload?.reason ?? 'promote from Health longest').trim()
|
|
684
|
+
if (!content) return { ok: false, error: 'empty content' }
|
|
685
|
+
if (!reason) return { ok: false, error: 'empty reason' }
|
|
686
|
+
const res = enqueueSuggestion(queue, 'key', content, reason, undefined as any)
|
|
687
|
+
if (!res.ok) return { ok: false, error: (res as any).message ?? 'failed' }
|
|
688
|
+
return { ok: true, value: res }
|
|
689
|
+
} catch (e: any) {
|
|
690
|
+
return { ok: false, error: e?.message ?? String(e) }
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const wrapped = async (ep: string, pl: unknown, _s: AbortSignal) => {
|
|
694
|
+
const r: any = await proposeHandler(ep, pl)
|
|
695
|
+
if (r.ok) return { ok: true as const, value: r.value }
|
|
696
|
+
return { ok: false as const, error: { message: r.error } }
|
|
697
|
+
}
|
|
698
|
+
const d3 = conn3.rpc.handle('/dsh-maestro-memory-propose', wrapped, { authority: 'loopback' })
|
|
699
|
+
return () => { if (typeof d3 === 'function') d3() }
|
|
700
|
+
}, 'maestro-memory: propose')
|
|
591
701
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/sanitize.ts — desensitize sensitive fragments before persistence.
|
|
3
|
+
* Ported from FuRongJun-1999/dsh-memory src/hooks.ts desensitize().
|
|
4
|
+
* English labels, same 7 patterns, same residue→null semantics.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const SENSITIVE_PATTERNS: Array<{ re: RegExp; label: string }> = [
|
|
8
|
+
{ re: /sk-[A-Za-z0-9_-]{8,}/g, label: 'API key' },
|
|
9
|
+
{ re: /\b(?:api[_-]?key|apikey|access[_-]?token)\b\s*[:=]\s*[^\s,,。;;]+/gi, label: 'API key' },
|
|
10
|
+
{ re: /\b(?:password|passwd|pwd)\b\s*[:=]\s*[^\s,,。;;]+/gi, label: 'password' },
|
|
11
|
+
{ re: /Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, label: 'token' },
|
|
12
|
+
// Chinese password: value must be credential-like (non-CJK run) to avoid false positives on "password is important"
|
|
13
|
+
{ re: /密码\s*[::是]\s*[A-Za-z0-9_@#$%^&*!.-]{4,}/g, label: 'password' },
|
|
14
|
+
{ re: /\b\d{17}[\dXx]\b/g, label: 'ID number' },
|
|
15
|
+
{ re: /\b1[3-9]\d{9}\b/g, label: 'phone number' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Replace sensitive fragments with [Filtered:<label>].
|
|
20
|
+
* Returns null when the residue after stripping placeholders is empty
|
|
21
|
+
* (pure-credential message should be skipped, not persisted).
|
|
22
|
+
*/
|
|
23
|
+
export function desensitize(text: string): string | null {
|
|
24
|
+
let out = text
|
|
25
|
+
for (const { re, label } of SENSITIVE_PATTERNS) {
|
|
26
|
+
out = out.replace(re, `[Filtered:${label}]`)
|
|
27
|
+
}
|
|
28
|
+
const residue = out.replace(/\[Filtered:[^\]]+\]/g, '').trim()
|
|
29
|
+
if (!residue) return null
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Convenience wrapper for store integration: sanitize or return original
|
|
35
|
+
* when disabled. Returns { filtered, sanitized } where filtered indicates
|
|
36
|
+
* the input was pure-sensitive and should be rejected.
|
|
37
|
+
*/
|
|
38
|
+
export function sanitizeInput(text: string, enabled: boolean): { filtered: boolean; sanitized: string | null } {
|
|
39
|
+
if (!enabled) return { filtered: false, sanitized: text }
|
|
40
|
+
const r = desensitize(text)
|
|
41
|
+
if (r === null) return { filtered: true, sanitized: null }
|
|
42
|
+
return { filtered: false, sanitized: r }
|
|
43
|
+
}
|
package/src/host/memory/store.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
globalArchivePath,
|
|
33
33
|
userArchivePath,
|
|
34
34
|
projectKeyArchivePath,
|
|
35
|
+
projectArchivePath,
|
|
35
36
|
maestroMetaDir,
|
|
36
37
|
} from '../storage/layout.ts'
|
|
37
38
|
import {
|
|
@@ -41,7 +42,9 @@ import {
|
|
|
41
42
|
autoSummary,
|
|
42
43
|
extractEntryDate,
|
|
43
44
|
BRANCH_TAG_RE,
|
|
45
|
+
SUMMARY_TAG_RE,
|
|
44
46
|
} from '../storage/legacy-format.ts'
|
|
47
|
+
import { desensitize } from './sanitize.ts'
|
|
45
48
|
|
|
46
49
|
export type MemoryTarget = 'memory' | 'global' | 'user' | 'project' | 'key' | 'daily'
|
|
47
50
|
export type MemoryAction = 'add' | 'list' | 'replace' | 'remove' | 'archive' | 'expand'
|
|
@@ -144,7 +147,11 @@ export class MaestroMemoryStore {
|
|
|
144
147
|
if (!cwd) throw new Error('key archive requires cwd')
|
|
145
148
|
return projectKeyArchivePath(r, cwd)
|
|
146
149
|
}
|
|
147
|
-
|
|
150
|
+
if (t === 'project') {
|
|
151
|
+
if (!cwd) throw new Error('project archive requires cwd')
|
|
152
|
+
return projectArchivePath(r, cwd)
|
|
153
|
+
}
|
|
154
|
+
throw new Error(`archive only for memory/user/key/project (got ${target})`)
|
|
148
155
|
}
|
|
149
156
|
|
|
150
157
|
// -------------------------------------------------------------------------
|
|
@@ -232,6 +239,22 @@ export class MaestroMemoryStore {
|
|
|
232
239
|
return `[id:${genId()}] ${entry}`
|
|
233
240
|
}
|
|
234
241
|
|
|
242
|
+
private ensureDatePrefix(entry: string): string {
|
|
243
|
+
const t = String(entry).trim()
|
|
244
|
+
if (/^\[(?:\d{4}-\d{2}-\d{2}|id:\s*[0-9a-f]{8}|branch:)/i.test(t)) return t
|
|
245
|
+
// daily entries often have [HH:MM] — keep if present (but we still want date for non-daily)
|
|
246
|
+
if (/^\[\d{1,2}:\d{2}(?::\d{2})?\]/.test(t)) return t
|
|
247
|
+
return `[${todayStamp()} ${timeStamp()}] ${t}`
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private ensureAutoSummary(entry: string, target: MemoryTarget): string {
|
|
251
|
+
if (target === 'daily') return entry
|
|
252
|
+
if (SUMMARY_TAG_RE.test(entry)) return entry
|
|
253
|
+
const s = autoSummary(entry, 80).replace(/[\n\r\t\]]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120)
|
|
254
|
+
if (!s) return entry
|
|
255
|
+
return `${entry.trimEnd()} [summary:${s}]`
|
|
256
|
+
}
|
|
257
|
+
|
|
235
258
|
// -------------------------------------------------------------------------
|
|
236
259
|
// Public API: add / list / replace / remove / archive / expand / snapshot
|
|
237
260
|
// -------------------------------------------------------------------------
|
|
@@ -335,12 +358,12 @@ export class MaestroMemoryStore {
|
|
|
335
358
|
return out
|
|
336
359
|
}
|
|
337
360
|
|
|
338
|
-
/** Add entry (with optional branches/summary for key) */
|
|
361
|
+
/** Add entry (with optional branches/summary for key) — hardened: auto date + auto summary + desensitize */
|
|
339
362
|
add(
|
|
340
363
|
target: MemoryTarget,
|
|
341
364
|
entry: string,
|
|
342
365
|
cwd?: string,
|
|
343
|
-
opts: { branches?: string; summary?: string; date?: string } = {},
|
|
366
|
+
opts: { branches?: string; summary?: string; date?: string; desensitize?: boolean } = {},
|
|
344
367
|
): { ok: true; duplicate?: boolean; id?: string } | { ok: false; error: string } {
|
|
345
368
|
try {
|
|
346
369
|
this.assertNotBlocked()
|
|
@@ -350,21 +373,58 @@ export class MaestroMemoryStore {
|
|
|
350
373
|
const t = normalizeTarget(target)
|
|
351
374
|
let content = String(entry ?? '').trim()
|
|
352
375
|
if (!content) return { ok: false, error: 'empty content' }
|
|
353
|
-
//
|
|
376
|
+
// Guard against idle-loop spam: reject trivial placeholder entries that carry
|
|
377
|
+
// no new information (e.g. "Idle", "Idle — no change"). The snapshot
|
|
378
|
+
// discipline now says to skip when idle, but keep a code-level guard as
|
|
379
|
+
// defense-in-depth so a misbehaving model cannot flood daily/project.
|
|
380
|
+
const trivialStripped = content.replace(/^\[.*?\]\s*/g, '').trim().toLowerCase()
|
|
381
|
+
if (
|
|
382
|
+
trivialStripped.startsWith('idle') ||
|
|
383
|
+
trivialStripped.startsWith('no change') ||
|
|
384
|
+
trivialStripped.startsWith('awaiting') ||
|
|
385
|
+
trivialStripped.startsWith('waiting')
|
|
386
|
+
) {
|
|
387
|
+
// Idle/placeholder entries carry no new information; treat as duplicate/no-op
|
|
388
|
+
// rather than error so the caller can proceed without retrying. Cap at 150
|
|
389
|
+
// chars so legitimate long entries that merely mention "idle" are not blocked.
|
|
390
|
+
if (trivialStripped.length < 150) return { ok: true, duplicate: true }
|
|
391
|
+
}
|
|
392
|
+
// Desensitize by default (opt-out via {desensitize:false} for tests/internal)
|
|
393
|
+
const doDesensitize = opts.desensitize !== false
|
|
394
|
+
if (doDesensitize) {
|
|
395
|
+
const s = desensitize(content)
|
|
396
|
+
if (s === null) return { ok: false, error: 'content filtered (sensitive-only)' }
|
|
397
|
+
content = s
|
|
398
|
+
}
|
|
399
|
+
// Auto date prefix for all tracks (local calendar, preserves existing [id:/date/branch/time)
|
|
400
|
+
content = this.ensureDatePrefix(content)
|
|
401
|
+
// For key, handle branches and summary before id generation, then auto summary if still missing
|
|
354
402
|
if (t === 'key') {
|
|
355
403
|
if (opts.branches) content = this.applyBranchTag(content, opts.branches)
|
|
356
404
|
if (opts.summary) {
|
|
357
405
|
content = this.applySummaryTag(content, opts.summary)
|
|
358
406
|
}
|
|
359
407
|
content = this.ensureId(content)
|
|
408
|
+
content = this.ensureAutoSummary(content, t)
|
|
409
|
+
} else {
|
|
410
|
+
content = this.ensureAutoSummary(content, t)
|
|
360
411
|
}
|
|
361
|
-
// For daily/project, strip hand-written date prefix? Keep simple: no stamping, store verbatim
|
|
362
412
|
let file: string
|
|
363
413
|
try {
|
|
364
414
|
file = this.fileFor(t, cwd, opts.date)
|
|
365
415
|
} catch (e: any) {
|
|
366
416
|
return { ok: false, error: e?.message ?? String(e) }
|
|
367
417
|
}
|
|
418
|
+
// Dedupe with stripped id+summary so summary difference doesn't create duplicate
|
|
419
|
+
try {
|
|
420
|
+
const existing = readEntriesSync(file)
|
|
421
|
+
const stripForDedupe = (s: string) => s.replace(/\[summary:[^\]]*\]\s*/g, '').replace(/^\[id:\s*[0-9a-f]{8}\]\s*/i, '').trim()
|
|
422
|
+
const probe = stripForDedupe(content)
|
|
423
|
+
const isDup = existing.some((e) => stripForDedupe(e) === probe)
|
|
424
|
+
if (isDup) return { ok: true, duplicate: true }
|
|
425
|
+
} catch {
|
|
426
|
+
// read failure → treat as no duplicate, let append handle it
|
|
427
|
+
}
|
|
368
428
|
const res = appendEntryAtomicSync(file, content)
|
|
369
429
|
if (!res.ok) return { ok: false, error: res.error }
|
|
370
430
|
if (res.duplicate) return { ok: true, duplicate: true }
|
|
@@ -409,13 +469,14 @@ export class MaestroMemoryStore {
|
|
|
409
469
|
let replacement = newText
|
|
410
470
|
const oldEntry = matches[0]
|
|
411
471
|
const oldId = /^\[id:\s*([0-9a-f]{8})\]\s*/i.exec(oldEntry)?.[1]
|
|
412
|
-
// If replacement already has summary handling? Not for replace; keep simple
|
|
413
472
|
if (oldId) {
|
|
414
473
|
// If replacement doesn't already have id, prepend it
|
|
415
474
|
if (!/^\[id:\s*[0-9a-f]{8}\]\s*/i.test(replacement)) {
|
|
416
475
|
replacement = `[id:${oldId.toLowerCase()}] ${replacement}`
|
|
417
476
|
}
|
|
418
477
|
}
|
|
478
|
+
replacement = this.ensureDatePrefix(replacement)
|
|
479
|
+
replacement = this.ensureAutoSummary(replacement, t)
|
|
419
480
|
const idx = entries.indexOf(oldEntry)
|
|
420
481
|
const next = [...entries]
|
|
421
482
|
next[idx] = replacement
|
|
@@ -473,12 +534,17 @@ export class MaestroMemoryStore {
|
|
|
473
534
|
return { ok: false, error: e?.message ?? String(e) }
|
|
474
535
|
}
|
|
475
536
|
const t = normalizeTarget(target)
|
|
476
|
-
if (t !== 'memory' && t !== 'user' && t !== 'key') {
|
|
477
|
-
return { ok: false, error: 'archive only for memory/user/key' }
|
|
537
|
+
if (t !== 'memory' && t !== 'user' && t !== 'key' && t !== 'project') {
|
|
538
|
+
return { ok: false, error: 'archive only for memory/user/key/project' }
|
|
478
539
|
}
|
|
479
540
|
const m = String(match ?? '').trim()
|
|
480
541
|
if (!m) return { ok: false, error: 'empty match' }
|
|
481
|
-
|
|
542
|
+
let mainFile: string
|
|
543
|
+
try {
|
|
544
|
+
mainFile = this.fileFor(t, cwd)
|
|
545
|
+
} catch (e: any) {
|
|
546
|
+
return { ok: false, error: e?.message ?? String(e) }
|
|
547
|
+
}
|
|
482
548
|
return withLockSync(dirname(mainFile), () => {
|
|
483
549
|
const entries = readEntriesSync(mainFile)
|
|
484
550
|
const matches = entries.filter((e) => e.includes(m))
|
|
@@ -620,7 +686,11 @@ export class MaestroArchiveStore {
|
|
|
620
686
|
if (!cwd) throw new Error('key archive requires cwd')
|
|
621
687
|
return projectKeyArchivePath(r, cwd)
|
|
622
688
|
}
|
|
623
|
-
|
|
689
|
+
if (t === 'project') {
|
|
690
|
+
if (!cwd) throw new Error('project archive requires cwd')
|
|
691
|
+
return projectArchivePath(r, cwd)
|
|
692
|
+
}
|
|
693
|
+
throw new Error(`archive only for memory/user/key/project`)
|
|
624
694
|
}
|
|
625
695
|
|
|
626
696
|
entries(target: MemoryTarget, cwd?: string): string[] {
|
|
@@ -10,7 +10,7 @@ export interface SnapshotContext {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/** Default per-section byte budgets for the snapshot prompt. */
|
|
13
|
-
export const SNAPSHOT_SECTION_CAPS = { memory: 2048, user: 4096, key: 6144 } as const
|
|
13
|
+
export const SNAPSHOT_SECTION_CAPS = { memory: 2048, user: 4096, key: 6144, recentDaily: 512, autoRecall: 1024 } as const
|
|
14
14
|
|
|
15
15
|
export type SnapshotSectionKey = keyof typeof SNAPSHOT_SECTION_CAPS
|
|
16
16
|
|
|
@@ -50,10 +50,11 @@ function fitSection(entries: string[], cap: number): string[] {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
|
-
* Bounded snapshot renderer — contract
|
|
53
|
+
* Bounded snapshot renderer — contract from README § System Prompt Snapshot:
|
|
54
54
|
* Header (sessionId/sessionName) + USER + global MEMORY + current-project KEY
|
|
55
|
-
* (branch-filtered) +
|
|
56
|
-
*
|
|
55
|
+
* (branch-filtered) + Project Context (auto-recall top-4, 600 chars each)
|
|
56
|
+
* + Recent Daily + end-of-turn discipline note.
|
|
57
|
+
* Full daily/project logs are query-only; only the bounded recall slices are injected.
|
|
57
58
|
*/
|
|
58
59
|
export function renderSnapshot(
|
|
59
60
|
store: MaestroMemoryStore,
|
|
@@ -83,9 +84,42 @@ export function renderSnapshot(
|
|
|
83
84
|
if (user.length) parts.push(`# User Memory\n${user.join('\n---\n')}`)
|
|
84
85
|
if (key.length) parts.push(`# Project Key Memory\n${key.join('\n---\n')}`)
|
|
85
86
|
|
|
87
|
+
// Auto-recall: newest 4 project entries for current cwd, each truncated to 600 chars
|
|
88
|
+
// Mirrors dsh-memory timeline(limit:4, 600 chars) but file-native, no Python.
|
|
89
|
+
if (ctx.cwd) {
|
|
90
|
+
try {
|
|
91
|
+
const proj = store.list('project', ctx.cwd)
|
|
92
|
+
if (proj.length) {
|
|
93
|
+
const newest4 = proj.slice(-4).map((e) => e.slice(0, 600))
|
|
94
|
+
const fitted = fitSection(newest4, (caps as any).autoRecall ?? 1024)
|
|
95
|
+
if (fitted.length) parts.push(`# Project Context\n${fitted.join('\n---\n')}`)
|
|
96
|
+
}
|
|
97
|
+
} catch {}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Recent daily slot (512B) — last 2 days' newest entries
|
|
101
|
+
// Keeps recent context without exceeding cap; full logs remain query-only.
|
|
102
|
+
try {
|
|
103
|
+
const recentDaily: string[] = []
|
|
104
|
+
for (let i = 0; i < 2; i++) {
|
|
105
|
+
const d = new Date()
|
|
106
|
+
d.setDate(d.getDate() - i)
|
|
107
|
+
const ds = d.toISOString().slice(0, 10)
|
|
108
|
+
try {
|
|
109
|
+
const list: string[] = store.list('daily', undefined, { date: ds } as any)
|
|
110
|
+
if (list.length) recentDaily.push(list[list.length - 1])
|
|
111
|
+
} catch {}
|
|
112
|
+
}
|
|
113
|
+
if (recentDaily.length) {
|
|
114
|
+
const fitted = fitSection(recentDaily, caps.recentDaily)
|
|
115
|
+
if (fitted.length) parts.push(`# Recent Daily\n${fitted.join('\n---\n')}`)
|
|
116
|
+
}
|
|
117
|
+
} catch {}
|
|
118
|
+
|
|
86
119
|
// End-of-turn discipline note — verbatim contract (hardened: exactly once, always last)
|
|
120
|
+
// Conditional to avoid idle loops: only when turn produced meaningful progress.
|
|
87
121
|
const discipline =
|
|
88
|
-
`---\nEnd of every turn
|
|
122
|
+
`---\nEnd of every turn — if this turn produced meaningful progress (code, decisions, learnings, or next steps): 1. Write daily+project via memory entries (daily+project in one call, skip if idle/waiting or no new information — never write entries containing only 'Idle' or placeholders) 2. Check dtodo list only if relevant (bounded, max 8)\nFor important project decisions (convention, incident, infra) use memory_suggest target=key with reason, not memory add.`
|
|
89
123
|
// Defensive: strip any pre-existing discipline entry (should never occur — parts is fresh per call)
|
|
90
124
|
// then append exactly once so the note is guaranteed last even for empty stores or repeated calls.
|
|
91
125
|
const deduped = parts.filter((p) => p !== discipline)
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { existsSync, readdirSync, statSync, lstatSync, readFileSync } from 'node:fs'
|
|
24
|
+
import { homedir } from 'node:os'
|
|
24
25
|
import { join, resolve, relative } from 'node:path'
|
|
25
26
|
|
|
26
27
|
export interface SkillBrowseEntry {
|
|
@@ -180,10 +181,11 @@ export async function listSkills(opts: ListSkillsOpts = {}): Promise<SkillBrowse
|
|
|
180
181
|
* if it exists, for metadata/origin listing.
|
|
181
182
|
*/
|
|
182
183
|
export function resolveDefaultMaestroSkillsDir(): string | null {
|
|
183
|
-
const candidates = [
|
|
184
|
-
'
|
|
184
|
+
const candidates: string[] = [
|
|
185
|
+
process.env.MAESTRO_HARNESS_ROOT ? join(process.env.MAESTRO_HARNESS_ROOT, 'maestro-skills/skills') : null,
|
|
185
186
|
join(process.cwd(), '../maestro-skills/skills'),
|
|
186
|
-
|
|
187
|
+
join(homedir(), 'Work/htdocs/maestro-harness/maestro-skills/skills'),
|
|
188
|
+
].filter((v): v is string => Boolean(v))
|
|
187
189
|
for (const c of candidates) {
|
|
188
190
|
if (existsSync(c)) return c
|
|
189
191
|
}
|
|
@@ -67,6 +67,14 @@ export function projectKeyPath(root: string, cwd: string): string {
|
|
|
67
67
|
export function projectKeyArchivePath(root: string, cwd: string): string {
|
|
68
68
|
return join(projectDir(root, cwd), 'KEY-archive.md')
|
|
69
69
|
}
|
|
70
|
+
export function projectArchivePath(root: string, cwd: string): string {
|
|
71
|
+
if (!cwd) throw new Error('projectArchivePath: cwd is required')
|
|
72
|
+
return join(projectDir(root, cwd), 'MEMORY-archive.md')
|
|
73
|
+
}
|
|
74
|
+
export function projectArchiveDir(root: string, cwd: string): string {
|
|
75
|
+
if (!cwd) throw new Error('projectArchiveDir: cwd is required')
|
|
76
|
+
return projectDir(root, cwd)
|
|
77
|
+
}
|
|
70
78
|
export function projectTodoPath(root: string, cwd: string): string {
|
|
71
79
|
return join(projectDir(root, cwd), 'TODOS.md')
|
|
72
80
|
}
|
|
@@ -148,12 +156,14 @@ export function allArchivePaths(root: string, cwd: string): {
|
|
|
148
156
|
user: string
|
|
149
157
|
key: string
|
|
150
158
|
todo: string
|
|
159
|
+
projectMemoryArchive: string
|
|
151
160
|
} {
|
|
152
161
|
return {
|
|
153
162
|
memory: globalArchivePath(root),
|
|
154
163
|
user: userArchivePath(root),
|
|
155
164
|
key: projectKeyArchivePath(root, cwd),
|
|
156
165
|
todo: todoArchivePath(root),
|
|
166
|
+
projectMemoryArchive: projectArchivePath(root, cwd),
|
|
157
167
|
}
|
|
158
168
|
}
|
|
159
169
|
|