@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +39 -397
  3. package/lib/auto-memory.d.ts +21 -0
  4. package/lib/auto-memory.d.ts.map +1 -0
  5. package/lib/auto-memory.js +105 -0
  6. package/lib/auto-memory.js.map +1 -0
  7. package/lib/client.js +57 -2
  8. package/lib/health-score.d.ts +23 -0
  9. package/lib/health-score.d.ts.map +1 -0
  10. package/lib/health-score.js +28 -0
  11. package/lib/health-score.js.map +1 -0
  12. package/lib/index.d.ts +4 -0
  13. package/lib/index.d.ts.map +1 -1
  14. package/lib/index.js +123 -0
  15. package/lib/index.js.map +1 -1
  16. package/lib/memory/sanitize.d.ts +25 -0
  17. package/lib/memory/sanitize.d.ts.map +1 -0
  18. package/lib/memory/sanitize.js +44 -0
  19. package/lib/memory/sanitize.js.map +1 -0
  20. package/lib/memory/store.d.ts +4 -1
  21. package/lib/memory/store.d.ts.map +1 -1
  22. package/lib/memory/store.js +88 -11
  23. package/lib/memory/store.js.map +1 -1
  24. package/lib/prompt/snapshot.d.ts +6 -3
  25. package/lib/prompt/snapshot.d.ts.map +1 -1
  26. package/lib/prompt/snapshot.js +43 -5
  27. package/lib/prompt/snapshot.js.map +1 -1
  28. package/lib/skills-browser.d.ts.map +1 -1
  29. package/lib/skills-browser.js +4 -2
  30. package/lib/skills-browser.js.map +1 -1
  31. package/lib/storage/layout.d.ts +3 -0
  32. package/lib/storage/layout.d.ts.map +1 -1
  33. package/lib/storage/layout.js +11 -0
  34. package/lib/storage/layout.js.map +1 -1
  35. package/lib/types/client/index.d.ts.map +1 -1
  36. package/package.json +2 -1
  37. package/src/client/index.tsx +87 -3
  38. package/src/host/auto-memory.ts +97 -0
  39. package/src/host/health-score.ts +47 -0
  40. package/src/host/index.ts +111 -1
  41. package/src/host/memory/sanitize.ts +43 -0
  42. package/src/host/memory/store.ts +80 -10
  43. package/src/host/prompt/snapshot.ts +39 -5
  44. package/src/host/skills-browser.ts +5 -3
  45. 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
+ }
@@ -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
- throw new Error(`archive only for memory/user/key (got ${target})`)
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
- // For key, handle branches and summary before id generation
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
- const mainFile = this.fileFor(t, cwd)
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
- throw new Error(`archive only for memory/user/key`)
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 từ README § System Prompt Snapshot:
53
+ * Bounded snapshot renderer — contract from README § System Prompt Snapshot:
54
54
  * Header (sessionId/sessionName) + USER + global MEMORY + current-project KEY
55
- * (branch-filtered) + end-of-turn discipline note.
56
- * daily project KHÔNG inject.
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 you must: 1. Write daily+project via memory entries (daily+project in one call) 2. Check dtodo list (bounded, max 8)`
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
- '/home/kai/Work/htdocs/maestro-harness/maestro-skills/skills',
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