memorix 1.4.4 → 1.4.6

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 (90) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/CLAUDE.md +7 -3
  3. package/README.md +14 -4
  4. package/README.zh-CN.md +9 -2
  5. package/dist/cli/index.js +1802 -1138
  6. package/dist/index.js +1298 -991
  7. package/dist/{maintenance-jobs-B1-W0ssz.d.ts → maintenance-jobs-DVRF-46r.d.ts} +1 -1
  8. package/dist/maintenance-runner.d.ts +1 -1
  9. package/dist/maintenance-runner.js +1755 -829
  10. package/dist/media-audio-runner.js +1760 -835
  11. package/dist/media-video-runner.js +1755 -830
  12. package/dist/memcode-runtime/CHANGELOG.md +69 -0
  13. package/dist/sdk.js +1290 -990
  14. package/dist/types.d.ts +7 -1
  15. package/dist/vector-backfill-runner.d.ts +1 -1
  16. package/dist/vector-backfill-runner.js +1751 -826
  17. package/docs/1.4.5-AGENT-AUTO-MEMORY-RESEARCH.md +95 -0
  18. package/docs/AGENT_OPERATOR_PLAYBOOK.md +3 -2
  19. package/docs/API_REFERENCE.md +23 -4
  20. package/docs/CONFIGURATION.md +17 -3
  21. package/docs/INTEGRATIONS.md +9 -2
  22. package/docs/KNOWN_ISSUES_AND_ROADMAP.md +1 -0
  23. package/docs/MEMCODE.md +1 -1
  24. package/docs/SETUP.md +14 -4
  25. package/llms-full.txt +5 -4
  26. package/llms.txt +1 -1
  27. package/package.json +1 -1
  28. package/plugins/antigravity/memorix/mcp_config.json +3 -1
  29. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  30. package/plugins/claude/memorix/.mcp.json +1 -1
  31. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  32. package/plugins/codebuddy/memorix/.mcp.json +1 -1
  33. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  34. package/plugins/codex/memorix/.mcp.json +1 -1
  35. package/plugins/copilot/memorix/.mcp.json +1 -1
  36. package/plugins/copilot/memorix/plugin.json +1 -1
  37. package/plugins/gemini/memorix/gemini-extension.json +4 -2
  38. package/plugins/omp/memorix/package.json +1 -1
  39. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  40. package/plugins/openclaw/memorix/.mcp.json +3 -1
  41. package/plugins/pi/memorix/package.json +1 -1
  42. package/server.json +2 -2
  43. package/src/cli/commands/agent-integrations.ts +7 -1
  44. package/src/cli/commands/dashboard.ts +14 -3
  45. package/src/cli/commands/hooks-install.ts +2 -0
  46. package/src/cli/commands/hooks-preview.ts +5 -0
  47. package/src/cli/commands/ingest-commit.ts +47 -3
  48. package/src/cli/commands/integrate.ts +2 -0
  49. package/src/cli/commands/memory.ts +17 -4
  50. package/src/cli/commands/purge.ts +120 -0
  51. package/src/cli/commands/serve-http.ts +39 -5
  52. package/src/cli/commands/setup.ts +60 -3
  53. package/src/cli/commands/uninstall.ts +20 -0
  54. package/src/cli/index.ts +3 -804
  55. package/src/codegraph/auto-context.ts +82 -1
  56. package/src/config/yaml-loader.ts +3 -8
  57. package/src/config.ts +0 -5
  58. package/src/evaluation/session-replay.ts +98 -0
  59. package/src/git/extractor.ts +5 -3
  60. package/src/hooks/installers/index.ts +58 -2
  61. package/src/hooks/types.ts +2 -0
  62. package/src/integrations/registry.ts +9 -0
  63. package/src/knowledge/workset.ts +58 -0
  64. package/src/memory/long-term.ts +97 -2
  65. package/src/runtime/control-plane-maintenance.ts +1 -0
  66. package/src/runtime/isolated-maintenance.ts +1 -0
  67. package/src/runtime/lifecycle.ts +19 -0
  68. package/src/runtime/maintenance-jobs.ts +1 -0
  69. package/src/runtime/project-maintenance.ts +8 -0
  70. package/src/search/surfaced-registry.ts +62 -0
  71. package/src/server.ts +74 -14
  72. package/src/store/orama-store.ts +17 -1
  73. package/src/types.ts +7 -1
  74. package/src/workspace/mcp-adapters/dsh.ts +147 -0
  75. package/src/cli/commands/audit-list.ts +0 -89
  76. package/src/cli/commands/purge-all-memory.ts +0 -85
  77. package/src/cli/commands/purge-project-memory.ts +0 -83
  78. package/src/cli/commands/uninstall-project-artifacts.ts +0 -86
  79. package/src/cli/tui/Sidebar.tsx +0 -179
  80. package/src/cli/tui/interactive-commands.ts +0 -261
  81. package/src/cli/tui/markdown-render.tsx +0 -371
  82. package/src/cli/tui/use-mouse.ts +0 -157
  83. package/src/cli/tui/useNavigation.ts +0 -56
  84. package/src/orchestrate/context-compact.ts +0 -188
  85. package/src/team/file-locks.ts +0 -173
  86. package/src/team/index.ts +0 -23
  87. package/src/team/messages.ts +0 -203
  88. package/src/team/persistence.ts +0 -96
  89. package/src/team/registry.ts +0 -158
  90. package/src/team/tasks.ts +0 -211
