memorix 1.2.3 → 1.2.5

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 (52) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +3 -3
  3. package/README.zh-CN.md +3 -3
  4. package/dist/cli/index.js +5273 -4730
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/index.js +506 -99
  7. package/dist/index.js.map +1 -1
  8. package/dist/maintenance-runner.js +176 -53
  9. package/dist/maintenance-runner.js.map +1 -1
  10. package/dist/memcode-runtime/CHANGELOG.md +33 -0
  11. package/dist/sdk.d.ts +4 -2
  12. package/dist/sdk.js +507 -99
  13. package/dist/sdk.js.map +1 -1
  14. package/dist/types.d.ts +8 -1
  15. package/dist/types.js.map +1 -1
  16. package/docs/1.2.4-PERSISTENT-MEMORY-DELIVERY.md +86 -0
  17. package/docs/1.2.5-RETRIEVAL-PERFORMANCE.md +85 -0
  18. package/docs/AGENT_OPERATOR_PLAYBOOK.md +13 -1
  19. package/docs/API_REFERENCE.md +13 -3
  20. package/docs/PERFORMANCE.md +22 -1
  21. package/docs/dev-log/progress.txt +73 -7
  22. package/package.json +2 -1
  23. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  24. package/src/cli/capability-map.ts +1 -1
  25. package/src/cli/command-guide.ts +4 -1
  26. package/src/cli/commands/agent-integrations.ts +5 -1
  27. package/src/cli/commands/codegraph.ts +1 -1
  28. package/src/cli/commands/context.ts +9 -1
  29. package/src/cli/commands/memory.ts +12 -3
  30. package/src/cli/commands/operator-shared.ts +74 -2
  31. package/src/cli/commands/resume.ts +31 -0
  32. package/src/cli/index.ts +5 -1
  33. package/src/codegraph/auto-context.ts +54 -1
  34. package/src/codegraph/task-lens.ts +29 -0
  35. package/src/compact/token-budget.ts +16 -1
  36. package/src/config/resolved-config.ts +29 -4
  37. package/src/config/toml-loader.ts +9 -5
  38. package/src/hooks/handler.ts +127 -66
  39. package/src/hooks/installers/index.ts +5 -4
  40. package/src/hooks/official-skills.ts +6 -4
  41. package/src/hooks/rules/memorix-agent-rules.md +9 -7
  42. package/src/knowledge/context-assembly.ts +4 -1
  43. package/src/knowledge/workset.ts +89 -1
  44. package/src/memory/session.ts +144 -10
  45. package/src/runtime/project-maintenance.ts +1 -14
  46. package/src/sdk.ts +4 -0
  47. package/src/server.ts +144 -25
  48. package/src/store/bun-sqlite-compat.ts +118 -15
  49. package/src/store/orama-store.ts +39 -18
  50. package/src/store/sqlite-db.ts +3 -3
  51. package/src/timeout.ts +23 -0
  52. package/src/types.ts +8 -0
@@ -22,6 +22,17 @@ import { redactCredentials, sanitizeCredentials } from './secret-filter.js';
22
22
  import { canReadObservation } from './visibility.js';
23
23
 
24
24
  const PRIORITY_TYPES = new Set(['gotcha', 'decision', 'problem-solution', 'trade-off', 'discovery']);
