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
@@ -29,6 +29,8 @@ import {
29
29
  import { CodeGraphStore } from './store.js';
30
30
  import { isEligibleForAutomaticDelivery } from '../memory/admission.js';
31
31
  import { getSessionResumeBrief } from '../memory/session.js';
32
+ import { listLongTermMemories } from '../memory/long-term.js';
33
+ import { filterReadableObservations } from '../memory/visibility.js';
32
34
  import { initSessionStore } from '../store/session-store.js';
33
35
  import {
34
36
  isContinuationTask,
@@ -94,6 +96,64 @@ function deliveryEligibleProjectObservations(
94
96
  .filter((observation) => isEligibleForAutomaticDelivery(observation));
95
97
  }
96
98
 
99
+ /**
100
+ * The always-on block: a small "who you are and what this workspace is
101
+ * doing" context every brief carries, whatever the task. It mirrors the
102
+ * memory-native feel of a per-session MEMORY.md and stays strictly bounded:
103
+ * profile 2 lines, latest session 1 line, durable facts 2 lines.
104
+ */
105
+ async function buildAlwaysOnBlock(input: {
106
+ dataDir: string;
107
+ projectId: string;
108
+ reader: ObservationReader;
109
+ eligible: ProjectContextObservation[];
110
+ }): Promise<{ profile: string[]; state?: string; durable: string[] }> {
111
+ // Profile: personal-visibility observations the guidance teaches as
112
+ // entityName 'user-profile'. Only their owner's reader sees them.
113
+ const profile = filterReadableObservations(input.eligible, input.reader)
114
+ .filter(observation => (
115
+ observation as unknown as { visibility?: string; entityName?: string }
116
+ ).visibility === 'personal'
117
+ && (
118
+ observation as unknown as { entityName?: string }
119
+ ).entityName === 'user-profile')
120
+ .slice(0, 2)
121
+ .map(observation => compactContinuationText(observation.title, 22));
122
+
123
+ // Workspace state: the latest completed session with a summary.
124
+ let state: string | undefined;
125
+ try {
126
+ await initSessionStore(input.dataDir);
127
+ const { getSessionStore } = await import('../store/session-store.js');
128
+ const sessions = await getSessionStore().loadByProject(input.projectId);
129
+ const latest = sessions
130
+ .filter(session => session.endedAt && session.summary?.trim())
131
+ .sort((a, b) => Date.parse(b.endedAt ?? '') - Date.parse(a.endedAt ?? ''))[0];
132
+ if (latest?.summary) state = compactContinuationText(latest.summary, 40);
133
+ } catch { /* session context is optional enrichment */ }
134
+
135
+ // Durable facts: the most recently updated approved long-term memories.
136
+ const durable: string[] = [];
137
+ try {
138
+ const items = await listLongTermMemories({
139
+ dataDir: input.dataDir,
140
+ reader: {
141
+ projectId: input.projectId,
142
+ ...(input.reader.agentId ? { agentId: input.reader.agentId } : {}),
143
+ ...(input.reader.isTeamMember ? { isTeamMember: true } : {}),
144
+ },
145
+ limit: 10,
146
+ });
147
+ durable.push(...items
148
+ .filter(item => item.memory.state === 'approved')
149
+ .sort((a, b) => (b.memory.updatedAt ?? '').localeCompare(a.memory.updatedAt ?? ''))
150
+ .slice(0, 2)
151
+ .map(item => item.memory.kind + ': ' + compactContinuationText(item.memory.title, 22)));
152
+ } catch { /* long-term memory is optional enrichment */ }
153
+
154
+ return { profile, state, durable };
155
+ }
156
+
97
157
  function decideRefresh(input: {
98
158
  mode: AutoContextRefreshMode;
99
159
  status: ReturnType<CodeGraphStore['status']>;
@@ -356,12 +416,21 @@ export async function buildAutoProjectContext(input: {
356
416
  };
357
417
  }
358
418
  }
419
+ const alwaysOn = await buildAlwaysOnBlock({
420
+ dataDir: input.dataDir,
421
+ projectId: input.project.id,
422
+ reader: input.reader ?? { projectId: input.project.id },
423
+ eligible: deliveryEligibleProjectObservations(input.observations, input.project.id),
424
+ });
359
425
  const workset = await buildTaskWorkset({
360
426
  projectId: input.project.id,
361
427
  dataDir: input.dataDir,
362
428
  ...(task ? { task } : {}),
363
429
  lens: lens.id,
364
430
  startHere,
431
+ ...(alwaysOn.profile.length > 0 || alwaysOn.state || alwaysOn.durable.length > 0
432
+ ? { alwaysOn }
433
+ : {}),
365
434
  ...(externalOutline ? { semanticCode: externalOutline } : {}),
366
435
  providerQuality,
367
436
  currentFacts: worksetFactLines(currentFacts),
@@ -598,6 +667,18 @@ export function formatAutoProjectContextSummary(context: AutoProjectContext): st
598
667
  '',
599
668
  ...formatCurrentFactsLines(context.currentFacts),
600
669
  '',
670
+ ].filter(Boolean);
671
+
672
+ if (context.workset.alwaysOn
673
+ && (context.workset.alwaysOn.profile.length > 0 || context.workset.alwaysOn.state || context.workset.alwaysOn.durable.length > 0)) {
674
+ lines.push('You and this workspace');
675
+ for (const profile of context.workset.alwaysOn.profile.slice(0, 2)) lines.push(`- ${profile}`);
676
+ if (context.workset.alwaysOn.state) lines.push(`- Recently: ${context.workset.alwaysOn.state}`);
677
+ for (const durable of context.workset.alwaysOn.durable.slice(0, 2)) lines.push(`- ${durable}`);
678
+ lines.push('');
679
+ }
680
+
681
+ lines.push(...[
601
682
  `- Code memory: ${context.overview.code.files} files / ${context.overview.code.symbols} symbols / ${context.overview.code.refs} memory links`,
602
683
  `- Code provider: ${context.providerQuality.selected} (${context.providerQuality.selectedQuality})`,
603
684
  `- Languages: ${formatLanguages(context.overview)}`,
@@ -606,7 +687,7 @@ export function formatAutoProjectContextSummary(context: AutoProjectContext): st
606
687
  `- Refresh: ${context.refresh.message}`,
607
688
  '',
608
689
  'Start here',
609
- ].filter(Boolean);
690
+ ].filter(Boolean));
610
691
 
611
692
  if (startHere.length > 0) {
612
693
  startHere.forEach((path, index) => lines.push(`${index + 1}. ${path}`));
@@ -110,14 +110,9 @@ export interface MemorixYamlConfig {
110
110
  workspaceCollection?: string;
111
111
  };
112
112
 
113
- /** Additional MCP servers to aggregate (Memorix as hub) */
114
- mcpServers?: Record<string, {
115
- type?: 'stdio' | 'sse';
116
- command?: string;
117
- args?: string[];
118
- env?: Record<string, string>;
119
- url?: string;
120
- }>;
113
+ // NOTE: a previous `mcpServers` declaration ("Memorix as an MCP hub") was
114
+ // removed — no code ever consumed it. Unknown top-level keys are ignored,
115
+ // so existing files with the old key keep loading without error.
121
116
  }
122
117
 
123
118
  // ─── Loader ──────────────────────────────────────────────────────────
package/src/config.ts CHANGED
@@ -120,10 +120,5 @@ export function getServerConfig(): NonNullable<MemorixYamlConfig['server']> {
120
120
  };
121
121
  }
122
122
 
123
- /** Team config */
124
- export function getTeamConfig(): NonNullable<MemorixYamlConfig['team']> {
125
- return loadYamlConfig().team ?? {};
126
- }
127
-
128
123
  /** Get the full resolved YAML config (for status display) */
129
124
  export { loadYamlConfig } from './config/yaml-loader.js';
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Session-replay evaluation — deterministic scoring for the retrieval path.
3
+ *
4
+ * Purpose: before changing how Memorix retrieves memory, measure the current
5
+ * behavior with a fixed exam so every later change can be judged by the same
6
+ * three scores:
7
+ *
8
+ * - hit rate — the right memory showed up for each question
9
+ * - duplicate rate — how much of a session's delivery re-shows the same
10
+ * memory (lower is better; the dedup change targets it)
11
+ * - token ceiling — no turn exceeds its budget
12
+ *
13
+ * Scoring is mechanical: ids in, ids out. No model, no network, no keys.
14
+ */
15
+
16
+ export interface SessionReplayQuestion {
17
+ id: string;
18
+ query: string;
19
+ /** The observation ids that MUST appear in this turn's results. */
20
+ expectedObsIds: number[];
21
+ }
22
+
23
+ export interface SessionReplayTurn {
24
+ questionId: string;
25
+ shownObsIds: number[];
26
+ tokens: number;
27
+ }
28
+
29
+ export interface SessionReplayScores {
30
+ questions: number;
31
+ hits: number;
32
+ /** hitRate ∈ [0, 1] — share of questions whose expected ids all appeared. */
33
+ hitRate: number;
34
+ /** How many times an observation was re-shown after its first appearance. */
35
+ duplicateShows: number;
36
+ /** duplicateRate ∈ [0, 1] — duplicate shows / total shows (0 = perfect). */
37
+ duplicateRate: number;
38
+ totalShows: number;
39
+ /** Highest token count across turns. */
40
+ maxTurnTokens: number;
41
+ overBudgetTurns: number;
42
+ turns: SessionReplayTurn[];
43
+ }
44
+
45
+ export function scoreSessionReplay(
46
+ questions: SessionReplayQuestion[],
47
+ turns: SessionReplayTurn[],
48
+ tokenCeiling: number,
49
+ ): SessionReplayScores {
50
+ const turnById = new Map(turns.map((turn) => [turn.questionId, turn]));
51
+
52
+ let hits = 0;
53
+ let duplicateShows = 0;
54
+ let totalShows = 0;
55
+ let maxTurnTokens = 0;
56
+ let overBudgetTurns = 0;
57
+ const seen = new Set<number>();
58
+
59
+ for (const question of questions) {
60
+ const turn = turnById.get(question.id);
61
+ if (!turn) continue;
62
+ maxTurnTokens = Math.max(maxTurnTokens, turn.tokens);
63
+ if (turn.tokens > tokenCeiling) overBudgetTurns += 1;
64
+
65
+ const expectedFound = question.expectedObsIds.every((id) => turn.shownObsIds.includes(id));
66
+ if (expectedFound) hits += 1;
67
+
68
+ for (const id of turn.shownObsIds) {
69
+ totalShows += 1;
70
+ if (seen.has(id)) duplicateShows += 1;
71
+ seen.add(id);
72
+ }
73
+ }
74
+
75
+ return {
76
+ questions: questions.length,
77
+ hits,
78
+ hitRate: questions.length === 0 ? 1 : hits / questions.length,
79
+ duplicateShows,
80
+ duplicateRate: totalShows === 0 ? 0 : duplicateShows / totalShows,
81
+ totalShows,
82
+ maxTurnTokens,
83
+ overBudgetTurns,
84
+ turns,
85
+ };
86
+ }
87
+
88
+ export function formatSessionReplayScoreboard(scores: SessionReplayScores, label: string): string {
89
+ const lines = [
90
+ `Session replay scoreboard — ${label}`,
91
+ ` questions: ${scores.questions}`,
92
+ ` hit rate: ${scores.hits}/${scores.questions} (${(scores.hitRate * 100).toFixed(0)}%)`,
93
+ ` duplicate shows: ${scores.duplicateShows} of ${scores.totalShows} shows (${(scores.duplicateRate * 100).toFixed(0)}%)`,
94
+ ` max turn tokens: ${scores.maxTurnTokens}`,
95
+ ` over-budget: ${scores.overBudgetTurns} turn(s)`,
96
+ ];
97
+ return lines.join('\n');
98
+ }
@@ -61,8 +61,10 @@ function normalizeCommitCount(count: number): number {
61
61
 
62
62
  /**
63
63
  * Get commit info by hash (or HEAD).
64
+ * `maxDiffChars` caps how much diff content is captured (default 500).
64
65
  */
65
- export function getCommitInfo(cwd: string, ref = 'HEAD'): CommitInfo {
66
+ export function getCommitInfo(cwd: string, ref = 'HEAD', maxDiffChars = 500): CommitInfo {
67
+ const cap = Number.isFinite(maxDiffChars) ? Math.max(0, Math.floor(maxDiffChars)) : 500;
66
68
  const FORMAT = '%H%n%h%n%aN%n%aI%n%s%n%b';
67
69
  const revision = normalizeCommitRef(ref);
68
70
  const raw = runGit(cwd, ['log', '-1', `--format=${FORMAT}`, '--end-of-options', revision]).trim();
@@ -93,11 +95,11 @@ export function getCommitInfo(cwd: string, ref = 'HEAD'): CommitInfo {
93
95
  }
94
96
  }
95
97
 
96
- // Get short diff summary (first 500 chars of diff)
98
+ // Get short diff summary, capped at the configured diff-size budget.
97
99
  let diffSummary = '';
98
100
  try {
99
101
  const diff = runGit(cwd, ['diff-tree', '-p', '--no-commit-id', hash], 1024 * 100);
100
- diffSummary = diff.substring(0, 500);
102
+ diffSummary = diff.substring(0, cap);
101
103
  } catch { /* diff may be too large */ }
102
104
 
103
105
  return {
@@ -360,6 +360,9 @@ const AGENT_SKILL_DIRS: Partial<Record<AgentName, { project: string; global?: st
360
360
  kiro: { project: path.join('.kiro', 'skills'), global: path.join('.kiro', 'skills') },
361
361
  opencode: { project: path.join('.opencode', 'skills'), global: path.join('.config', 'opencode', 'skills') },
362
362
  trae: { project: path.join('.trae', 'skills'), global: path.join('.trae', 'skills') },
363
+ // DSH's user skill root is <harness home>/skills; the global root below is
364
+ // resolved to $DSH_HOME (or ~/.dsh) for dsh by installOfficialSkillsForAgent.
365
+ dsh: { project: path.join('.dsh', 'skills'), global: 'skills' },
363
366
  };
364
367
 
365
368
  const PACKAGE_OWNED_HOOK_AGENTS = new Set<AgentName>(['openclaw', 'hermes', 'omp']);
@@ -384,7 +387,11 @@ async function installOfficialSkillsForAgent(
384
387
  const relativeDir = global ? dirs?.global : dirs?.project;
385
388
  if (!relativeDir) return [];
386
389
 
387
- const root = global ? os.homedir() : projectRoot;
390
+ const root = global
391
+ ? (agent === 'dsh'
392
+ ? (process.env.DSH_HOME?.trim() || path.join(os.homedir(), '.dsh'))
393
+ : os.homedir())
394
+ : projectRoot;
388
395
  const skillPaths: string[] = [];
389
396
  for (const skillEntry of OFFICIAL_MEMORIX_SKILLS) {
390
397
  const skillPath = path.join(root, relativeDir, skillEntry.name, 'SKILL.md');
@@ -626,6 +633,9 @@ export function getProjectConfigPath(agent: AgentName, projectRoot: string): str
626
633
  case 'trae':
627
634
  // Trae has no hooks system — only rules (.trae/rules/project_rules.md)
628
635
  return path.join(projectRoot, '.trae', 'rules', 'project_rules.md');
636
+ case 'dsh':
637
+ // DeepSeek Harness has no hooks system — only guidance (AGENTS.md)
638
+ return path.join(projectRoot, 'AGENTS.md');
629
639
  case 'opencode':
630
640
  // OpenCode uses plugin files for hooks
631
641
  return path.join(projectRoot, '.opencode', 'plugins', 'memorix.js');
@@ -684,6 +694,9 @@ export function getGlobalConfigPath(agent: AgentName): string {
684
694
  return path.join(home, '.omp', 'agent', 'packages', 'memorix', 'extensions', 'memorix.js');
685
695
  case 'trae':
686
696
  return path.join(home, '.trae', 'rules', 'project_rules.md');
697
+ case 'dsh':
698
+ // DSH reads the user-global AGENTS.md from its harness home.
699
+ return path.join(process.env.DSH_HOME?.trim() || path.join(home, '.dsh'), 'AGENTS.md');
687
700
  default:
688
701
  return path.join(home, '.memorix', 'hooks.json');
689
702
  }
@@ -709,6 +722,9 @@ export function getAgentRulesPath(agent: AgentName, root: string, global = false
709
722
  return path.join(root, '.gemini', 'GEMINI.md');
710
723
  case 'trae':
711
724
  return path.join(root, '.trae', 'rules', 'project_rules.md');
725
+ case 'dsh':
726
+ // The user-global AGENTS.md lives in the harness home DSH reads.
727
+ return path.join(process.env.DSH_HOME?.trim() || path.join(root, '.dsh'), 'AGENTS.md');
712
728
  default:
713
729
  return path.join(root, '.agent', 'rules', 'memorix.md');
714
730
  }
@@ -734,6 +750,8 @@ export function getAgentRulesPath(agent: AgentName, root: string, global = false
734
750
  return path.join(root, 'GEMINI.md');
735
751
  case 'trae':
736
752
  return path.join(root, '.trae', 'rules', 'project_rules.md');
753
+ case 'dsh':
754
+ return path.join(root, 'AGENTS.md');
737
755
  default:
738
756
  return path.join(root, '.agent', 'rules', 'memorix.md');
739
757
  }
@@ -842,6 +860,13 @@ export async function detectInstalledAgents(): Promise<AgentName[]> {
842
860
  agents.push('trae');
843
861
  } catch { /* not installed */ }
844
862
 
863
+ // Check for DeepSeek Harness (its home dir or an explicit DSH_HOME)
864
+ const dshDir = process.env.DSH_HOME?.trim() || path.join(home, '.dsh');
865
+ try {
866
+ await fs.access(dshDir);
867
+ agents.push('dsh');
868
+ } catch { /* not installed */ }
869
+
845
870
  return agents;
846
871
  }
847
872
 
@@ -947,6 +972,22 @@ export async function installHooks(
947
972
  generated: { note: 'Trae has no hooks system, only rules (.trae/rules/project_rules.md) installed' },
948
973
  };
949
974
  }
975
+ case 'dsh':
976
+ // DeepSeek Harness has no hook system — install AGENTS.md guidance and
977
+ // skills; the MCP row is installed by `memorix setup --agent dsh`.
978
+ {
979
+ const rulesPath = await installAgentRules(agent, projectRoot, global);
980
+ const skillPaths = await installOfficialSkillsForAgent(agent, projectRoot, global);
981
+ return {
982
+ agent,
983
+ configPath: rulesPath,
984
+ events: [],
985
+ generated: {
986
+ note: 'DeepSeek Harness has no hook system — installed AGENTS.md guidance and skills; the MCP row is installed by `memorix setup --agent dsh`.',
987
+ ...(skillPaths.length > 0 ? { skillPaths, skillPath: skillPaths[0] } : {}),
988
+ },
989
+ };
990
+ }
950
991
  case 'opencode': {
951
992
  // OpenCode uses JS plugin files for hooks
952
993
  const pluginContent = generateOpenCodePlugin();
@@ -1127,7 +1168,7 @@ async function installAgentRules(agent: AgentName, projectRoot: string, global =
1127
1168
  try {
1128
1169
  await fs.mkdir(path.dirname(rulesPath), { recursive: true });
1129
1170
 
1130
- if (agent === 'claude' || agent === 'codex' || agent === 'opencode' || agent === 'antigravity' || agent === 'gemini-cli') {
1171
+ if (agent === 'claude' || agent === 'codex' || agent === 'opencode' || agent === 'antigravity' || agent === 'gemini-cli' || agent === 'dsh') {
1131
1172
  // For shared context files (CLAUDE.md / AGENTS.md / GEMINI.md), append rather than overwrite.
1132
1173
  try {
1133
1174
  const existing = await fs.readFile(rulesPath, 'utf-8');
@@ -1255,13 +1296,28 @@ function getAgentRulesContent(agent?: AgentName, scope: 'project' | 'global' = '
1255
1296
  '- For "why" decisions, use \x60memorix_store_reasoning\x60',
1256
1297
  '- For a stable fact, reusable procedure, or completed episode that merits deliberate long-term review, include \x60longTerm\x60 in \x60memorix_store\x60 with the appropriate kind and normally \x60scope: "project"\x60. It creates a candidate only: do not use it for routine updates, do not make project-derived evidence portable user memory, and do not assume it enters context until an operator qualifies and approves it through \x60memorix memory long-term\x60.',
1257
1298
  '- A \x60user\x60 + \x60portable\x60 durable memory delivered in a task brief is intentionally available across projects. When it matches the task, use it as reusable background even if its origin differs; do not treat it as a current-project fact. Expand it only when needed with \x60memorix_detail\x60 using its \x60durable:<id>\x60 reference and a specific purpose.',
1299
+ '- Record the user profile: the user\u2019s role, expertise, preferences, and goals. Save these with \x60entityName: "user-profile"\x60 and \x60visibility: "personal"\x60 so they stay private and appear in every brief as the "who you are" context.',
1258
1300
  '',
1259
1301
  "**Don't store:** greetings, simple file reads, trivial commands (ls, pwd, git status).",
1260
1302
  '',
1303
+ "**Only store what a future session cannot re-derive.** Code structure, file contents, and Git history are live in the checkout — do not store facts already visible there. A memory earns its place by capturing the why, the context, or a conclusion the checkout alone cannot show.",
1304
+ '',
1305
+ "**Record what worked, not only what failed.** Store validated approaches and explicit user confirmations alongside corrections. Saving only failures drifts behavior away from what the user already accepted; a clear \"yes, that's right\" is feedback worth keeping too.",
1306
+ '',
1307
+ '**Recalled memory is a claim about the past.** A memory naming a specific file, function, or flag describes the past at write time — check the file exists or grep the symbol before recommending it. If the user says to ignore or not use memory, proceed as if memory were empty: do not apply, cite, compare, or mention stored content.',
1308
+ '',
1261
1309
  '## When to resolve memory',
1262
1310
  '',
1263
1311
  'Use \x60memorix_resolve\x60 when a task is done or a bug is fixed. This keeps future searches focused on active work instead of surfacing completed items.',
1264
1312
  '',
1313
+ '## End sessions with a summary',
1314
+ '',
1315
+ 'When a session finishes, call \x60memorix_session_end\x60 with a short structured summary so the next agent can resume. Recommended sections:',
1316
+ '- **Goal** — what this session was working on',
1317
+ '- **Discoveries** — findings, gotchas, learnings',
1318
+ '- **Accomplished** — completed items, plus PENDING items for the next session',
1319
+ '- **Relevant Files** — paths and what changed',
1320
+ '',
1265
1321
  '## Tools quick reference',
1266
1322
  '',
1267
1323
  '| Tool | Use when |',
@@ -41,6 +41,7 @@ export type AgentName =
41
41
  | 'hermes'
42
42
  | 'opencode'
43
43
  | 'trae'
44
+ | 'dsh'
44
45
  | 'pi'
45
46
  | 'omp';
46
47
 
@@ -61,6 +62,7 @@ export const AGENT_SUPPORT_TIER: Record<AgentName, SupportTier> = {
61
62
  hermes: 'community',
62
63
  opencode: 'community',
63
64
  trae: 'community',
65
+ dsh: 'community',
64
66
  pi: 'community',
65
67
  omp: 'community',
66
68
  antigravity: 'community',
@@ -150,6 +150,15 @@ export const MEMORIX_INTEGRATIONS: MemorixIntegration[] = [
150
150
  install: 'memorix setup --agent trae',
151
151
  surfaces: ['MCP', 'rules'],
152
152
  },
153
+ {
154
+ agent: 'dsh',
155
+ name: 'DeepSeek Harness',
156
+ entry: 'official-config',
157
+ status: 'ready',
158
+ install: 'memorix setup --agent dsh',
159
+ surfaces: ['MCP', 'AGENTS.md', 'skills'],
160
+ note: 'DeepSeek Harness support writes a @deepseek-ai/dsh-mcp-client row into $DSH_HOME/cordis.patch.yml (default ~/.dsh/cordis.patch.yml), guidance into the harness AGENTS.md, and skills under $DSH_HOME/skills.',
161
+ },
153
162
  {
154
163
  agent: 'memcode',
155
164
  name: 'memcode',
@@ -136,6 +136,12 @@ export interface TaskWorkset {
136
136
  version: '1.3';
137
137
  task: string;
138
138
  lens: string;
139
+ /** Always-on "who you are and what this workspace is doing" block. */
140
+ alwaysOn?: {
141
+ profile: string[];
142
+ state?: string;
143
+ durable: string[];
144
+ };
139
145
  currentFacts: string[];
140
146
  continuation?: WorksetContinuation;
141
147
  codeState?: string;
@@ -197,6 +203,12 @@ export interface BuildTaskWorksetInput {
197
203
  stale: number;
198
204
  };
199
205
  runtimeCautions?: WorksetCaution[];
206
+ /** Always-on "who you are and what this workspace is doing" lines. */
207
+ alwaysOn?: {
208
+ profile: string[];
209
+ state?: string;
210
+ durable: string[];
211
+ };
200
212
  /** Reader identity used only for team-scoped durable memory filtering. */
201
213
  reader?: ObservationReader;
202
214
  maxTokens?: number;
@@ -478,6 +490,44 @@ export function renderTaskWorksetPrompt(input: Omit<TaskWorkset, 'prompt' | 'bud
478
490
  });
479
491
  appendLine(lines, 'Task lens: ' + input.lens, maxTokens, omitted, 'lens');
480
492
 
493
+ // Always-on block: the small "who you are and what this workspace is
494
+ // doing" context every brief carries, mirroring the memory-native feel of
495
+ // a per-session MEMORY.md. Lines are pre-truncated by the caller; the
496
+ // enclosing budget still decides whether they fit.
497
+ if (input.alwaysOn) {
498
+ const hasAlwaysOn = input.alwaysOn.profile.length > 0
499
+ || Boolean(input.alwaysOn.state)
500
+ || input.alwaysOn.durable.length > 0;
501
+ if (hasAlwaysOn) {
502
+ appendLine(lines, '', maxTokens, omitted, 'always-on-heading');
503
+ appendLine(lines, 'You and this workspace', maxTokens, omitted, 'always-on-heading');
504
+ for (const profile of input.alwaysOn.profile.slice(0, 2)) {
505
+ appendLine(lines, '- ' + short(profile, 26), maxTokens, omitted, 'always-on-profile', selected, {
506
+ kind: 'memory',
507
+ id: 'profile',
508
+ reason: 'user profile memory',
509
+ trust: 'source-backed',
510
+ });
511
+ }
512
+ if (input.alwaysOn.state) {
513
+ appendLine(lines, '- Recently: ' + short(input.alwaysOn.state, 40), maxTokens, omitted, 'always-on-state', selected, {
514
+ kind: 'continuation',
515
+ id: 'workspace-state',
516
+ reason: 'latest session summary for this workspace',
517
+ trust: 'historical',
518
+ });
519
+ }
520
+ for (const durable of input.alwaysOn.durable.slice(0, 2)) {
521
+ appendLine(lines, '- ' + short(durable, 28), maxTokens, omitted, 'always-on-durable', selected, {
522
+ kind: 'durable-memory',
523
+ id: 'durable-always',
524
+ reason: 'recent durable memory for this workspace',
525
+ trust: 'source-backed',
526
+ });
527
+ }
528
+ }
529
+ }
530
+
481
531
  const hasContinuation = Boolean(
482
532
  input.continuation?.previousSession
483
533
  || (input.continuation?.memories.length ?? 0) > 0
@@ -1011,6 +1061,14 @@ export async function buildTaskWorkset(input: BuildTaskWorksetInput): Promise<Ta
1011
1061
  version: '1.3' as const,
1012
1062
  task,
1013
1063
  lens: input.lens,
1064
+ ...(input.alwaysOn
1065
+ && (
1066
+ input.alwaysOn.profile.length > 0
1067
+ || input.alwaysOn.state
1068
+ || input.alwaysOn.durable.length > 0
1069
+ )
1070
+ ? { alwaysOn: input.alwaysOn }
1071
+ : {}),
1014
1072
  currentFacts: input.currentFacts?.map(fact => fact.startsWith('Historical note:')
1015
1073
  ? short(fact, 48)
1016
1074
  : short(fact, 28)).slice(0, 4) ?? [],
@@ -511,13 +511,108 @@ export async function supersedeLongTermMemory(input: {
511
511
  });
512
512
  }
513
513
 
514
+ /**
515
+ * Rule-leg auto qualification: an explicitly requested candidate carries its
516
+ * own source evidence, so it qualifies immediately instead of waiting for a
517
+ * manual CLI review. Hook-captured and git-derived candidates keep the
518
+ * manual review path. The LLM lane (formation) may enrich evaluation on top
519
+ * of this deterministic rule; no LLM is required for it to work.
520
+ */
521
+ export async function maybeAutoQualifyLongTermMemory(input: {
522
+ dataDir: string;
523
+ id: string;
524
+ source?: 'agent' | 'git' | 'manual';
525
+ sourceDetail?: 'explicit' | 'hook' | 'git-ingest';
526
+ }): Promise<LongTermMemory | null> {
527
+ if (input.source !== 'manual' && input.sourceDetail !== 'explicit') return null;
528
+ try {
529
+ return await qualifyLongTermMemory({
530
+ dataDir: input.dataDir,
531
+ id: input.id,
532
+ reason: 'auto-qualified: explicit request carries its own source evidence',
533
+ });
534
+ } catch {
535
+ // Already beyond candidate, or the record no longer exists.
536
+ return null;
537
+ }
538
+ }
539
+
540
+ export interface LongTermMaintenanceResult {
541
+ archived: number;
542
+ superseded: number;
543
+ }
544
+
545
+ /**
546
+ * Rule-leg self maintenance: stale active records archive themselves, and a
547
+ * newer record supersedes older ones sharing the same title within the same
548
+ * scope and kind. Deterministic and offline; the optional LLM lane can layer
549
+ * semantic consolidation on top later.
550
+ */
551
+ export async function maintainLongTermMemories(input: {
552
+ dataDir: string;
553
+ projectId: string;
554
+ now?: Date;
555
+ decayDays?: number;
556
+ }): Promise<LongTermMaintenanceResult> {
557
+ const now = input.now ?? new Date();
558
+ const decayDays = input.decayDays ?? 30;
559
+ const cutoff = now.getTime() - decayDays * 24 * 60 * 60 * 1000;
560
+ const result: LongTermMaintenanceResult = { archived: 0, superseded: 0 };
561
+ const store = await createStore(input.dataDir);
562
+ const all = store.list({ originProjectId: input.projectId });
563
+
564
+ // Decay: qualified/approved records with no read or write activity since
565
+ // the cutoff archive themselves.
566
+ for (const memory of all) {
567
+ if (memory.state !== 'qualified' && memory.state !== 'approved') continue;
568
+ const lastActivity = Date.parse(memory.lastAccessedAt ?? memory.updatedAt);
569
+ if (!Number.isFinite(lastActivity) || lastActivity >= cutoff) continue;
570
+ try {
571
+ await archiveLongTermMemory({
572
+ dataDir: input.dataDir,
573
+ id: memory.id,
574
+ reason: 'auto-archive: no activity since ' + new Date(lastActivity).toISOString().slice(0, 10),
575
+ });
576
+ result.archived += 1;
577
+ } catch { /* already inactive */ }
578
+ }
579
+
580
+ // Supersede: within one scope and kind, records sharing a title keep only
581
+ // the newest; the rest are retired with a traceable supersededBy link.
582
+ const groups = new Map<string, LongTermMemory[]>();
583
+ for (const memory of store.list({ originProjectId: input.projectId })) {
584
+ if (memory.state !== 'qualified' && memory.state !== 'approved') continue;
585
+ const key = memory.scope + '\0' + memory.kind + '\0' + memory.title.trim().toLowerCase();
586
+ const group = groups.get(key) ?? [];
587
+ group.push(memory);
588
+ groups.set(key, group);
589
+ }
590
+ for (const group of groups.values()) {
591
+ if (group.length < 2) continue;
592
+ group.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
593
+ const newest = group[0];
594
+ for (const older of group.slice(1)) {
595
+ try {
596
+ await supersedeLongTermMemory({
597
+ dataDir: input.dataDir,
598
+ id: older.id,
599
+ supersededBy: newest.id,
600
+ reason: 'auto-supersede: a newer record shares the same title',
601
+ });
602
+ result.superseded += 1;
603
+ } catch { /* scope or owner boundary, or already inactive */ }
604
+ }
605
+ }
606
+
607
+ return result;
608
+ }
609
+
514
610
  export async function listLongTermMemories(input: {
515
611
  dataDir: string;
516
612
  reader: LongTermMemoryReader;
517
613
  includeInactive?: boolean;
518
614
  limit?: number;
519
- }): Promise<Array<{ memory: LongTermMemory; evidence: LongTermMemoryEvidence[] }>> {
520
- const store = await createStore(input.dataDir);
615
+ }): Promise<Array<{ memory: LongTermMemory; evidence: LongTermMemoryEvidence[] }>> { const store = await createStore(input.dataDir);
521
616
  return store.list({ limit: input.limit ?? 100 })
522
617
  .filter(memory => canReadLongTermMemory(memory, input.reader))
523
618
  .filter(memory => input.includeInactive || memory.state !== 'archived' && memory.state !== 'superseded')
@@ -19,6 +19,7 @@ export const CONTROL_PLANE_MAINTENANCE_KINDS: readonly MaintenanceJobKind[] = [
19
19
  'knowledge-compile',
20
20
  'knowledge-lint',
21
21
  'workflow-index',
22
+ 'long-term-maintenance',
22
23
  ];
23
24
 
24
25
  const TARGET_WAIT_RETRY_MS = 30_000;
@@ -20,6 +20,7 @@ export const ISOLATED_MAINTENANCE_JOB_KINDS: readonly MaintenanceJobKind[] = [
20
20
  'knowledge-compile',
21
21
  'knowledge-lint',
22
22
  'workflow-index',
23
+ 'long-term-maintenance',
23
24
  ];
24
25
  const DEFAULT_TIMEOUT_MS = 5 * 60_000;
25
26
  const MAX_CHILD_OUTPUT_BYTES = 1_000_000;