@@ -83,6 +83,25 @@ export function enqueueObservationQualification(input: {
83
83
  });
84
84
  }
85
85
 
86
+ /**
87
+ * Long-term memory maintains itself on a daily rule leg: stale active records
88
+ * archive themselves and newer records supersede same-title older ones. No
89
+ * LLM required; enqueueing is a durable no-op when nothing is due.
90
+ */
91
+ export function enqueueLongTermMaintenance(input: {
92
+ dataDir: string;
93
+ projectId: string;
94
+ source: string;
95
+ queue?: MaintenanceQueue;
96
+ }): void {
97
+ queueFor(input).enqueue({
98
+ projectId: input.projectId,
99
+ kind: 'long-term-maintenance',
100
+ dedupeKey: 'long-term-maintenance',
101
+ payload: { source: input.source },
102
+ });
103
+ }
104
+
86
105
  /**
87
106
  * The follow-up jobs operate on the same workspace the Workset reads: a
88
107
  * versioned workspace when present, otherwise the local workspace.
@@ -16,6 +16,7 @@ export const MAINTENANCE_JOB_KINDS = [
16
16
  'knowledge-compile',
17
17
  'knowledge-lint',
18
18
  'workflow-index',
19
+ 'long-term-maintenance',
19
20
  ] as const;
20
21
 
21
22
  export type MaintenanceJobKind = typeof MAINTENANCE_JOB_KINDS[number];
@@ -263,6 +263,14 @@ export function createProjectMaintenanceHandler(
263
263
  return { action: 'complete' };
264
264
  }
265
265
 
266
+ if (job.kind === 'long-term-maintenance') {
267
+ const { maintainLongTermMemories } = await import('../memory/long-term.js');
268
+ await maintainLongTermMemories({ dataDir: projectDir, projectId });
269
+ // Decay and supersede thresholds are measured in days: one daily pass is
270
+ // plenty. Rescheduling the same row keeps a quiet, durable heartbeat.
271
+ return { action: 'reschedule', delayMs: 24 * 60 * 60 * 1000, resetAttempts: true };
272
+ }
273
+
266
274
  if (job.kind === 'consolidation') {
267
275
  const limit = consolidationBatchSize(job.payload);
268
276
  const result = await runAutomaticConsolidation(projectId, projectDir, {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Surfaced-entry registry — session-level "already shown this session" bookkeeping.
3
+ *
4
+ * The retrieval layer can demote entries whose ids are marked as surfaced, so
5
+ * a coding session does not get the same memory rows re-shown for every
6
+ * related question. This registry is the state that remembers which ids have
7
+ * been shown, keyed by session scope.
8
+ *
9
+ * Design boundaries:
10
+ * - In-memory and bounded: at most MAX_KEYS scopes, at most MAX_IDS ids per
11
+ * scope, oldest evicted first. Restarting the server resets it, which is
12
+ * correct — "surfaced" only means "shown in this live session".
13
+ * - Best-effort: losing an entry only costs one redundant row, never data.
14
+ * - Scope keys are caller-owned strings (e.g. "session:<id>" or
15
+ * "project:<id>"); the registry does not interpret them.
16
+ */
17
+
18
+ const MAX_KEYS = 64;
19
+ const MAX_IDS = 200;
20
+
21
+ const registry = new Map<string, number[]>();
22
+
23
+ function touch(key: string): number[] {
24
+ const ids = registry.get(key);
25
+ if (!ids) return [];
26
+ // Re-insert so Map iteration order approximates LRU for eviction.
27
+ registry.delete(key);
28
+ registry.set(key, ids);
29
+ return ids;
30
+ }
31
+
32
+ /** Ids already surfaced for this scope, oldest first. Never mutates state. */
33
+ export function getSurfacedIds(key: string): number[] {
34
+ return [...touch(key)];
35
+ }
36
+
37
+ /** Mark ids as surfaced for this scope. New ids append; old ids overflow out. */
38
+ export function recordSurfacedIds(key: string, ids: number[]): void {
39
+ if (ids.length === 0) return;
40
+
41
+ let list = registry.get(key) ?? [];
42
+ const known = new Set(list);
43
+ for (const id of ids) {
44
+ if (known.has(id)) continue;
45
+ known.add(id);
46
+ list.push(id);
47
+ }
48
+ if (list.length > MAX_IDS) {
49
+ list = list.slice(list.length - MAX_IDS);
50
+ }
51
+ registry.set(key, list);
52
+
53
+ if (registry.size > MAX_KEYS) {
54
+ const oldestKey = registry.keys().next().value;
55
+ if (oldestKey !== undefined && oldestKey !== key) registry.delete(oldestKey);
56
+ }
57
+ }
58
+
59
+ /** Test/teardown helper — resets all in-memory state. */
60
+ export function clearSurfacedRegistry(): void {
61
+ registry.clear();
62
+ }
package/src/server.ts CHANGED
@@ -52,6 +52,7 @@ import type { FormationConfig, SearchHit, FormedMemory, FormationStage, Formatio
52
52
  import { parseFormationTimeoutMs } from './server/formation-timeout.js';
53
53
  import { withTimeout } from './timeout.js';
54
54
  import { sanitizeCredentials } from './memory/secret-filter.js';
55
+ import { getSurfacedIds, recordSurfacedIds } from './search/surfaced-registry.js';
55
56
  import {
56
57
  createProjectBindingController,
57
58
  type ProjectBindingController,
@@ -508,6 +509,12 @@ export async function createMemorixServer(
508
509
  kind: 'consolidation',
509
510
  dedupeKey: 'startup-consolidation',
510
511
  });
512
+ lifecycle.enqueueLongTermMaintenance({
513
+ dataDir: projectDir,
514
+ projectId: project.id,
515
+ source: 'startup',
516
+ queue,
517
+ });
511
518
  }
