@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.
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 +73 -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 +42 -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 +64 -10
  43. package/src/host/prompt/snapshot.ts +38 -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,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
- // For key, handle branches and summary before id generation
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
- const mainFile = this.fileFor(t, cwd)
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
- throw new Error(`archive only for memory/user/key`)
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 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,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
- '/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