mixdog 0.9.46 → 0.9.47

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.46",
3
+ "version": "0.9.47",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -23,7 +23,9 @@ CORE is durable standing knowledge that lands in one of three layers:
23
23
  - **L3 — Current map:** one-line project-landscape summaries, live long-running
24
24
  goals, environment anchors documented nowhere else.
25
25
 
26
- Every entry should be ONE short clause (≤120 chars). CORE is not a log.
26
+ Every entry must be ONE compact ENGLISH clause (≤120 chars), regardless of
27
+ source language. Keep identifiers, paths, and exact phrases coined by the user
28
+ verbatim. CORE is not a log.
27
29
 
28
30
  ## The distinction that decides every verdict
29
31
 
@@ -48,8 +50,10 @@ or inconclusive, keep the CORE entry.
48
50
 
49
51
  ## Verdicts
50
52
 
51
- - `keep` — durable and already one short clause.
52
- - `update` — durable but verbose or multi-sentence → rewrite as one ≤120-char clause.
53
+ - `keep` — durable and already one compact ENGLISH clause.
54
+ - `update` — durable but verbose, multi-sentence, or non-English → rewrite as
55
+ one compact ≤120-char ENGLISH clause; keep identifiers, paths, and exact
56
+ phrases coined by the user verbatim.
53
57
  - `merge` — duplicates another entry → fold into the survivor (same project pool).
54
58
  - `reclassify` — the entry is filed under the WRONG project pool. Its subject
55
59
  clearly belongs to a different pool shown among the entries under review: a
@@ -81,7 +85,9 @@ or inconclusive, keep the CORE entry.
81
85
  `<id>|delete` with no reason, or any reason outside that set, is treated as
82
86
  "needs confirmation" and only removed on an explicit APPLY CYCLE3 run.
83
87
 
84
- A verbose durable entry is always `update`, never `keep`.
88
+ A verbose, multi-sentence, or non-English durable entry is always `update`,
89
+ never `keep`; rewrite it as a ≤120-char English clause while keeping
90
+ identifiers, paths, and exact user-coined phrases verbatim.
85
91
  Delete is the rarest verdict. Prefer `keep` for durable rules/preferences and
86
92
  `update` for compression when the current behavior is still valid.
87
93
  Use `superseded` (not `delete`) when the entry WAS a valid durable rule/fact but
@@ -47,9 +47,10 @@ Use `why|A` for durable cross-session invariants (L1/L2). Use `why|B` for L3
47
47
  current-map entries: one-line project-landscape summaries, live long-running
48
48
  goals, and environment anchors documented nowhere else. `why` is validation
49
49
  evidence only; it is
50
- not stored. `core_summary` is injected into Core Memory, so keep it one
51
- self-contained clause, <=120 chars. For `merge`, `why` and `core` may use the
52
- survivor `target_row`.
50
+ not stored. `core_summary` is injected into Core Memory, so write it in ENGLISH
51
+ regardless of source language as one compact, self-contained clause, <=120
52
+ chars. Keep code identifiers, paths, and commands verbatim. For `merge`, `why`
53
+ and `core` may use the survivor `target_row`.
53
54
 
54
55
  Allowed primary verbs:
55
56
 
@@ -11,11 +11,10 @@ import { __mixdogMemoryLog, throwIfAborted, resourceDir } from './memory-cycle2-
11
11
 
12
12
  export const CYCLE2_ACTIVE_TARGET_CAP = 100
13
13
 
14
- // Minimum number of active (promoted) rows cycle2 must preserve. When the
15
- // active pool is at or below this floor, active recheck/demotion is skipped
16
- // and no archiving path may push active below it — prevents draining the
17
- // pool to zero. Configurable via config.active_floor.
18
- export const CYCLE2_ACTIVE_MIN_FLOOR = 20
14
+ // Default active-row floor for cycle2. It is zero so rolling active rechecks
15
+ // always run; protection from archive verdicts is opt-in via
16
+ // config.active_floor.
17
+ export const CYCLE2_ACTIVE_MIN_FLOOR = 0
19
18
 
20
19
  // Status-based verb whitelist. 3-tier policy: pending → active/archived,
21
20
  // active → active/archived/update/merge.
@@ -224,6 +223,15 @@ export function loadCurrentRulesDigest() {
224
223
  }
225
224
  } catch {}
226
225
  }
