@ddtcorex/dsh-maestro-memory 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +73 -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 +42 -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 +64 -10
- package/src/host/prompt/snapshot.ts +38 -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,42 @@ 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
|
+
// Desensitize by default (opt-out via {desensitize:false} for tests/internal)
|
|
377
|
+
const doDesensitize = opts.desensitize !== false
|
|
378
|
+
if (doDesensitize) {
|
|
379
|
+
const s = desensitize(content)
|
|
380
|
+
if (s === null) return { ok: false, error: 'content filtered (sensitive-only)' }
|
|
381
|
+
content = s
|
|
382
|
+
}
|
|
383
|
+
// Auto date prefix for all tracks (local calendar, preserves existing [id:/date/branch/time)
|
|
384
|
+
content = this.ensureDatePrefix(content)
|
|
385
|
+
// For key, handle branches and summary before id generation, then auto summary if still missing
|
|
354
386
|
if (t === 'key') {
|
|
355
387
|
if (opts.branches) content = this.applyBranchTag(content, opts.branches)
|
|
356
388
|
if (opts.summary) {
|
|
357
389
|
content = this.applySummaryTag(content, opts.summary)
|
|
358
390
|
}
|
|
359
391
|
content = this.ensureId(content)
|
|
392
|
+
content = this.ensureAutoSummary(content, t)
|
|
393
|
+
} else {
|
|
394
|
+
content = this.ensureAutoSummary(content, t)
|
|
360
395
|
}
|
|
361
|
-
// For daily/project, strip hand-written date prefix? Keep simple: no stamping, store verbatim
|
|
362
396
|
let file: string
|
|
363
397
|
try {
|
|
364
398
|
file = this.fileFor(t, cwd, opts.date)
|
|
365
399
|
} catch (e: any) {
|
|
366
400
|
return { ok: false, error: e?.message ?? String(e) }
|
|
367
401
|
}
|
|
402
|
+
// Dedupe with stripped id+summary so summary difference doesn't create duplicate
|
|
403
|
+
try {
|
|
404
|
+
const existing = readEntriesSync(file)
|
|
405
|
+
const stripForDedupe = (s: string) => s.replace(/\[summary:[^\]]*\]\s*/g, '').replace(/^\[id:\s*[0-9a-f]{8}\]\s*/i, '').trim()
|
|
406
|
+
const probe = stripForDedupe(content)
|
|
407
|
+
const isDup = existing.some((e) => stripForDedupe(e) === probe)
|
|
408
|
+
if (isDup) return { ok: true, duplicate: true }
|
|
409
|
+
} catch {
|
|
410
|
+
// read failure → treat as no duplicate, let append handle it
|
|
411
|
+
}
|
|
368
412
|
const res = appendEntryAtomicSync(file, content)
|
|
369
413
|
if (!res.ok) return { ok: false, error: res.error }
|
|
370
414
|
if (res.duplicate) return { ok: true, duplicate: true }
|
|
@@ -409,13 +453,14 @@ export class MaestroMemoryStore {
|
|
|
409
453
|
let replacement = newText
|
|
410
454
|
const oldEntry = matches[0]
|
|
411
455
|
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
456
|
if (oldId) {
|
|
414
457
|
// If replacement doesn't already have id, prepend it
|
|
415
458
|
if (!/^\[id:\s*[0-9a-f]{8}\]\s*/i.test(replacement)) {
|
|
416
459
|
replacement = `[id:${oldId.toLowerCase()}] ${replacement}`
|
|
417
460
|
}
|
|
418
461
|
}
|
|
462
|
+
replacement = this.ensureDatePrefix(replacement)
|
|
463
|
+
replacement = this.ensureAutoSummary(replacement, t)
|
|
419
464
|
const idx = entries.indexOf(oldEntry)
|
|
420
465
|
const next = [...entries]
|
|
421
466
|
next[idx] = replacement
|
|
@@ -473,12 +518,17 @@ export class MaestroMemoryStore {
|
|
|
473
518
|
return { ok: false, error: e?.message ?? String(e) }
|
|
474
519
|
}
|
|
475
520
|
const t = normalizeTarget(target)
|
|
476
|
-
if (t !== 'memory' && t !== 'user' && t !== 'key') {
|
|
477
|
-
return { ok: false, error: 'archive only for memory/user/key' }
|
|
521
|
+
if (t !== 'memory' && t !== 'user' && t !== 'key' && t !== 'project') {
|
|
522
|
+
return { ok: false, error: 'archive only for memory/user/key/project' }
|
|
478
523
|
}
|
|
479
524
|
const m = String(match ?? '').trim()
|
|
480
525
|
if (!m) return { ok: false, error: 'empty match' }
|
|
481
|
-
|
|
526
|
+
let mainFile: string
|
|
527
|
+
try {
|
|
528
|
+
mainFile = this.fileFor(t, cwd)
|
|
529
|
+
} catch (e: any) {
|
|
530
|
+
return { ok: false, error: e?.message ?? String(e) }
|
|
531
|
+
}
|
|
482
532
|
return withLockSync(dirname(mainFile), () => {
|
|
483
533
|
const entries = readEntriesSync(mainFile)
|
|
484
534
|
const matches = entries.filter((e) => e.includes(m))
|
|
@@ -620,7 +670,11 @@ export class MaestroArchiveStore {
|
|
|
620
670
|
if (!cwd) throw new Error('key archive requires cwd')
|
|
621
671
|
return projectKeyArchivePath(r, cwd)
|
|
622
672
|
}
|
|
623
|
-
|
|
673
|
+
if (t === 'project') {
|
|
674
|
+
if (!cwd) throw new Error('project archive requires cwd')
|
|
675
|
+
return projectArchivePath(r, cwd)
|
|
676
|
+
}
|
|
677
|
+
throw new Error(`archive only for memory/user/key/project`)
|
|
624
678
|
}
|
|
625
679
|
|
|
626
680
|
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,41 @@ 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)
|
|
87
120
|
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)
|
|
121
|
+
`---\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)\nFor important project decisions (convention, incident, infra) use memory_suggest target=key with reason, not memory add.`
|
|
89
122
|
// Defensive: strip any pre-existing discipline entry (should never occur — parts is fresh per call)
|
|
90
123
|
// then append exactly once so the note is guaranteed last even for empty stores or repeated calls.
|
|
91
124
|
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
|
|