25
+ const RESUME_TYPES = new Set([
26
+ 'gotcha',
27
+ 'decision',
28
+ 'problem-solution',
29
+ 'trade-off',
30
+ 'discovery',
31
+ 'how-it-works',
32
+ 'what-changed',
33
+ 'reasoning',
34
+ 'session-request',
35
+ ]);
25
36
  const TYPE_EMOJI: Record<string, string> = {
26
37
  'gotcha': '[DISCOVERY]',
27
38
  'decision': '[WHY]',
@@ -178,6 +189,138 @@ function isSystemSelfObservation(obs: Observation): boolean {
178
189
  return SYSTEM_SELF_PATTERNS.some((pattern) => pattern.test(text));
179
190
  }
180
191
 
192
+ export interface SessionResumeMemory {
193
+ id: number;
194
+ title: string;
195
+ type: string;
196
+ detail?: string;
197
+ }
198
+
199
+ /** A compact, source-aware view of prior work for a task continuation. */
200
+ export interface SessionResumeBrief {
201
+ previousSession?: {
202
+ id: string;
203
+ agent?: string;
204
+ endedAt?: string;
205
+ summary: string;
206
+ };
207
+ memories: SessionResumeMemory[];
208
+ }
209
+
210
+ function readerForAlias(
211
+ reader: ObservationReader | undefined,
212
+ aliases: Set<string>,
213
+ observation: Observation,
214
+ ): ObservationReader | undefined {
215
+ if (!reader) return undefined;
216
+ return reader.projectId && aliases.has(observation.projectId)
217
+ ? { ...reader, projectId: observation.projectId }
218
+ : reader;
219
+ }
220
+
221
+ function readableAliasObservations(
222
+ observations: Observation[],
223
+ aliases: Set<string>,
224
+ reader?: ObservationReader,
225
+ ): Observation[] {
226
+ return reader
227
+ ? observations.filter((observation) => canReadObservation(
228
+ observation,
229
+ readerForAlias(reader, aliases, observation),
230
+ ))
231
+ : observations;
232
+ }
233
+
234
+ function isUsefulSessionSummary(summary: string | undefined): summary is string {
235
+ return Boolean(summary)
236
+ && !NOISE_PATTERNS.some((pattern) => pattern.test(summary!))
237
+ && !SYSTEM_SELF_PATTERNS.some((pattern) => pattern.test(summary!));
238
+ }
239
+
240
+ function continuationTaskTokens(task?: string): string[] {
241
+ return [...new Set(
242
+ (task?.toLowerCase().match(/[a-z0-9_./-]+|[\u4e00-\u9fff]+/g) ?? [])
243
+ .map((token) => token.trim())
244
+ .filter((token) => token.length > 1 && !['continue', 'resume', '继续', '接手', '恢复'].includes(token)),
245
+ )].slice(0, 8);
246
+ }
247
+
248
+ function continuationScore(observation: Observation, projectTokens: string[], task?: string): number {
249
+ let score = scoreObservationForSessionContext(observation, projectTokens);
250
+ if (observation.type === 'session-request' || observation.type === 'what-changed') score += 1;
251
+ const text = stringifyObservation(observation);
252
+ const matches = continuationTaskTokens(task).filter((token) => text.includes(token)).length;
253
+ score += Math.min(matches, 2) * 2;
254
+ return score;
255
+ }
256
+
257
+ /**
258
+ * Return only the latest meaningful session summary and a few durable memory
259
+ * anchors. It is deliberately separate from the verbose session-context view:
260
+ * this is a delivery projection for one bounded Task Workset, not another
261
+ * storage model or an automatic transcript dump.
262
+ */
263
+ export async function getSessionResumeBrief(
264
+ projectId: string,
265
+ task?: string,
266
+ reader?: ObservationReader,
267
+ ): Promise<SessionResumeBrief> {
268
+ const aliasSet = await resolveProjectIds(projectId);
269
+ const [sessions, allObs] = await Promise.all([
270
+ loadAliasSessions(aliasSet),
271
+ loadAliasActiveObservations(aliasSet),
272
+ ]);
273
+ const readableObs = readableAliasObservations(allObs, aliasSet, reader);
274
+ const previous = sessions
275
+ .filter((session) => session.status === 'completed')
276
+ .sort((a, b) => new Date(b.endedAt || b.startedAt).getTime() - new Date(a.endedAt || a.startedAt).getTime())
277
+ .find((session) => (
278
+ session.summary
279
+ && session.summary !== '(session ended implicitly by new session start)'
280
+ && isUsefulSessionSummary(session.summary)
281
+ ));
282
+
283
+ const projectTokens = tokenizeProjectId(projectId);
284
+ const memories = readableObs
285
+ .filter((observation) => RESUME_TYPES.has(observation.type))
286
+ .filter((observation) => classifyLayer(observation) === 'L2')
287
+ .filter((observation) => !isNoiseObservation(observation) && !isSystemSelfObservation(observation))
288
+ .map((observation) => ({
289
+ observation,
290
+ score: continuationScore(observation, projectTokens, task),
291
+ }))
292
+ .sort((a, b) => (
293
+ b.score - a.score
294
+ || new Date(b.observation.createdAt).getTime() - new Date(a.observation.createdAt).getTime()
295
+ || a.observation.id - b.observation.id
296
+ ))
297
+ .slice(0, 3)
298
+ .map(({ observation }) => ({
299
+ id: observation.id,
300
+ title: sanitizeCredentials(observation.title),
301
+ type: observation.type,
302
+ ...(observation.facts?.[0]
303
+ ? { detail: sanitizeCredentials(observation.facts[0]) }
304
+ : observation.narrative
305
+ ? { detail: sanitizeCredentials(observation.narrative) }
306
+ : {}),
307
+ }));
308
+
309
+ return {
310
+ ...(previous?.summary
311
+ ? {
312
+ previousSession: {
313
+ id: previous.id,
314
+ ...(previous.agent ? { agent: previous.agent } : {}),
315
+ ...(previous.endedAt ? { endedAt: previous.endedAt } : {}),
316
+ summary: sanitizeCredentials(previous.summary),
317
+ },
318
+ }
319
+ : {}),
320
+ memories,
321
+ };
322
+ }
323
+
181
324
  export function scoreObservationForSessionContext(obs: Observation, projectTokens: string[], now = Date.now()): number {
182
325
  let score = TYPE_WEIGHTS[obs.type] ?? 1;
183
326
  const text = stringifyObservation(obs);
@@ -322,16 +465,7 @@ export async function getSessionContext(
322
465
  loadAliasSessions(aliasSet),
323
466
  loadAliasActiveObservations(aliasSet),
324
467
  ]);
325
- const readableObs = reader
326
- ? allObs.filter((observation) => {
327
- // Aliases represent one project across moved/renamed worktrees. Preserve
328
- // that project equivalence while still enforcing the caller's identity.
329
- const observationReader = reader.projectId && aliasSet.has(observation.projectId)
330
- ? { ...reader, projectId: observation.projectId }
331
- : reader;
332
- return canReadObservation(observation, observationReader);
333
- })
334
- : allObs;
468
+ const readableObs = readableAliasObservations(allObs, aliasSet, reader);
335
469
  /** Check if a session summary contains noise/system-self content */
336
470
  const isNoisySummary = (summary: string | undefined): boolean => {
337
471
  if (!summary) return false;
@@ -9,6 +9,7 @@ import {
9
9
  enqueueObservationQualification,
10
10
  type MaintenanceQueue,
11
11
  } from './lifecycle.js';
12
+ import { withTimeout } from '../timeout.js';
12
13
 
13
14
  const DEFAULT_VECTOR_BATCH_SIZE = 12;
14
15
  const DEFAULT_RETENTION_BATCH_SIZE = 100;
@@ -110,20 +111,6 @@ async function loadWorkspaceForMaintenance(
110
111
  return versioned ?? local;
111
112
  }
112
113
 
113
- /**
114
- * Creates handlers for one initialized project runtime. The worker itself is
115
- * project-scoped so it never claims a different project's queued work.
116
- */
117
- function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
118
- return new Promise<T>((resolve, reject) => {
119
- const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
120
- promise.then(
121
- (value) => { clearTimeout(timer); resolve(value); },
122
- (error) => { clearTimeout(timer); reject(error); },
123
- );
124
- });
125
- }
126
-
127
114
  async function runAutomaticConsolidation(
128
115
  projectId: string,
129
116
  projectDir: string,
package/src/sdk.ts CHANGED
@@ -26,6 +26,7 @@ import type {
26
26
  ProjectInfo,
27
27
  DetectionResult,
28
28
  ObservationReader,
29
+ RetrievalQuality,
29
30
  } from './types.js';
30
31
  import {
31
32
  canManageObservation,
@@ -90,6 +91,8 @@ export interface ClientSearchOptions {
90
91
  status?: ObservationStatus | 'all';
91
92
  /** Maximum results. Default: 20 */
92
93
  limit?: number;
94
+ /** Retrieval profile. Default: balanced. */
95
+ quality?: RetrievalQuality;
93
96
  }
94
97
 
95
98
  /** Result from resolving observations */
@@ -220,6 +223,7 @@ export class MemoryClient {
220
223
  type: options.type,
221
224
  source: options.source,
222
225
  status: options.status === 'all' ? undefined : (options.status ?? 'active'),
226
+ quality: options.quality,
223
227
  reader: this._reader,
224
228
  };
225
229
 
package/src/server.ts CHANGED
@@ -50,23 +50,13 @@ import type { ExistingMemory } from './llm/memory-manager.js';
50
50
  import { runFormation, getMetricsSummary, getBeforeAfterMetrics } from './memory/formation/index.js';
51
51
  import type { FormationConfig, SearchHit, FormedMemory, FormationStage, FormationStageEvent } from './memory/formation/types.js';
52
52
  import { parseFormationTimeoutMs } from './server/formation-timeout.js';
53
+ import { withTimeout } from './timeout.js';
53
54
 
54
55
  // ── Timeout budgets for LLM-heavy paths ──────────────────────────
55
56
  const FORMATION_TIMEOUT_MS = parseFormationTimeoutMs(process.env.MEMORIX_FORMATION_TIMEOUT_MS); // Formation pipeline (extract+resolve+evaluate)
56
57
  const COMPACT_ON_WRITE_TIMEOUT_MS = 12_000; // Legacy compact-on-write fallback path
57
58
  const COMPRESSION_TIMEOUT_MS = 5_000; // Narrative compression
58
59
 
59
- /** Race a promise against a timeout. Rejects with a descriptive Error on timeout. */
60
- function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
61
- let timer: ReturnType<typeof setTimeout>;
62
- return Promise.race([
63
- promise,
64
- new Promise<never>((_, reject) => {
65
- timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
66
- }),
67
- ]).finally(() => clearTimeout(timer!));
68
- }
69
-
70
60
  function formatFormationStageDurations(stageDurationsMs: Partial<Record<FormationStage, number>>): string {
71
61
  const orderedStages: FormationStage[] = ['extract', 'resolve', 'evaluate'];
72
62
  const parts = orderedStages
@@ -228,6 +218,9 @@ export const BOOTSTRAP_SAFE_TOOL_NAMES = new Set([
228
218
  'memorix_context_pack',
229
219
  ]);
230
220
 
221
+ const AUTOPILOT_RETRIEVAL_BOUNDARY_TTL_MS = 2 * 60 * 1000;
222
+ const READ_ONLY_TASK_PATTERN = /\b(?:do not|don't|never)\s+(?:modify|edit|change|write)\b|\bread[- ]only\b|(?:不要|不准|勿|禁止).{0,6}(?:修改|编辑|写入)|只读/i;
223
+
231
224
  export function shouldAwaitProjectRuntime(toolName: string): boolean {
232
225
  return !BOOTSTRAP_SAFE_TOOL_NAMES.has(toolName);
233
226
  }
@@ -602,6 +595,63 @@ export async function createMemorixServer(
602
595
  };
603
596
  };
604
597
 
598
+ // A complete Autopilot brief is the normal retrieval boundary for one coding
599
+ // turn. Keep accidental search/detail loops cheap, while preserving an
600
+ // explicit escape hatch when a caller really needs deeper history.
601
+ let autopilotRetrievalBoundary: {
602
+ projectId: string;
603
+ issuedAt: number;
604
+ coveredObservationIds: Set<number>;
605
+ readOnly: boolean;
606
+ } | null = null;
607
+ const getActiveAutopilotRetrievalBoundary = () => {
608
+ const boundary = autopilotRetrievalBoundary;
609
+ if (!boundary) return null;
610
+ if (boundary.projectId === project.id && Date.now() - boundary.issuedAt <= AUTOPILOT_RETRIEVAL_BOUNDARY_TTL_MS) {
611
+ return boundary;
612
+ }
613
+ autopilotRetrievalBoundary = null;
614
+ return null;
615
+ };
616
+ const requireExplicitAutopilotExpansion = (toolName: string, purpose?: string) => {
617
+ if (!getActiveAutopilotRetrievalBoundary() || purpose?.trim()) return null;
618
+
619
+ return {
620
+ content: [{
621
+ type: 'text' as const,
622
+ text:
623
+ 'Memorix Autopilot retrieval boundary: the latest `memorix_project_context` already supplied the default bounded brief for this coding turn, so no additional memory was retrieved. ' +
624
+ 'Verify the current project first. To intentionally expand beyond that brief, call `' + toolName + '` again with `purpose` naming the specific missing fact or the user\'s explicit request.',
625
+ }],
626
+ };
627
+ };
628
+ const blockCoveredAutopilotEvidence = (toolName: string, observationIds: number[], force?: boolean) => {
629
+ const boundary = getActiveAutopilotRetrievalBoundary();
630
+ if (!boundary || force || observationIds.length === 0 || !observationIds.every(id => boundary.coveredObservationIds.has(id))) {
631
+ return null;
632
+ }
633
+ return {
634
+ content: [{
635
+ type: 'text' as const,
636
+ text:
637
+ 'Memorix Autopilot retrieval boundary: every requested memory is already represented in the latest project brief, so no duplicate detail was retrieved. ' +
638
+ 'Inspect the current project or seek a new source. Retry with `force: true` only when the user explicitly asks to read the underlying record in full.',
639
+ }],
640
+ };
641
+ };
642
+ const blockReadOnlyAutopilotWrite = (overrideReadOnly?: boolean) => {
643
+ const boundary = getActiveAutopilotRetrievalBoundary();
644
+ if (!boundary?.readOnly || overrideReadOnly) return null;
645
+ return {
646
+ content: [{
647
+ type: 'text' as const,
648
+ text:
649
+ 'Memorix write boundary: the latest task is read-only or asks not to modify files, so no memory was stored. ' +
650
+ 'Persist a record only when the user explicitly asks to save it, then retry with `overrideReadOnly: true`.',
651
+ }],
652
+ };
653
+ };
654
+
605
655
  // ================================================================
606
656
  // Memorix Extended Tools (3-layer Progressive Disclosure)
607
657
  // ================================================================
@@ -622,7 +672,8 @@ export async function createMemorixServer(
622
672
  'problem-solution ([FIX] bug fix), how-it-works ([INFO] explanation), what-changed ([CHANGE] change), ' +
623
673
  'discovery ([DISCOVERY] insight), why-it-exists ([WHY] rationale), trade-off ([TRADEOFF] compromise), ' +
624
674
  'session-request ([SESSION] original goal). ' +
625
- 'Project visibility is the default. Personal or team visibility requires an explicitly joined coordination identity.',
675
+ 'Project visibility is the default. Personal or team visibility requires an explicitly joined coordination identity. ' +
676
+ 'For a read-only task, do not store unless the user explicitly asks to save a record.',
626
677
  inputSchema: {
627
678
  entityName: z.string().describe('The entity this observation belongs to (e.g., "auth-module", "port-config")'),
628
679
  type: z.enum(OBSERVATION_TYPES).describe('Observation type for classification'),
@@ -646,11 +697,16 @@ export async function createMemorixServer(
646
697
  visibility: z.enum(['personal', 'project', 'team']).optional().describe(
647
698
  'Retrieval scope. Project is the normal shared default; personal/team require memorix_session_start with joinTeam=true.',
648
699
  ),
700
+ overrideReadOnly: z.boolean().optional().describe(
701
+ 'Use only when the user explicitly asks to save memory during a read-only or no-modification task.',
702
+ ),
649
703
  },
650
704
  },
651
- async ({ entityName: rawEntityName, type: rawType, title: rawTitle, narrative, facts, filesModified, concepts, topicKey, progress, relatedCommits, relatedEntities, visibility }) => {
705
+ async ({ entityName: rawEntityName, type: rawType, title: rawTitle, narrative, facts, filesModified, concepts, topicKey, progress, relatedCommits, relatedEntities, visibility, overrideReadOnly }) => {
652
706
  const unresolved = requireResolvedProject('store memory in the current project');
653
707
  if (unresolved) return unresolved;
708
+ const readOnlyBoundary = blockReadOnlyAutopilotWrite(overrideReadOnly);
709
+ if (readOnlyBoundary) return readOnlyBoundary;
654
710
  const requestedVisibility = (visibility ?? 'project') as 'personal' | 'project' | 'team';
655
711
  const reader = getObservationReader();
656
712
  if (requestedVisibility !== 'project' && !currentAgentId) {
@@ -1224,6 +1280,7 @@ export async function createMemorixServer(
1224
1280
  title: 'Search Memory',
1225
1281
  description:
1226
1282
  'Search project memory. Returns a compact index (~50-100 tokens/result). ' +
1283
+ 'Do not use as a follow-up to a complete memorix_project_context brief unless a specific fact is still missing or the user asks for deeper history; provide purpose when intentionally expanding. ' +
1227
1284
  'Use memorix_detail to fetch full content for specific IDs. ' +
1228
1285
  'Use memorix_timeline to see chronological context. ' +
1229
1286
  'Searches across all observations stored from any IDE session — enabling cross-session and cross-agent context retrieval.',
@@ -1243,13 +1300,26 @@ export async function createMemorixServer(
1243
1300
  source: z.enum(['agent', 'git', 'manual']).optional().describe(
1244
1301
  'Filter by memory source. "git" returns only commit-derived ground truth memories. Omit for all sources.',
1245
1302
  ),
1303
+ quality: z.enum(['fast', 'balanced', 'thorough']).optional().default('balanced').describe(
1304
+ 'Retrieval profile: fast stays local, balanced uses configured embeddings, thorough explicitly permits optional LLM refinement.',
1305
+ ),
1306
+ purpose: z.string().optional().describe(
1307
+ 'Why this must expand beyond the latest Autopilot brief. Name the missing fact or the user\'s explicit request.',
1308
+ ),
1309
+ force: z.boolean().optional().describe(
1310
+ 'Use only when the user explicitly asks to read a record already represented in the latest Autopilot brief.',
1311
+ ),
1246
1312
  },
1247
1313
  },
1248
- async ({ query, limit, type, maxTokens, scope, since, until, status, source }) => {
1314
+ async ({ query, limit, type, maxTokens, scope, since, until, status, source, quality, purpose, force }) => {
1249
1315
  if (scope !== 'global') {
1250
1316
  const unresolved = requireResolvedProject('search the current project');
1251
1317
  if (unresolved) return unresolved;
1252
1318
  }
1319
+ if (scope !== 'global') {
1320
+ const boundary = requireExplicitAutopilotExpansion('memorix_search', purpose);
1321
+ if (boundary) return boundary;
1322
+ }
1253
1323
  return withFreshIndex(async () => {
1254
1324
 
1255
1325
  const safeLimit = limit != null ? coerceNumber(limit, 20) : undefined;
@@ -1269,18 +1339,15 @@ export async function createMemorixServer(
1269
1339
  projectId: scope === 'global' ? undefined : project.id,
1270
1340
  status: (status as 'active' | 'resolved' | 'archived' | 'all') ?? 'active',
1271
1341
  source: source as 'agent' | 'git' | 'manual' | undefined,
1342
+ quality: quality as 'fast' | 'balanced' | 'thorough',
1272
1343
  reader: getObservationReader(scope === 'global' ? 'global' : 'project'),
1273
1344
  });
1274
1345
 
1275
- const timeoutPromise = new Promise<never>((_, reject) =>
1276
- setTimeout(() => reject(new Error(`Search timeout after ${TIMEOUT_MS}ms`)), TIMEOUT_MS)
1277
- );
1278
-
1279
1346
  let result;
1280
1347
  try {
1281
- result = await Promise.race([searchPromise, timeoutPromise]);
1348
+ result = await withTimeout(searchPromise, TIMEOUT_MS, 'Search');
1282
1349
  } catch (error) {
1283
- if (error instanceof Error && error.message.includes('timeout')) {
1350
+ if (error instanceof Error && /\btimeout\b|timed out/i.test(error.message)) {
1284
1351
  // Timeout: return empty result with error message
1285
1352
  return {
1286
1353
  content: [
@@ -1295,6 +1362,18 @@ export async function createMemorixServer(
1295
1362
  throw error;
1296
1363
  }
1297
1364
 
1365
+ const activeBoundary = getActiveAutopilotRetrievalBoundary();
1366
+ if (
1367
+ scope !== 'global'
1368
+ && activeBoundary
1369
+ && !force
1370
+ && result.entries.length > 0
1371
+ && result.entries.every(entry => activeBoundary.coveredObservationIds.has(entry.id))
1372
+ ) {
1373
+ const duplicate = blockCoveredAutopilotEvidence('memorix_search', result.entries.map(entry => entry.id), force);
1374
+ if (duplicate) return duplicate;
1375
+ }
1376
+
1298
1377
  // Append retrieval diagnostics only; do not mix workspace-sync guidance into memory results.
1299
1378
  let text = result.formatted;
1300
1379
  try {
@@ -1414,6 +1493,7 @@ export async function createMemorixServer(
1414
1493
  observations,
1415
1494
  task,
1416
1495
  refresh: refresh ?? 'auto',
1496
+ reader: getObservationReader(),
1417
1497
  enqueueRefresh: () => {
1418
1498
  enqueueCodegraphRefresh({
1419
1499
  dataDir: projectDir,
@@ -1430,6 +1510,17 @@ export async function createMemorixServer(
1430
1510
  ? formatAutoProjectContextSummary(context)
1431
1511
  : formatAutoProjectContextPrompt(context);
1432
1512
 
1513
+ autopilotRetrievalBoundary = {
1514
+ projectId: project.id,
1515
+ issuedAt: Date.now(),
1516
+ coveredObservationIds: new Set([
1517
+ ...(context.workset.continuation?.memories.map(memory => memory.id) ?? []),
1518
+ ...context.workset.reliableMemory.map(memory => memory.id),
1519
+ ...context.workset.cautionMemory.map(memory => memory.id),
1520
+ ]),
1521
+ readOnly: READ_ONLY_TASK_PATTERN.test(task ?? ''),
1522
+ };
1523
+
1433
1524
  return {
1434
1525
  content: [{ type: 'text' as const, text }],
1435
1526
  };
@@ -1475,18 +1566,24 @@ export async function createMemorixServer(
1475
1566
  title: 'Context Pack',
1476
1567
  description:
1477
1568
  'Build a prompt-ready working context pack for a coding task. ' +
1478
- 'Combines relevant memories, CodeGraph Memory facts, freshness warnings, suggested reads, and verification hints.',
1569
+ 'Combines relevant memories, CodeGraph Memory facts, freshness warnings, suggested reads, and verification hints. ' +
1570
+ 'After a complete memorix_project_context brief, provide purpose only when deliberately expanding beyond it.',
1479
1571
  inputSchema: {
1480
1572
  task: z.string().describe('Current coding task or question'),
1481
1573
  limit: z.preprocess(
1482
1574
  value => (typeof value === 'string' && value.trim() !== '' ? Number(value) : value),
1483
1575
  z.number().int().positive().max(100),
1484
1576
  ).optional().describe('Max active memories to inspect before code-ref filtering (default: 20)'),
1577
+ purpose: z.string().optional().describe(
1578
+ 'Why this must expand beyond the latest Autopilot brief. Name the missing fact or the user\'s explicit request.',
1579
+ ),
1485
1580
  },
1486
1581
  },
1487
- async ({ task, limit }) => {
1582
+ async ({ task, limit, purpose }) => {
1488
1583
  const unresolved = requireResolvedProject('build a context pack for the current project');
1489
1584
  if (unresolved) return unresolved;
1585
+ const boundary = requireExplicitAutopilotExpansion('memorix_context_pack', purpose);
1586
+ if (boundary) return boundary;
1490
1587
 
1491
1588
  const [
1492
1589
  { CodeGraphStore },
@@ -2380,6 +2477,7 @@ export async function createMemorixServer(
2380
2477
  description:
2381
2478
  'Fetch full observation or mini-skill details — includes source kind (explicit memory / hook trace / git evidence), ' +
2382
2479
  'value category, and cross-references (~500-1000 tokens each). ' +
2480
+ 'Do not re-fetch content already covered by a complete memorix_project_context brief unless a specific fact is still missing or the user asks for deeper history; provide purpose when intentionally expanding. ' +
2383
2481
  'Always use memorix_search first to find relevant IDs, then fetch only what you need. ' +
2384
2482
  'Accepts typed refs from search results (e.g. "obs:42", "skill:3") via the typedRefs field, ' +
2385
2483
  'or legacy numeric ids / object refs for backward compatibility.',
@@ -2392,19 +2490,39 @@ export async function createMemorixServer(
2392
2490
  }),
2393
2491
  ).optional().describe('Explicit observation refs. Prefer this for global search results.'),
2394
2492
  typedRefs: z.array(z.string()).optional().describe('Typed memory refs from search results, e.g. "obs:42", "skill:3", "obs:42@org/proj"'),
2493
+ purpose: z.string().optional().describe(
2494
+ 'Why this must expand beyond the latest Autopilot brief. Name the missing fact or the user\'s explicit request.',
2495
+ ),
2496
+ force: z.boolean().optional().describe(
2497
+ 'Use only when the user explicitly asks to read a record already represented in the latest Autopilot brief.',
2498
+ ),
2395
2499
  },
2396
2500
  },
2397
- async ({ ids, refs, typedRefs }) => {
2501
+ async ({ ids, refs, typedRefs, purpose, force }) => {
2398
2502
  // Defensive coercion: Claude Code CLI + GLM may send "[16]" instead of [16]
2399
2503
  const safeIds = coerceNumberArray(ids);
2400
2504
  const safeRefs = coerceObservationRefs(refs);
2401
2505
  const safeTypedRefs = coerceStringArray(typedRefs);
2506
+ const hasCrossProjectRef = safeRefs.some((ref) => ref.projectId && ref.projectId !== project.id)
2507
+ || safeTypedRefs.some((ref) => ref.includes('@') && !ref.endsWith(`@${project.id}`));
2508
+ if (!hasCrossProjectRef) {
2509
+ const boundary = requireExplicitAutopilotExpansion('memorix_detail', purpose);
2510
+ if (boundary) return boundary;
2511
+ const requestedObservationIds = [
2512
+ ...safeIds,
2513
+ ...safeRefs.map(ref => ref.id),
2514
+ ...safeTypedRefs.flatMap((ref) => {
2515
+ const match = /^obs:(\d+)(?:@.+)?$/i.exec(ref.trim());
2516
+ return match ? [Number(match[1])] : [];
2517
+ }),
2518
+ ];
2519
+ const duplicate = blockCoveredAutopilotEvidence('memorix_detail', requestedObservationIds, force);
2520
+ if (duplicate) return duplicate;
2521
+ }
2402
2522
 
2403
2523
  // Priority: typedRefs > refs > ids (each is a complete, homogeneous input path)
2404
2524
  let result;
2405
2525
  try {
2406
- const hasCrossProjectRef = safeRefs.some((ref) => ref.projectId && ref.projectId !== project.id)
2407
- || safeTypedRefs.some((ref) => ref.includes('@') && !ref.endsWith(`@${project.id}`));
2408
2526
  const reader = getObservationReader(hasCrossProjectRef ? 'global' : 'project');
2409
2527
  if (safeTypedRefs.length > 0) {
2410
2528
  // Pass typed ref strings directly — compactDetail handles parsing
@@ -4825,6 +4943,7 @@ export async function createMemorixServer(
4825
4943
 
4826
4944
  // Phase 4a: clear agent identity — old project's agent is not valid in new project
4827
4945
  currentAgentId = undefined;
4946
+ autopilotRetrievalBoundary = null;
4828
4947
 
4829
4948
  // Re-resolve data dir with canonical ID (may differ from raw detected ID)
4830
4949
  const canonicalProjectDir = newCanonicalId !== newDetected.id