226
+ const workflows = join(resourceDir(), 'workflows')
227
+ try {
228
+ if (existsSync(workflows)) {
229
+ for (const dir of readdirSync(workflows).sort()) {
230
+ const workflow = join(workflows, dir, 'WORKFLOW.md')
231
+ if (existsSync(workflow)) sources.push(workflow)
232
+ }
233
+ }
234
+ } catch {}
227
235
  const parts = []
228
236
  for (const p of sources) {
229
237
  try {
@@ -259,16 +259,19 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
259
259
  // over-cap path was historically the ONLY one that re-examined active.
260
260
  // Reserve a bounded slice of batch slots for the stalest active rows so
261
261
  // rule-duplicate / drifted promotions are archived continuously instead of
262
- // sitting forever un-rechecked. Bounded count + reviewed_at rotation
263
- // prevents eroding the set to zero (the original over-cap-only concern):
262
+ // sitting forever un-rechecked. Bounded count + reviewed_at rotation keep
263
+ // each cycle's exposure small (with the default floor of 0 the pool CAN
264
+ // erode to zero over repeated cycles if the gate keeps archiving; set
265
+ // config.active_floor to retain a hard minimum):
264
266
  // only the oldest few are re-judged per cycle, and the gate — shown
265
267
  // {{CURRENT_RULES}} — keeps genuine A/B entries and archives only
266
268
  // restatements. Embedding dedup is skipped on purpose: rule restatements
267
269
  // are often cross-language paraphrases whose cosine never clears the merge
268
270
  // threshold, but the LLM gate catches the semantic overlap.
269
- // Floor guard: when the active pool is at/below the minimum floor, skip the
270
- // rolling active recheck entirely so demotions cannot drain it further.
271
- const activeRecheckQuota = (reviewActiveRows || activeCount <= activeFloor)
271
+ // Rolling active recheck always runs outside the over-cap broad review,
272
+ // including when the active pool is small. Floor protection is opt-in via
273
+ // config.active_floor and is enforced by the shared floor guard below.
274
+ const activeRecheckQuota = reviewActiveRows
272
275
  ? 0
273
276
  : Math.max(0, Math.min(Number(config.active_recheck_quota ?? 8), batchSize - 1))
274
277
  const pendingLimit = batchSize - activeRecheckQuota
@@ -112,7 +112,30 @@ function coreText(core) {
112
112
  return `${core?.element || ''}\n${core?.summary || ''}`
113
113
  }
114
114
 
115
- function isSafeConservativeUpdate(current, action) {
115
+ function hasSubstantialNonLatinScript(value) {
116
+ const text = String(value ?? '')
117
+ const letters = text.match(/\p{L}/gu) || []
118
+ const latinLetters = letters.filter((letter) => /\p{Script=Latin}/u.test(letter))
119
+ const nonLatinLetters = letters.length - latinLetters.length
120
+ return nonLatinLetters >= 3 && nonLatinLetters >= letters.length * 0.3
121
+ }
122
+
123
+ function cosineSimilarity(a, b) {
124
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) return null
125
+ let dot = 0
126
+ let aNorm = 0
127
+ let bNorm = 0
128
+ for (let i = 0; i < a.length; i++) {
129
+ if (!Number.isFinite(a[i]) || !Number.isFinite(b[i])) return null
130
+ dot += a[i] * b[i]
131
+ aNorm += a[i] ** 2
132
+ bNorm += b[i] ** 2
133
+ }
134
+ if (aNorm === 0 || bNorm === 0) return null
135
+ return dot / Math.sqrt(aNorm * bNorm)
136
+ }
137
+
138
+ async function isSafeConservativeUpdate(current, action) {
116
139
  if (!current || !action?.element || !action?.summary) return { ok: false, reason: 'missing text' }
117
140
  const newElement = normalizeComparable(action.element)
118
141
  const newSummary = normalizeComparable(action.summary)
@@ -123,11 +146,23 @@ function isSafeConservativeUpdate(current, action) {
123
146
  const newText = `${action.element}\n${action.summary}`
124
147
  const oldLen = normalizeComparable(oldText).length
125
148
  const newLen = normalizeComparable(newText).length
126
- if (oldLen > 0 && newLen > oldLen + 20) return { ok: false, reason: 'rewrite expands entry' }
127
-
128
149
  const sim = charDice(oldText, newText)
129
- if (sim < 0.28) return { ok: false, reason: `rewrite drift sim=${sim.toFixed(2)}` }
130
- return { ok: true, reason: 'safe compression' }
150
+ const crossLanguageRewrite = sim < 0.28 && hasSubstantialNonLatinScript(oldText)
151
+ if (!crossLanguageRewrite) {
152
+ if (oldLen > 0 && newLen > oldLen + 20) return { ok: false, reason: 'rewrite expands entry' }
153
+ if (sim < 0.28) return { ok: false, reason: `rewrite drift sim=${sim.toFixed(2)}` }
154
+ return { ok: true, reason: 'safe compression' }
155
+ }
156
+
157
+ try {
158
+ const [oldEmbedding, newEmbedding] = await Promise.all([embedText(oldText), embedText(newText)])
159
+ const cosine = cosineSimilarity(oldEmbedding, newEmbedding)
160
+ if (cosine == null) return { ok: false, reason: 'cross-language embedding invalid' }
161
+ if (cosine < 0.6) return { ok: false, reason: `cross-language semantic drift cosine=${cosine.toFixed(2)}` }
162
+ return { ok: true, reason: `safe cross-language rewrite cosine=${cosine.toFixed(2)}` }
163
+ } catch (err) {
164
+ return { ok: false, reason: `cross-language embedding failed: ${err?.message || 'unknown error'}` }
165
+ }
131
166
  }
132
167
 
133
168
  function findElementConflict(coreById, currentId, element, projectId) {
@@ -659,7 +694,7 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
659
694
  }
660
695
  if (a.verb === 'update') {
661
696
  proposed.updated++
662
- const safety = conservative ? isSafeConservativeUpdate(coreById.get(a.id), a) : { ok: true, reason: 'confirmed' }
697
+ const safety = conservative ? await isSafeConservativeUpdate(coreById.get(a.id), a) : { ok: true, reason: 'confirmed' }
663
698
  const conflictId = conservative
664
699
  ? findElementConflict(coreById, a.id, a.element, coreById.get(a.id)?.project_id ?? null)
665
700
  : null
@@ -49,7 +49,7 @@ export function createQueryHandlers({
49
49
  // Raw-row priority lookup for narrow-window queries. Raw rows (is_root=0,
50
50
  // chunk_root IS NULL) are inserted immediately by ingestTranscriptFile before
51
51
  // cycle1 runs, so they always carry the freshest turns in the DB.
52
- async function readRawRowsInWindow(db, tsFromMs, tsToMs, hardLimit = 10, { projectScope, sessionId, terms } = {}) {
52
+ async function readRawRowsInWindow(db, tsFromMs, tsToMs, hardLimit = 10, { projectScope, sessionId, terms, minHits: minHitsOverride } = {}) {
53
53
  try {
54
54
  // Composable WHERE assembly (mirrors retrieveEntries' filter semantics so
55
55
  // raw and chunked legs stay in filter parity: projectScope AND sessionId
@@ -78,7 +78,9 @@ export function createQueryHandlers({
78
78
  // into the page. 1-2 term queries keep single-hit contains semantics —
79
79
  // short Korean queries are often exactly two meaningful tokens and a
80
80
  // 2-of-2 requirement silently emptied the raw leg for them.
81
- const minHits = terms.length >= 3 ? 2 : 1
81
+ const minHits = Number.isFinite(Number(minHitsOverride))
82
+ ? Math.max(1, Math.floor(Number(minHitsOverride)))
83
+ : (terms.length >= 3 ? 2 : 1)
82
84
  where.push(`(${clauses.join(' + ')}) >= ${minHits}`)
83
85
  }
84
86
  params.push(hardLimit)
@@ -722,7 +724,7 @@ export function createQueryHandlers({
722
724
  // Deterministic tie-breaker: equal MAX(ts) across sessions would let the
723
725
  // LIMIT/OFFSET window skip or duplicate a session between offset pages
724
726
  // without a stable secondary sort.
725
- const sessSql = `SELECT session_id, MAX(ts) AS last_ts
727
+ const sessSql = `SELECT session_id, MIN(ts) AS first_ts, MAX(ts) AS last_ts
726
728
  FROM entries
727
729
  WHERE ${selWhere.join(' AND ')}
728
730
  GROUP BY session_id
@@ -732,6 +734,7 @@ export function createQueryHandlers({
732
734
  // 2) per selected session, fetch its newest rows (roots+members, sort by
733
735
  // date) plus the fresh raw window, capped at PER_SESSION_ROW_CAP.
734
736
  const allRows = []
737
+ const sessionMeta = new Map()
735
738
  for (const s of sessRows) {
736
739
  const sid = String(s?.session_id || '').trim()
737
740
  if (!sid) continue
@@ -743,7 +746,24 @@ export function createQueryHandlers({
743
746
  const sRows = await retrieveEntries(db, sf)
744
747
  let merged = sRows
745
748
  if (includeRaw) {
746
- const rawRows = await readRawRowsInWindow(db, null, Date.now(), perSessionFetchCap, { projectScope, sessionId: sid, terms: queryTerms })
749
+ let rawRows
750
+ if (queryTerms.length > 0) {
751
+ // Keep a full unfiltered recency window for the newest-row floor,
752
+ // while separately retaining the deeper term-matched raw window.
753
+ const [recentRawRows, matchedRawRows] = await Promise.all([
754
+ readRawRowsInWindow(db, null, Date.now(), perSessionFetchCap, { projectScope, sessionId: sid, terms: [] }),
755
+ readRawRowsInWindow(db, null, Date.now(), perSessionFetchCap, { projectScope, sessionId: sid, terms: queryTerms, minHits: 1 }),
756
+ ])
757
+ const rawIds = new Set()
758
+ rawRows = [...recentRawRows, ...matchedRawRows].filter((r) => {
759
+ const id = Number(r.id)
760
+ if (rawIds.has(id)) return false
761
+ rawIds.add(id)
762
+ return true
763
+ })
764
+ } else {
765
+ rawRows = await readRawRowsInWindow(db, null, Date.now(), perSessionFetchCap, { projectScope, sessionId: sid, terms: [] })
766
+ }
747
767
  const seenIds = new Set(sRows.map((r) => Number(r.id)))
748
768
  for (const r of sRows) if (Array.isArray(r.members)) for (const m of r.members) seenIds.add(Number(m.id))
749
769
  // readRawRowsInWindow carries no category filter, so a category-
@@ -768,14 +788,37 @@ export function createQueryHandlers({
768
788
  merged.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0) || (Number(b.id) || 0) - (Number(a.id) || 0))
769
789
  }
770
790
  }
791
+ const fetchedCount = merged.length
792
+ let queryFiltered = false
771
793
  if (queryTerms.length > 0) {
772
- merged = merged.filter(matchesQueryTerms)
794
+ const newestRows = merged.slice(0, 3)
795
+ const newestIds = new Set(newestRows.map((r) => r.id))
796
+ const matchedRows = merged.filter(matchesQueryTerms)
797
+ // A topic query must not obscure a session's latest activity: keep
798
+ // its three newest rows, then use term matches for the remaining
799
+ // display slots without duplicating rows already kept for recency.
800
+ merged = [...newestRows, ...matchedRows.filter((r) => !newestIds.has(r.id))]
801
+ // Only mark query filtering when its floor+match union actually
802
+ // excludes fetched rows. The later display cap is independent.
803
+ queryFiltered = merged.length < fetchedCount
773
804
  }
774
805
  merged = merged.slice(0, PER_SESSION_ROW_CAP)
806
+ sessionMeta.set(sid, {
807
+ minTs: Number(s.first_ts),
808
+ maxTs: Number(s.last_ts),
809
+ fetchedCount,
810
+ shownCount: merged.length,
811
+ queryFiltered,
812
+ })
775
813
  for (const r of merged) allRows.push(r)
776
814
  }
777
815
  const _currentSessionHint = String(args?.currentSessionId || '').trim()
778
- return { text: recallCapPrefix + renderSessionGroupedLines(allRows, { currentSessionId: _currentSessionHint, recencyOrder: true, spanHeaders: true }) }
816
+ return { text: recallCapPrefix + renderSessionGroupedLines(allRows, {
817
+ currentSessionId: _currentSessionHint,
818
+ recencyOrder: true,
819
+ spanHeaders: true,
820
+ sessionMeta,
821
+ }) }
779
822
  }
780
823
 
781
824
  const filters = { limit: limit + offset }
@@ -336,10 +336,16 @@ function spanHeaderSuffix(minTs, maxTs, n) {
336
336
  // group's lines are globally ts-desc: without it a chunk root's members (stored
337
337
  // ts-ASC) interleave with raw rows and invert the visible timeline within a
338
338
  // session (e.g. 04:33 rendered above 04:41).
339
- export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder = false, spanHeaders = false } = {}) {
340
- if (!rows || rows.length === 0) return '(no results)'
339
+ export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder = false, spanHeaders = false, sessionMeta } = {}) {
340
+ const hasSessionMeta = spanHeaders && Number(sessionMeta?.size) > 0
341
+ if ((!rows || rows.length === 0) && !hasSessionMeta) return '(no results)'
341
342
  const groups = new Map()
342
- for (const r of rows) {
343
+ // Seed selected sessions so an entirely query-filtered session still
344
+ // reports its real activity span and filtering status.
345
+ if (hasSessionMeta) {
346
+ for (const sid of sessionMeta.keys()) groups.set(sid, [])
347
+ }
348
+ for (const r of rows || []) {
343
349
  const sid = String(r?.session_id || '').trim()
344
350
  const key = sid || '(no session)'
345
351
  if (!groups.has(key)) groups.set(key, [])
@@ -356,11 +362,20 @@ export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder
356
362
  for (const [sid, groupRows] of groups) {
357
363
  const mark = current && sid === current ? ' (current)' : ''
358
364
  const label = sid === '(no session)' ? sid : `session ${shortSessionLabel(sid)}`
365
+ const meta = sessionMeta?.get?.(sid)
359
366
  const tsAll = collectGroupTs(groupRows)
360
- const suffix = tsAll.length
361
- ? ` ${spanHeaderSuffix(Math.min(...tsAll), Math.max(...tsAll), groupRows.length)}`
367
+ const minTs = Number(meta?.minTs)
368
+ const maxTs = Number(meta?.maxTs)
369
+ const hasActivitySpan = Number.isFinite(minTs) && Number.isFinite(maxTs)
370
+ const suffix = hasActivitySpan
371
+ ? ` ${spanHeaderSuffix(minTs, maxTs, groupRows.length)}`
372
+ : tsAll.length
373
+ ? ` ${spanHeaderSuffix(Math.min(...tsAll), Math.max(...tsAll), groupRows.length)}`
362
374
  : ` (${groupRows.length} entries)`
363
- parts.push(`## ${label}${mark}${suffix}`)
375
+ const filterNote = meta?.queryFiltered
376
+ ? ` · query-filtered ${meta.shownCount}/${meta.fetchedCount} rows`
377
+ : ''
378
+ parts.push(`## ${label}${mark}${suffix}${filterNote}`)
364
379
  const bodyStr = renderEntryLines(groupRows, { recencyOrder })
365
380
  const bodyLines = bodyStr === '(no results)'
366
381
  ? []
@@ -8,7 +8,7 @@ export const TOOL_DEFS = [
8
8
  name: 'memory',
9
9
  title: 'Memory Cycle',
10
10
  annotations: { title: 'Memory Cycle', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
11
- description: 'Core-memory mutation and status; use recall for retrieval. Store durable rules/preferences/facts, one short clause each — never transient task state. add requires project_id+category+summary; edit works by id alone.',
11
+ description: 'Core-memory mutation and status; use recall for retrieval. Store durable rules/preferences/facts, one compact ENGLISH clause each — never transient task state. add requires project_id+category+summary; edit works by id alone.',
12
12
  inputSchema: {
13
13
  type: 'object',
14
14
  properties: {
@@ -16,7 +16,7 @@ export const TOOL_DEFS = [
16
16
  op: { type: 'string', enum: ['add','edit','delete','list','candidates','promote','dismiss'], description: 'Mutation op. candidates/promote/dismiss drive core-memory proposal approval.' },
17
17
  id: { type: 'number', description: 'Exact memory id.' },
18
18
  element: { type: 'string', maxLength: 40, description: 'Memory key/title. Defaults to the first 40 chars of summary. Max 40 chars.' },
19
- summary: { type: 'string', maxLength: 100, description: 'Memory content: one short fact, max 100 chars.' },
19
+ summary: { type: 'string', maxLength: 100, description: 'Memory content: one short English clause, max 100 chars.' },
20
20
  category: { type: 'string', enum: ['rule','constraint','decision','fact','goal','preference','task','issue'], description: 'Category.' },
21
21
  status: { type: 'string', enum: ['pending','active','archived'], description: 'Lifecycle status.' },
22
22
  limit: { type: 'number', description: 'Max rows/items.' },