512
519
 
513
520
  // This check is only a small SQLite metadata query. The actual directory
@@ -1152,7 +1159,7 @@ export async function createMemorixServer(
1152
1159
  let longTermNote = '';
1153
1160
  if (longTerm) {
1154
1161
  try {
1155
- const { promoteObservationToLongTermMemory } = await import('./memory/long-term.js');
1162
+ const { promoteObservationToLongTermMemory, maybeAutoQualifyLongTermMemory } = await import('./memory/long-term.js');
1156
1163
  const promoted = await promoteObservationToLongTermMemory({
1157
1164
  dataDir: projectDir,
1158
1165
  observation: obs,
@@ -1166,7 +1173,28 @@ export async function createMemorixServer(
1166
1173
  ...(reader.isTeamMember ? { isTeamMember: true } : {}),
1167
1174
  },
1168
1175
  });
1169
- longTermNote = `\nLong-term candidate: ${promoted.memory.id} (${promoted.memory.kind}/${promoted.memory.scope}). Review it with the CLI before it is delivered automatically.`;
1176
+ // An explicit store call carries its own source evidence: qualify
1177
+ // on the spot instead of waiting for a manual CLI review.
1178
+ const autoQualified = await maybeAutoQualifyLongTermMemory({
1179
+ dataDir: projectDir,
1180
+ id: promoted.memory.id,
1181
+ sourceDetail: 'explicit',
1182
+ });
1183
+ if (autoQualified) {
1184
+ // A fresh explicit record retires stale same-title records on the
1185
+ // durable rule leg instead of waiting for the next session start.
1186
+ try {
1187
+ const { enqueueLongTermMaintenance } = await import('./runtime/lifecycle.js');
1188
+ enqueueLongTermMaintenance({
1189
+ dataDir: projectDir,
1190
+ projectId: project.id,
1191
+ source: 'long-term:' + promoted.memory.id,
1192
+ });
1193
+ } catch { /* maintenance is optional; the record already qualified */ }
1194
+ }
1195
+ longTermNote = autoQualified
1196
+ ? `\nLong-term memory: ${promoted.memory.id} (${promoted.memory.kind}/${promoted.memory.scope}, auto-qualified) and now enters briefs as durable context. Audit with \`memorix memory long-term list\`.`
1197
+ : `\nLong-term candidate: ${promoted.memory.id} (${promoted.memory.kind}/${promoted.memory.scope}). Review it with the CLI before it is delivered automatically.`;
1170
1198
  } catch (longTermError) {
1171
1199
  longTermNote = `\n[WARN] Observation stored, but long-term candidate was not created: ${longTermError instanceof Error ? longTermError.message : 'unknown error'}`;
1172
1200
  }
@@ -1394,6 +1422,17 @@ export async function createMemorixServer(
1394
1422
  const boundary = requireExplicitAutopilotExpansion('memorix_search', purpose);
1395
1423
  if (boundary) return boundary;
1396
1424
  }
1425
+
1426
+ // Session dedup scope: prefer the active session id, fall back to the
1427
+ // bound project. Best-effort bookkeeping — losing it only costs one
1428
+ // redundant result row, never data.
1429
+ let surfacedKey = `project:${project.id}`;
1430
+ try {
1431
+ const { getActiveSession } = await import('./memory/session.js');
1432
+ const active = await getActiveSession(projectDir, project.id);
1433
+ if (active) surfacedKey = `session:${active.id}`;
1434
+ } catch { /* fall back to the project key */ }
1435
+
1397
1436
  return withFreshIndex(async () => {
1398
1437
 
1399
1438
  const safeLimit = limit != null ? coerceNumber(limit, 20) : undefined;
@@ -1415,6 +1454,7 @@ export async function createMemorixServer(
1415
1454
  source: source as 'agent' | 'git' | 'manual' | undefined,
1416
1455
  quality: quality as 'fast' | 'balanced' | 'thorough',
1417
1456
  reader: getObservationReader(scope === 'global' ? 'global' : 'project'),
1457
+ surfacedIds: getSurfacedIds(surfacedKey),
1418
1458
  });
1419
1459
 
1420
1460
  let result;
@@ -1436,6 +1476,10 @@ export async function createMemorixServer(
1436
1476
  throw error;
1437
1477
  }
1438
1478
 
1479
+ // These rows were just shown to the model — remember them so later
1480
+ // searches in the same session demote instead of re-showing.
1481
+ recordSurfacedIds(surfacedKey, result.entries.map(entry => entry.id));
1482
+
1439
1483
  const activeBoundary = getActiveAutopilotRetrievalBoundary();
1440
1484
  if (
1441
1485
  scope !== 'global'
@@ -4998,6 +5042,12 @@ export async function createMemorixServer(
4998
5042
  return undefined;
4999
5043
  }
5000
5044
 
5045
+ // The advertised schema matches what the operator actually enabled: a
5046
+ // closed gate means the action does not exist for the agent, not that it
5047
+ // exists and throws. The handler keeps its own runtime checks as well.
5048
+ const mediaGenerationEnabled = process.env.MEMORIX_MCP_MEDIA_GENERATION === '1';
5049
+ const mediaTranscriptionEnabled = process.env.MEMORIX_MCP_MEDIA_TRANSCRIPTION === '1';
5050
+ const mediaGenerationOrTranscription = mediaGenerationEnabled || mediaTranscriptionEnabled;
5001
5051
  server.registerTool(
5002
5052
  'memorix_media',
5003
5053
  {
@@ -5008,7 +5058,11 @@ export async function createMemorixServer(
5008
5058
  'Use the CLI for destructive removal, quota cleanup, and direct generation. ' +
5009
5059
  'MCP generation is disabled by default and requires MEMORIX_MCP_MEDIA_GENERATION=1 after the operator reviews provider billing.',
5010
5060
  inputSchema: {
5011
- action: z.enum(['import', 'attach', 'list', 'show', 'derive-pdf', 'derive-audio', 'generate-image', 'generate-video', 'status', 'cancel']),
5061
+ action: z.enum([
5062
+ 'import', 'attach', 'list', 'show', 'derive-pdf', 'status', 'cancel',
5063
+ ...(mediaTranscriptionEnabled ? ['derive-audio'] : []),
5064
+ ...(mediaGenerationEnabled ? ['generate-image', 'generate-video'] : []),
5065
+ ] as [string, ...string[]]),
5012
5066
  path: z.string().optional().describe('Explicit local image/audio/video/PDF path for import.'),
5013
5067
  assetId: z.string().optional().describe('Controlled MediaAsset ID for attach/show.'),
5014
5068
  jobId: z.string().optional().describe('Durable media job ID for status.'),
@@ -5016,20 +5070,26 @@ export async function createMemorixServer(
5016
5070
  limit: z.number().int().min(1).max(100).optional().describe('Maximum assets to list.'),
5017
5071
  title: z.string().optional().describe('Observation title when attaching generated/imported output.'),
5018
5072
  narrative: z.string().optional().describe('Short retrieval text when attaching an asset.'),
5019
- prompt: z.string().optional().describe('Explicit MiniMax generation prompt.'),
5020
- image: z.string().optional().describe('Base64-encoded reference image for image-to-image generation.'),
5021
- model: z.string().max(160).optional().describe('Provider model for the selected media action.'),
5022
- provider: z.enum(['openai', 'groq']).optional().describe('Explicit audio transcription provider.'),
5023
- language: z.string().max(32).optional().describe('Optional ISO-639-1 hint for audio transcription.'),
5024
- region: z.enum(['global', 'cn']).optional().describe('MiniMax deployment region.'),
5025
- n: z.number().int().min(1).max(4).optional().describe('Image output count.'),
5026
- ratio: z.enum(['adaptive', '1:1', '16:9', '4:3', '3:2', '2:3', '3:4', '9:16', '21:9']).optional().describe('Image or video aspect ratio.'),
5027
- width: z.number().int().min(1).max(8192).optional().describe('Requested image width.'),
5028
- height: z.number().int().min(1).max(8192).optional().describe('Requested image height.'),
5029
- duration: z.union([z.literal(5), z.literal(10)]).optional().describe('MiniMax video duration in seconds.'),
5030
5073
  maxPages: z.number().int().min(1).max(100).optional().describe('Bounded PDF extraction page limit.'),
5031
5074
  maxChars: z.number().int().min(1).max(60_000).optional().describe('Bounded PDF extraction character limit.'),
5032
5075
  attach: z.boolean().optional().describe('Attach generated/imported output to normal project memory explicitly.'),
5076
+ ...(mediaGenerationOrTranscription ? {
5077
+ model: z.string().max(160).optional().describe('Provider model for the selected media action.'),
5078
+ prompt: z.string().optional().describe('Explicit prompt for generation or transcription.'),
5079
+ } : {}),
5080
+ ...(mediaTranscriptionEnabled ? {
5081
+ provider: z.enum(['openai', 'groq']).optional().describe('Explicit audio transcription provider.'),
5082
+ language: z.string().max(32).optional().describe('Optional ISO-639-1 hint for audio transcription.'),
5083
+ } : {}),
5084
+ ...(mediaGenerationEnabled ? {
5085
+ image: z.string().optional().describe('Base64-encoded reference image for image-to-image generation.'),
5086
+ region: z.enum(['global', 'cn']).optional().describe('MiniMax deployment region.'),
5087
+ n: z.number().int().min(1).max(4).optional().describe('Image output count.'),
5088
+ ratio: z.enum(['adaptive', '1:1', '16:9', '4:3', '3:2', '2:3', '3:4', '9:16', '21:9']).optional().describe('Image or video aspect ratio.'),
5089
+ width: z.number().int().min(1).max(8192).optional().describe('Requested image width.'),
5090
+ height: z.number().int().min(1).max(8192).optional().describe('Requested image height.'),
5091
+ duration: z.union([z.literal(5), z.literal(10)]).optional().describe('MiniMax video duration in seconds.'),
5092
+ } : {}),
5033
5093
  },
5034
5094
  },
5035
5095
  async ({ action, path: assetPath, assetId, jobId, kind, limit, title, narrative, prompt, image, model, provider, language, region, n, ratio, width, height, duration, maxPages, maxChars, attach }) => {
@@ -587,7 +587,9 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
587
587
 
588
588
  // Orama's vector/hybrid search can leak cross-project hits even when `where`
589
589
  // is present, so always keep enough headroom for a deterministic post-filter.
590
- const requestLimit = projectIds
590
+ // Session dedup demotes already-surfaced entries after ranking, so it needs
591
+ // the same headroom to let unseen candidates rise into the final limit.
592
+ const requestLimit = projectIds || (options.surfacedIds && options.surfacedIds.length > 0)
591
593
  ? (options.limit ?? 20) * 3
592
594
  : (options.limit ?? 20);
593
595
 
@@ -902,6 +904,20 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
902
904
  intermediate.sort((a, b) => b.score - a.score);
903
905
  }
904
906
 
907
+ // ── Session dedup (demote, never remove) ────────────────────────
908
+ // Entries already surfaced earlier in the same session are demoted so
909
+ // unseen evidence can rise into the final limit. A demoted entry is never
910
+ // removed: when it is the strong match for a new question, it still wins
911
+ // on score and answers that turn.
912
+ if (options.surfacedIds && options.surfacedIds.length > 0) {
913
+ const SURFACED_PENALTY = 0.5;
914
+ const surfaced = new Set(options.surfacedIds);
915
+ intermediate = intermediate.map(entry =>
916
+ surfaced.has(entry.id) ? { ...entry, score: (entry.score ?? 0) * SURFACED_PENALTY } : entry,
917
+ );
918
+ intermediate.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
919
+ }
920
+
905
921
  // Temporal filtering: since/until date range
906
922
  if (options.since) {
907
923
  const sinceDate = new Date(options.since).getTime();
package/src/types.ts CHANGED
@@ -317,6 +317,12 @@ export interface SearchOptions {
317
317
  trackAccess?: boolean;
318
318
  /** Internal reader context for agent-facing visibility filtering. */
319
319
  reader?: ObservationReader;
320
+ /**
321
+ * Observation ids already surfaced earlier in the same session. Surfaced
322
+ * entries are demoted (never removed) so unseen evidence can rise into the
323
+ * result limit; a strong match still answers a new question.
324
+ */
325
+ surfacedIds?: number[];
320
326
  /**
321
327
  * `fast` stays fully local, `balanced` (default) permits embeddings, and
322
328
  * `thorough` explicitly permits optional LLM query rewrite and reranking.
@@ -654,7 +660,7 @@ export interface MemoryRef {
654
660
  /** MCP config format adapter interface */
655
661
  export interface MCPConfigAdapter {
656
662
  /** Setup-only adapters may support a host that does not participate in workspace sync. */
657
- readonly source: AgentTarget | 'codebuddy';
663
+ readonly source: AgentTarget | 'codebuddy' | 'dsh';
658
664
  /** Parse MCP server entries from a config file */
659
665
  parse(content: string): MCPServerEntry[];
660
666
  /** Generate config file content from MCP server entries */
@@ -0,0 +1,147 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
4
+ import type { MCPConfigAdapter, MCPServerEntry } from '../../types.js';
5
+
6
+ /**
7
+ * DeepSeek Harness (DSH) MCP Configuration Adapter.
8
+ *
9
+ * DSH composes its runtime from Cordis patch layers. External MCP servers are
10
+ * registered as rows of the `@deepseek-ai/dsh-mcp-client` plugin inside a
11
+ * patch list. The user-level layer that applies to every profile lives at:
12
+ *
13
+ * $DSH_HOME/cordis.patch.yml (default: ~/.dsh/cordis.patch.yml)
14
+ *
15
+ * Row shape follows DSH's own shipped Memorix reference
16
+ * (examples/mcp-memory/memorix.cordis.yml in deepseek-ai/deepseek-harness):
17
+ *
18
+ * - insert:
19
+ * - id: memory-memorix
20
+ * name: '@deepseek-ai/dsh-mcp-client'
21
+ * config:
22
+ * serverName: memorix
23
+ * transport: stdio
24
+ * command: memorix
25
+ * args: [serve]
26
+ *
27
+ * The `cwd` key is deliberately omitted so the MCP child inherits the DSH
28
+ * process working directory — the workspace DSH was launched from — which is
29
+ * what keeps Memorix bound to the current Git project.
30
+ *
31
+ * Source: https://github.com/deepseek-ai/deepseek-harness/tree/master/packages/mcp/mcp-client
32
+ */
33
+
34
+ const MCP_CLIENT_PLUGIN = '@deepseek-ai/dsh-mcp-client';
35
+ const MEMORIX_ROW_ID = 'memory-memorix';
36
+
37
+ function resolveDshHome(): string {
38
+ const configured = process.env.DSH_HOME?.trim();
39
+ if (configured) return configured;
40
+ return join(homedir(), '.dsh');
41
+ }
42
+
43
+ function isMemorixRow(row: unknown): row is Record<string, unknown> {
44
+ if (!row || typeof row !== 'object' || Array.isArray(row)) return false;
45
+ const record = row as Record<string, unknown>;
46
+ if (record.name !== MCP_CLIENT_PLUGIN) return false;
47
+ const config = record.config;
48
+ return Boolean(
49
+ config && typeof config === 'object' && !Array.isArray(config)
50
+ && (config as Record<string, unknown>).serverName === 'memorix',
51
+ );
52
+ }
53
+
54
+ function rowToEntry(row: Record<string, unknown>): MCPServerEntry | null {
55
+ const config = row.config as Record<string, unknown>;
56
+ if (!config || typeof config !== 'object') return null;
57
+ const entry: MCPServerEntry = {
58
+ name: 'memorix',
59
+ command: typeof config.command === 'string' ? config.command : '',
60
+ args: Array.isArray(config.args) ? config.args.filter((arg): arg is string => typeof arg === 'string') : [],
61
+ };
62
+ if (typeof config.url === 'string' && config.url) entry.url = config.url;
63
+ if (config.env && typeof config.env === 'object' && !Array.isArray(config.env)) {
64
+ const env: Record<string, string> = {};
65
+ for (const [key, value] of Object.entries(config.env as Record<string, unknown>)) {
66
+ if (typeof value === 'string') env[key] = value;
67
+ }
68
+ if (Object.keys(env).length > 0) entry.env = env;
69
+ }
70
+ if (config.disabled === true) entry.disabled = true;
71
+ return entry;
72
+ }
73
+
74
+ /** Flatten one parsed patch document into its row list, tolerating both
75
+ * `- insert: [...]` operations and bare row lists. */
76
+ function patchRows(document: unknown): Record<string, unknown>[] {
77
+ const rows: Record<string, unknown>[] = [];
78
+ if (!Array.isArray(document)) return rows;
79
+ for (const item of document) {
80
+ if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
81
+ const record = item as Record<string, unknown>;
82
+ if (Array.isArray(record.insert)) {
83
+ for (const row of record.insert) {
84
+ if (row && typeof row === 'object' && !Array.isArray(row)) {
85
+ rows.push(row as Record<string, unknown>);
86
+ }
87
+ }
88
+ continue;
89
+ }
90
+ rows.push(record);
91
+ }
92
+ return rows;
93
+ }
94
+
95
+ export class DshMCPAdapter implements MCPConfigAdapter {
96
+ readonly source = 'dsh' as const;
97
+
98
+ parse(content: string): MCPServerEntry[] {
99
+ if (!content.trim()) return [];
100
+ let document: unknown;
101
+ try {
102
+ document = parseYaml(content);
103
+ } catch {
104
+ return [];
105
+ }
106
+ return patchRows(document)
107
+ .filter(isMemorixRow)
108
+ .map(rowToEntry)
109
+ .filter((entry): entry is MCPServerEntry => entry !== null);
110
+ }
111
+
112
+ generate(servers: MCPServerEntry[]): string {
113
+ const rows = servers
114
+ .filter((server) => server.name === 'memorix')
115
+ .map((server) => {
116
+ const config: Record<string, unknown> = {
117
+ serverName: server.name,
118
+ };
119
+ if (server.url) {
120
+ config.transport = 'streamable-http';
121
+ config.url = server.url;
122
+ if (server.headers && Object.keys(server.headers).length > 0) config.headers = server.headers;
123
+ } else {
124
+ config.transport = 'stdio';
125
+ config.command = server.command;
126
+ if (server.args && server.args.length > 0) config.args = server.args;
127
+ if (server.env && Object.keys(server.env).length > 0) config.env = server.env;
128
+ }
129
+ const row: Record<string, unknown> = {
130
+ id: MEMORIX_ROW_ID,
131
+ name: MCP_CLIENT_PLUGIN,
132
+ config,
133
+ };
134
+ if (server.disabled === true) row.disabled = true;
135
+ return row;
136
+ });
137
+
138
+ if (rows.length === 0) return '';
139
+ return stringifyYaml([{ insert: rows }], { lineWidth: 0 }) + '\n';
140
+ }
141
+
142
+ getConfigPath(_projectRoot?: string): string {
143
+ return join(resolveDshHome(), 'cordis.patch.yml');
144
+ }
145
+ }
146
+
147
+ export { MEMORIX_ROW_ID };
@@ -1,89 +0,0 @@
1
- /**
2
- * CLI Command: memorix audit list
3
- *
4
- * List all files written by Memorix (audit trail).
5
- */
6
-
7
- import { defineCommand } from 'citty';
8
- import * as p from '@clack/prompts';
9
-
10
- export default defineCommand({
11
- meta: {
12
- name: 'list',
13
- description: 'List Memorix-written files (audit trail)',
14
- },
15
- args: {
16
- project: {
17
- type: 'string',
18
- description: 'Filter by project (optional)',
19
- required: false,
20
- },
21
- },
22
- run: async ({ args }) => {
23
- const { getAllAuditEntries, getProjectId } = await import('../../audit/index.js');
24
- const os = await import('node:os');
25
- let cwd: string;
26
- try {
27
- cwd = process.cwd();
28
- } catch {
29
- cwd = os.homedir();
30
- console.log(`[WARN] Could not access current directory, using home: ${cwd}`);
31
- }
32
-
33
- p.intro('Memorix Audit Trail');
34
-
35
- const allEntries = await getAllAuditEntries();
36
-
37
- if (allEntries.length === 0) {
38
- console.log('No Memorix-written files found.');
39
- p.outro('Audit trail is empty.');
40
- return;
41
- }
42
-
43
- // Filter by project if specified
44
- let entries = allEntries;
45
- if (args.project) {
46
- const targetProjectId = getProjectId(args.project);
47
- entries = allEntries.filter((e) => e.projectId === targetProjectId);
48
- }
49
-
50
- if (entries.length === 0) {
51
- console.log('No Memorix-written files found for this project.');
52
- p.outro('Audit trail is empty for this project.');
53
- return;
54
- }
55
-
56
- // Group by project
57
- const byProject: Record<string, Array<{ entry: import('../../audit/index.js').AuditEntry }>> = {};
58
- for (const { projectId, entry } of entries) {
59
- if (!byProject[projectId]) {
60
- byProject[projectId] = [];
61
- }
62
- byProject[projectId].push({ entry });
63
- }
64
-
65
- // Display by project
66
- console.log('');
67
- for (const [projectId, items] of Object.entries(byProject)) {
68
- console.log(`Project: ${projectId}`);
69
- console.log('');
70
-
71
- for (const { entry } of items) {
72
- const icon = entry.type === 'hook' ? '[HOOK]' : '[TASK]';
73
- const agent = entry.agent ? ` (${entry.agent})` : '';
74
- console.log(`${icon} ${entry.path}${agent}`);
75
- console.log(` Created: ${entry.createdAt}`);
76
- console.log('');
77
- }
78
- }
79
-
80
- // Summary
81
- const hookCount = entries.filter((e) => e.entry.type === 'hook').length;
82
- const ruleCount = entries.filter((e) => e.entry.type === 'rule').length;
83
- const projectCount = new Set(entries.map((e) => e.projectId)).size;
84
-
85
- p.note(`${projectCount} projects, ${hookCount} hooks, ${ruleCount} rules`);
86
-
87
- p.outro('Audit trail complete.');
88
- },
89
- });
@@ -1,85 +0,0 @@
1
- /**
2
- * CLI Command: memorix purge all-memory
3
- *
4
- * Delete ALL memories (dangerous operation).
5
- */
6
-
7
- import { defineCommand } from 'citty';
8
- import * as p from '@clack/prompts';
9
-
10
- export default defineCommand({
11
- meta: {
12
- name: 'all-memory',
13
- description: 'Delete ALL memories (dangerous)',
14
- },
15
- args: {},
16
- run: async ({}) => {
17
- p.intro('[WARN] Purge All Memory');
18
-
19
- // Show warning
20
- console.log('');
21
- console.log('[WARN] WARNING: This will delete ALL memories!');
22
- console.log('');
23
- console.log('This will remove:');
24
- console.log(' - All observations from all projects');
25
- console.log(' - All entities and relations');
26
- console.log(' - All embeddings');
27
- console.log('');
28
- console.log('This action CANNOT be undone.');
29
- console.log('');
30
- console.log('Project artifacts (hook files) will be preserved.');
31
- console.log('');
32
-
33
- // First confirmation
34
- const confirmed1 = await p.confirm({
35
- message: 'Are you sure you want to delete ALL memories?',
36
- });
37
-
38
- if (p.isCancel(confirmed1) || !confirmed1) {
39
- p.outro('Purge cancelled.');
40
- return;
41
- }
42
-
43
- // Second confirmation (type "DELETE" to confirm)
44
- const confirmed2 = await p.text({
45
- message: 'Type "DELETE" to confirm:',
46
- validate: (value) => {
47
- if (value !== 'DELETE') {
48
- return 'Please type "DELETE" to confirm';
49
- }
50
- return undefined;
51
- },
52
- });
53
-
54
- if (p.isCancel(confirmed2) || confirmed2 !== 'DELETE') {
55
- p.outro('Purge cancelled.');
56
- return;
57
- }
58
-
59
- // Delete all memories
60
- try {
61
- const {
62
- getAllObservations,
63
- resolveObservations,
64
- } = await import('../../memory/observations.js');
65
- const { withFreshIndex } = await import('../../memory/freshness.js');
66
- const observations = await withFreshIndex(() => getAllObservations());
67
- const ids = observations.map((o) => o.id);
68
-
69
- if (ids.length === 0) {
70
- console.log('No observations found.');
71
- p.outro('No memories to purge.');
72
- return;
73
- }
74
-
75
- const result = await resolveObservations(ids, 'archived');
76
-
77
- console.log(`[OK] Archived ${result.resolved.length} observations`);
78
-
79
- p.outro(`All memory purged. ${result.resolved.length} observations archived.`);
80
- } catch (err) {
81
- console.error(`[ERROR] Failed to purge all memory: ${err}`);
82
- p.outro('Purge failed.');
83
- }
84
- },
85
- });