memorix 1.1.13 → 1.2.1

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 (97) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/README.md +6 -3
  3. package/README.zh-CN.md +6 -3
  4. package/dist/cli/index.js +36639 -31279
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/dashboard/static/app.js +50 -30
  7. package/dist/index.js +6636 -1778
  8. package/dist/index.js.map +1 -1
  9. package/dist/maintenance-runner.d.ts +1 -1
  10. package/dist/maintenance-runner.js +3775 -302
  11. package/dist/maintenance-runner.js.map +1 -1
  12. package/dist/memcode-runtime/CHANGELOG.md +33 -1
  13. package/dist/sdk.d.ts +1 -1
  14. package/dist/sdk.js +6634 -1776
  15. package/dist/sdk.js.map +1 -1
  16. package/docs/1.2.0-CLAIM-LEDGER.md +72 -0
  17. package/docs/1.2.0-CODE-STATE.md +61 -0
  18. package/docs/1.2.0-DEVELOPMENT-CHARTER.md +255 -0
  19. package/docs/1.2.0-DYNAMIC-LIFECYCLE.md +71 -0
  20. package/docs/1.2.0-EVALUATION-HARNESS.md +51 -0
  21. package/docs/1.2.0-IMPLEMENTATION-PLAN.md +554 -0
  22. package/docs/1.2.0-KNOWLEDGE-WORKFLOW-RESEARCH.md +205 -0
  23. package/docs/1.2.0-KNOWLEDGE-WORKSPACE.md +68 -0
  24. package/docs/1.2.0-PRODUCT-STORY.md +234 -0
  25. package/docs/1.2.0-PROVIDER-QUALITY.md +189 -0
  26. package/docs/1.2.0-WORKFLOW-INHERITANCE.md +101 -0
  27. package/docs/1.2.0-WORKSET-RETRIEVAL.md +80 -0
  28. package/docs/AGENT_OPERATOR_PLAYBOOK.md +6 -3
  29. package/docs/API_REFERENCE.md +27 -6
  30. package/docs/CONFIGURATION.md +21 -3
  31. package/docs/DEVELOPMENT.md +4 -0
  32. package/docs/README.md +17 -2
  33. package/docs/SETUP.md +7 -1
  34. package/docs/dev-log/progress.txt +120 -40
  35. package/docs/knowledge/workflows/memorix-release.md +57 -0
  36. package/llms-full.txt +16 -2
  37. package/llms.txt +9 -4
  38. package/package.json +3 -2
  39. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  40. package/src/cli/capability-map.ts +1 -0
  41. package/src/cli/commands/codegraph.ts +111 -9
  42. package/src/cli/commands/context.ts +2 -0
  43. package/src/cli/commands/doctor.ts +73 -4
  44. package/src/cli/commands/knowledge.ts +322 -0
  45. package/src/cli/commands/serve-http.ts +26 -42
  46. package/src/cli/commands/setup.ts +9 -3
  47. package/src/cli/index.ts +3 -1
  48. package/src/cli/tui/App.tsx +1 -1
  49. package/src/cli/tui/Panels.tsx +8 -8
  50. package/src/cli/tui/theme.ts +1 -1
  51. package/src/cli/tui/views/GraphView.tsx +8 -7
  52. package/src/cli/tui/views/KnowledgeView.tsx +9 -9
  53. package/src/codegraph/auto-context.ts +169 -19
  54. package/src/codegraph/code-state.ts +95 -0
  55. package/src/codegraph/context-pack.ts +82 -1
  56. package/src/codegraph/current-facts.ts +19 -1
  57. package/src/codegraph/external-provider.ts +581 -0
  58. package/src/codegraph/lite-provider.ts +64 -19
  59. package/src/codegraph/project-context.ts +9 -1
  60. package/src/codegraph/store.ts +154 -6
  61. package/src/codegraph/task-lens.ts +49 -5
  62. package/src/codegraph/types.ts +117 -0
  63. package/src/config/resolved-config.ts +28 -0
  64. package/src/config/toml-loader.ts +3 -0
  65. package/src/config/yaml-loader.ts +6 -0
  66. package/src/dashboard/server.ts +30 -47
  67. package/src/evaluation/workset-evaluation.ts +120 -0
  68. package/src/hooks/handler.ts +48 -6
  69. package/src/knowledge/claim-store.ts +267 -0
  70. package/src/knowledge/claims.ts +587 -0
  71. package/src/knowledge/markdown.ts +129 -0
  72. package/src/knowledge/types.ts +158 -0
  73. package/src/knowledge/wiki.ts +524 -0
  74. package/src/knowledge/workflow-store.ts +168 -0
  75. package/src/knowledge/workflow-types.ts +95 -0
  76. package/src/knowledge/workflows.ts +774 -0
  77. package/src/knowledge/workset.ts +515 -0
  78. package/src/knowledge/workspace-store.ts +220 -0
  79. package/src/knowledge/workspace-types.ts +106 -0
  80. package/src/knowledge/workspace.ts +220 -0
  81. package/src/memory/auto-relations.ts +21 -0
  82. package/src/memory/graph-scope.ts +46 -0
  83. package/src/memory/observations.ts +19 -0
  84. package/src/orchestrate/verify-gate.ts +33 -10
  85. package/src/runtime/control-plane-maintenance.ts +5 -0
  86. package/src/runtime/isolated-maintenance.ts +5 -0
  87. package/src/runtime/lifecycle-status.ts +102 -0
  88. package/src/runtime/lifecycle.ts +107 -0
  89. package/src/runtime/maintenance-jobs.ts +5 -0
  90. package/src/runtime/project-maintenance.ts +190 -0
  91. package/src/server/tool-profile.ts +3 -2
  92. package/src/server.ts +424 -22
  93. package/src/store/file-lock.ts +24 -4
  94. package/src/store/sqlite-db.ts +307 -0
  95. package/src/wiki/generator.ts +4 -2
  96. package/src/wiki/knowledge-graph.ts +7 -4
  97. package/src/wiki/types.ts +16 -4
@@ -0,0 +1,220 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { getDatabase } from '../store/sqlite-db.js';
3
+ import type {
4
+ KnowledgePageRecord,
5
+ KnowledgeProposal,
6
+ KnowledgeWorkspace,
7
+ KnowledgeWorkspaceMode,
8
+ KnowledgeWorkspaceStatus,
9
+ } from './workspace-types.js';
10
+
11
+ function arrayValue(raw: unknown): string[] {
12
+ if (typeof raw !== 'string' || !raw) return [];
13
+ try {
14
+ const parsed = JSON.parse(raw);
15
+ return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : [];
16
+ } catch {
17
+ return [];
18
+ }
19
+ }
20
+
21
+ function optionalText(value: unknown): string | undefined {
22
+ return typeof value === 'string' && value ? value : undefined;
23
+ }
24
+
25
+ function rowToWorkspace(row: any): KnowledgeWorkspace {
26
+ return {
27
+ id: row.id,
28
+ projectId: row.projectId,
29
+ mode: row.mode,
30
+ rootPath: row.rootPath,
31
+ ...(optionalText(row.projectRoot) ? { projectRoot: row.projectRoot } : {}),
32
+ status: row.status,
33
+ createdAt: row.createdAt,
34
+ updatedAt: row.updatedAt,
35
+ ...(optionalText(row.lastCompiledAt) ? { lastCompiledAt: row.lastCompiledAt } : {}),
36
+ ...(optionalText(row.lastLintedAt) ? { lastLintedAt: row.lastLintedAt } : {}),
37
+ };
38
+ }
39
+
40
+ function rowToPage(row: any): KnowledgePageRecord {
41
+ return {
42
+ id: row.id,
43
+ workspaceId: row.workspaceId,
44
+ relativePath: row.relativePath,
45
+ title: row.title,
46
+ kind: row.kind,
47
+ status: row.status,
48
+ reviewState: row.reviewState,
49
+ contentHash: row.contentHash,
50
+ sourceHash: row.sourceHash,
51
+ claimIds: arrayValue(row.claimIdsJson),
52
+ ...(optionalText(row.snapshotId) ? { snapshotId: row.snapshotId } : {}),
53
+ tags: arrayValue(row.tagsJson),
54
+ generatedAt: row.generatedAt,
55
+ updatedAt: row.updatedAt,
56
+ ...(optionalText(row.lastLintedAt) ? { lastLintedAt: row.lastLintedAt } : {}),
57
+ ...(optionalText(row.manualContentHash) ? { manualContentHash: row.manualContentHash } : {}),
58
+ };
59
+ }
60
+
61
+ function rowToProposal(row: any): KnowledgeProposal {
62
+ return {
63
+ id: row.id,
64
+ workspaceId: row.workspaceId,
65
+ pageId: row.pageId,
66
+ targetPath: row.targetPath,
67
+ proposalPath: row.proposalPath,
68
+ ...(optionalText(row.baseContentHash) ? { baseContentHash: row.baseContentHash } : {}),
69
+ sourceHash: row.sourceHash,
70
+ reason: row.reason,
71
+ status: row.status,
72
+ createdAt: row.createdAt,
73
+ ...(optionalText(row.appliedAt) ? { appliedAt: row.appliedAt } : {}),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Operational metadata for Markdown workspaces. The Markdown files remain the
79
+ * human-readable source; this store tracks provenance and safe proposal state.
80
+ */
81
+ export class KnowledgeWorkspaceStore {
82
+ private db: any = null;
83
+
84
+ async init(dataDir: string): Promise<void> {
85
+ this.db = getDatabase(dataDir);
86
+ }
87
+
88
+ upsertWorkspace(input: Omit<KnowledgeWorkspace, 'createdAt' | 'updatedAt'> & {
89
+ createdAt?: string;
90
+ updatedAt?: string;
91
+ }): KnowledgeWorkspace {
92
+ const now = input.updatedAt ?? new Date().toISOString();
93
+ const createdAt = input.createdAt ?? now;
94
+ this.db.prepare(
95
+ 'INSERT INTO knowledge_workspaces (id, projectId, mode, rootPath, projectRoot, status, createdAt, updatedAt, lastCompiledAt, lastLintedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET mode = excluded.mode, rootPath = excluded.rootPath, projectRoot = excluded.projectRoot, status = excluded.status, updatedAt = excluded.updatedAt, lastCompiledAt = excluded.lastCompiledAt, lastLintedAt = excluded.lastLintedAt',
96
+ ).run(
97
+ input.id,
98
+ input.projectId,
99
+ input.mode,
100
+ input.rootPath,
101
+ input.projectRoot ?? null,
102
+ input.status,
103
+ createdAt,
104
+ now,
105
+ input.lastCompiledAt ?? null,
106
+ input.lastLintedAt ?? null,
107
+ );
108
+ return this.getWorkspace(input.id)!;
109
+ }
110
+
111
+ getWorkspace(id: string): KnowledgeWorkspace | undefined {
112
+ const row = this.db.prepare('SELECT * FROM knowledge_workspaces WHERE id = ?').get(id);
113
+ return row ? rowToWorkspace(row) : undefined;
114
+ }
115
+
116
+ findWorkspace(projectId: string, mode: KnowledgeWorkspaceMode): KnowledgeWorkspace | undefined {
117
+ const row = this.db.prepare('SELECT * FROM knowledge_workspaces WHERE projectId = ? AND mode = ? ORDER BY updatedAt DESC LIMIT 1').get(projectId, mode);
118
+ return row ? rowToWorkspace(row) : undefined;
119
+ }
120
+
121
+ upsertPage(page: KnowledgePageRecord): void {
122
+ this.db.prepare(
123
+ 'INSERT INTO knowledge_pages (id, workspaceId, relativePath, title, kind, status, reviewState, contentHash, sourceHash, claimIdsJson, snapshotId, tagsJson, generatedAt, updatedAt, lastLintedAt, manualContentHash) VALUES (@id, @workspaceId, @relativePath, @title, @kind, @status, @reviewState, @contentHash, @sourceHash, @claimIdsJson, @snapshotId, @tagsJson, @generatedAt, @updatedAt, @lastLintedAt, @manualContentHash) ON CONFLICT(workspaceId, relativePath) DO UPDATE SET id = excluded.id, title = excluded.title, kind = excluded.kind, status = excluded.status, reviewState = excluded.reviewState, contentHash = excluded.contentHash, sourceHash = excluded.sourceHash, claimIdsJson = excluded.claimIdsJson, snapshotId = excluded.snapshotId, tagsJson = excluded.tagsJson, generatedAt = excluded.generatedAt, updatedAt = excluded.updatedAt, lastLintedAt = excluded.lastLintedAt, manualContentHash = excluded.manualContentHash',
124
+ ).run({
125
+ ...page,
126
+ claimIdsJson: JSON.stringify(page.claimIds),
127
+ snapshotId: page.snapshotId ?? null,
128
+ tagsJson: JSON.stringify(page.tags),
129
+ lastLintedAt: page.lastLintedAt ?? null,
130
+ manualContentHash: page.manualContentHash ?? null,
131
+ });
132
+ }
133
+
134
+ getPage(workspaceId: string, relativePath: string): KnowledgePageRecord | undefined {
135
+ const row = this.db.prepare('SELECT * FROM knowledge_pages WHERE workspaceId = ? AND relativePath = ?').get(workspaceId, relativePath);
136
+ return row ? rowToPage(row) : undefined;
137
+ }
138
+
139
+ getPageById(id: string): KnowledgePageRecord | undefined {
140
+ const row = this.db.prepare('SELECT * FROM knowledge_pages WHERE id = ?').get(id);
141
+ return row ? rowToPage(row) : undefined;
142
+ }
143
+
144
+ listPages(workspaceId: string): KnowledgePageRecord[] {
145
+ return this.db.prepare('SELECT * FROM knowledge_pages WHERE workspaceId = ? ORDER BY relativePath').all(workspaceId).map(rowToPage);
146
+ }
147
+
148
+ replacePageClaims(pageId: string, claimIds: string[]): void {
149
+ const remove = this.db.prepare('DELETE FROM knowledge_page_claims WHERE pageId = ?');
150
+ const insert = this.db.prepare("INSERT OR IGNORE INTO knowledge_page_claims (pageId, claimId, role) VALUES (?, ?, 'primary')");
151
+ this.db.transaction(() => {
152
+ remove.run(pageId);
153
+ for (const claimId of [...new Set(claimIds)]) insert.run(pageId, claimId);
154
+ })();
155
+ }
156
+
157
+ replacePageLinks(pageId: string, targets: string[]): void {
158
+ const remove = this.db.prepare('DELETE FROM knowledge_page_links WHERE sourcePageId = ?');
159
+ const insert = this.db.prepare('INSERT OR IGNORE INTO knowledge_page_links (sourcePageId, targetPath) VALUES (?, ?)');
160
+ this.db.transaction(() => {
161
+ remove.run(pageId);
162
+ for (const target of [...new Set(targets)]) insert.run(pageId, target);
163
+ })();
164
+ }
165
+
166
+ createProposal(input: Omit<KnowledgeProposal, 'id' | 'createdAt' | 'status'> & {
167
+ id?: string;
168
+ createdAt?: string;
169
+ status?: KnowledgeProposal['status'];
170
+ }): KnowledgeProposal {
171
+ const proposal: KnowledgeProposal = {
172
+ id: input.id ?? randomUUID(),
173
+ ...input,
174
+ status: input.status ?? 'pending',
175
+ createdAt: input.createdAt ?? new Date().toISOString(),
176
+ };
177
+ this.db.prepare(
178
+ 'INSERT INTO knowledge_proposals (id, workspaceId, pageId, targetPath, proposalPath, baseContentHash, sourceHash, reason, status, createdAt, appliedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(workspaceId, proposalPath) DO UPDATE SET pageId = excluded.pageId, targetPath = excluded.targetPath, baseContentHash = excluded.baseContentHash, sourceHash = excluded.sourceHash, reason = excluded.reason, status = excluded.status, createdAt = excluded.createdAt, appliedAt = excluded.appliedAt',
179
+ ).run(
180
+ proposal.id,
181
+ proposal.workspaceId,
182
+ proposal.pageId,
183
+ proposal.targetPath,
184
+ proposal.proposalPath,
185
+ proposal.baseContentHash ?? null,
186
+ proposal.sourceHash,
187
+ proposal.reason,
188
+ proposal.status,
189
+ proposal.createdAt,
190
+ proposal.appliedAt ?? null,
191
+ );
192
+ const row = this.db.prepare('SELECT * FROM knowledge_proposals WHERE workspaceId = ? AND proposalPath = ?').get(proposal.workspaceId, proposal.proposalPath);
193
+ return rowToProposal(row);
194
+ }
195
+
196
+ getProposal(id: string): KnowledgeProposal | undefined {
197
+ const row = this.db.prepare('SELECT * FROM knowledge_proposals WHERE id = ?').get(id);
198
+ return row ? rowToProposal(row) : undefined;
199
+ }
200
+
201
+ listProposals(workspaceId: string, status?: KnowledgeProposal['status']): KnowledgeProposal[] {
202
+ const rows = status
203
+ ? this.db.prepare('SELECT * FROM knowledge_proposals WHERE workspaceId = ? AND status = ? ORDER BY createdAt DESC, id').all(workspaceId, status)
204
+ : this.db.prepare('SELECT * FROM knowledge_proposals WHERE workspaceId = ? ORDER BY createdAt DESC, id').all(workspaceId);
205
+ return rows.map(rowToProposal);
206
+ }
207
+
208
+ markProposalApplied(id: string, appliedAt = new Date().toISOString()): KnowledgeProposal {
209
+ this.db.prepare("UPDATE knowledge_proposals SET status = 'applied', appliedAt = ? WHERE id = ?").run(appliedAt, id);
210
+ return this.getProposal(id)!;
211
+ }
212
+
213
+ markWorkspaceCompiled(id: string, at = new Date().toISOString()): void {
214
+ this.db.prepare('UPDATE knowledge_workspaces SET lastCompiledAt = ?, updatedAt = ? WHERE id = ?').run(at, at, id);
215
+ }
216
+
217
+ markWorkspaceLinted(id: string, status: KnowledgeWorkspaceStatus, at = new Date().toISOString()): void {
218
+ this.db.prepare('UPDATE knowledge_workspaces SET status = ?, lastLintedAt = ?, updatedAt = ? WHERE id = ?').run(status, at, at, id);
219
+ }
220
+ }
@@ -0,0 +1,106 @@
1
+ export type KnowledgeWorkspaceMode = 'local' | 'versioned';
2
+ export type KnowledgeWorkspaceStatus = 'ready' | 'needs-review' | 'error';
3
+
4
+ export interface KnowledgeWorkspace {
5
+ id: string;
6
+ projectId: string;
7
+ /** Local Memorix data directory used for the operational SQLite index. */
8
+ dataDir?: string;
9
+ mode: KnowledgeWorkspaceMode;
10
+ rootPath: string;
11
+ projectRoot?: string;
12
+ status: KnowledgeWorkspaceStatus;
13
+ createdAt: string;
14
+ updatedAt: string;
15
+ lastCompiledAt?: string;
16
+ lastLintedAt?: string;
17
+ }
18
+
19
+ export type KnowledgePageKind = 'topic' | 'decision' | 'risk' | 'index' | 'schema' | 'log';
20
+ export type KnowledgePageStatus = 'active' | 'proposed';
21
+ export type KnowledgePageReviewState = 'approved' | 'needs-review';
22
+
23
+ export interface KnowledgePageFrontmatter {
24
+ id: string;
25
+ title: string;
26
+ kind: KnowledgePageKind;
27
+ status: KnowledgePageStatus;
28
+ reviewState: KnowledgePageReviewState;
29
+ claimIds: string[];
30
+ evidenceRefs: string[];
31
+ snapshotId?: string;
32
+ tags: string[];
33
+ sourceHash: string;
34
+ generatedAt: string;
35
+ updatedAt: string;
36
+ }
37
+
38
+ export interface KnowledgePage {
39
+ absolutePath: string;
40
+ relativePath: string;
41
+ frontmatter: KnowledgePageFrontmatter;
42
+ body: string;
43
+ contentHash: string;
44
+ links: string[];
45
+ }
46
+
47
+ export interface KnowledgePageRecord {
48
+ id: string;
49
+ workspaceId: string;
50
+ relativePath: string;
51
+ title: string;
52
+ kind: KnowledgePageKind;
53
+ status: KnowledgePageStatus;
54
+ reviewState: KnowledgePageReviewState;
55
+ contentHash: string;
56
+ sourceHash: string;
57
+ claimIds: string[];
58
+ snapshotId?: string;
59
+ tags: string[];
60
+ generatedAt: string;
61
+ updatedAt: string;
62
+ lastLintedAt?: string;
63
+ manualContentHash?: string;
64
+ }
65
+
66
+ export type KnowledgeProposalReason = 'new-page' | 'source-changed' | 'manual-edit-protected';
67
+ export type KnowledgeProposalStatus = 'pending' | 'applied' | 'superseded';
68
+
69
+ export interface KnowledgeProposal {
70
+ id: string;
71
+ workspaceId: string;
72
+ pageId: string;
73
+ targetPath: string;
74
+ proposalPath: string;
75
+ baseContentHash?: string;
76
+ sourceHash: string;
77
+ reason: KnowledgeProposalReason;
78
+ status: KnowledgeProposalStatus;
79
+ createdAt: string;
80
+ appliedAt?: string;
81
+ }
82
+
83
+ export type WikiLintIssueKind =
84
+ | 'malformed-frontmatter'
85
+ | 'broken-link'
86
+ | 'orphan-page'
87
+ | 'missing-claim'
88
+ | 'missing-evidence'
89
+ | 'superseded-claim'
90
+ | 'unresolved-conflict'
91
+ | 'stale-snapshot'
92
+ | 'manual-edit-protected';
93
+
94
+ export interface WikiLintIssue {
95
+ kind: WikiLintIssueKind;
96
+ severity: 'error' | 'warning';
97
+ message: string;
98
+ relativePath?: string;
99
+ claimId?: string;
100
+ }
101
+
102
+ export interface WikiLintResult {
103
+ valid: boolean;
104
+ pagesScanned: number;
105
+ issues: WikiLintIssue[];
106
+ }
@@ -0,0 +1,220 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFile } from 'node:child_process';
3
+ import { promises as fs } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { atomicWriteFile, withFileLock } from '../store/file-lock.js';
7
+ import { KnowledgeWorkspaceStore } from './workspace-store.js';
8
+ import type { KnowledgeWorkspace, KnowledgeWorkspaceMode } from './workspace-types.js';
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ export interface KnowledgeWorkspacePaths {
13
+ root: string;
14
+ pages: string;
15
+ proposals: string;
16
+ workflows: string;
17
+ index: string;
18
+ log: string;
19
+ schema: string;
20
+ }
21
+
22
+ export interface InitializeKnowledgeWorkspaceInput {
23
+ projectId: string;
24
+ dataDir: string;
25
+ mode: KnowledgeWorkspaceMode;
26
+ projectRoot?: string;
27
+ rootPath?: string;
28
+ }
29
+
30
+ function hash(value: string): string {
31
+ return createHash('sha256').update(value).digest('hex');
32
+ }
33
+
34
+ function workspaceId(projectId: string, mode: KnowledgeWorkspaceMode): string {
35
+ return 'workspace:' + hash(projectId + ':' + mode).slice(0, 24);
36
+ }
37
+
38
+ function localWorkspacePath(dataDir: string, projectId: string): string {
39
+ return path.join(path.resolve(dataDir), 'knowledge-workspaces', hash(projectId).slice(0, 24));
40
+ }
41
+
42
+ function isWithin(root: string, candidate: string): boolean {
43
+ const relative = path.relative(root, candidate);
44
+ return relative !== '' && !relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative);
45
+ }
46
+
47
+ function ensureVersionedPath(projectRoot: string, rootPath: string): string {
48
+ if (!path.isAbsolute(rootPath)) {
49
+ throw new Error('A versioned knowledge workspace requires an explicit absolute path');
50
+ }
51
+ const resolvedProject = path.resolve(projectRoot);
52
+ const resolvedWorkspace = path.resolve(rootPath);
53
+ if (!isWithin(resolvedProject, resolvedWorkspace)) {
54
+ throw new Error('A versioned knowledge workspace must live inside the Git project');
55
+ }
56
+ const segments = path.relative(resolvedProject, resolvedWorkspace).split(path.sep);
57
+ if (segments.includes('.git') || segments.includes('node_modules') || segments.includes('dist')) {
58
+ throw new Error('The selected knowledge workspace path is not safe for project artifacts');
59
+ }
60
+ return resolvedWorkspace;
61
+ }
62
+
63
+ async function isIgnoredByGit(projectRoot: string, workspaceRoot: string): Promise<boolean> {
64
+ const relative = path.relative(projectRoot, workspaceRoot).split(path.sep).join('/');
65
+ const probes = [relative, relative.replace(/\/+$/, '') + '/.memorix-workspace-probe'];
66
+ for (const probe of probes) {
67
+ try {
68
+ await execFileAsync('git', ['-C', projectRoot, 'check-ignore', '--quiet', '--no-index', '--', probe], {
69
+ timeout: 3_000,
70
+ windowsHide: true,
71
+ });
72
+ return true;
73
+ } catch (error) {
74
+ const code = error && typeof error === 'object' && 'code' in error
75
+ ? (error as { code?: number | string }).code
76
+ : undefined;
77
+ if (code === 1 || code === '1') continue;
78
+ return false;
79
+ }
80
+ }
81
+ return false;
82
+ }
83
+
84
+ async function assertNoSymlinkEscape(projectRoot: string, workspaceRoot: string): Promise<void> {
85
+ const [realProject, realWorkspace] = await Promise.all([
86
+ fs.realpath(projectRoot),
87
+ fs.realpath(workspaceRoot),
88
+ ]);
89
+ if (!isWithin(realProject, realWorkspace)) {
90
+ throw new Error('The selected knowledge workspace resolves outside the Git project');
91
+ }
92
+ }
93
+
94
+ export function getKnowledgeWorkspacePaths(workspace: Pick<KnowledgeWorkspace, 'rootPath'>): KnowledgeWorkspacePaths {
95
+ const root = path.resolve(workspace.rootPath);
96
+ return {
97
+ root,
98
+ pages: path.join(root, 'pages'),
99
+ proposals: path.join(root, 'proposals'),
100
+ workflows: path.join(root, 'workflows'),
101
+ index: path.join(root, 'index.md'),
102
+ log: path.join(root, 'log.md'),
103
+ schema: path.join(root, 'schema.md'),
104
+ };
105
+ }
106
+
107
+ export function resolveKnowledgeWorkspaceFile(workspace: Pick<KnowledgeWorkspace, 'rootPath'>, relativePath: string): string {
108
+ if (!relativePath || path.isAbsolute(relativePath)) {
109
+ throw new Error('Knowledge workspace paths must be relative');
110
+ }
111
+ const root = path.resolve(workspace.rootPath);
112
+ const candidate = path.resolve(root, relativePath);
113
+ if (candidate === root || !isWithin(root, candidate)) {
114
+ throw new Error('Knowledge workspace path escapes its root');
115
+ }
116
+ return candidate;
117
+ }
118
+
119
+ async function writeIfMissing(filePath: string, content: string): Promise<void> {
120
+ try {
121
+ await fs.access(filePath);
122
+ } catch {
123
+ await atomicWriteFile(filePath, content);
124
+ }
125
+ }
126
+
127
+ function schemaContent(): string {
128
+ return [
129
+ '# Memorix Knowledge Workspace',
130
+ '',
131
+ 'This workspace is compiled from source-qualified Memorix claims.',
132
+ 'Topic pages are proposal-first: review and apply a proposal before relying',
133
+ 'on it as a published project knowledge page.',
134
+ '',
135
+ 'Do not remove claim ids, evidence references, source hashes, or snapshot',
136
+ 'references from page frontmatter. Manual body edits are preserved and cause',
137
+ 'later compiler output to remain a proposal.',
138
+ '',
139
+ ].join('\n');
140
+ }
141
+
142
+ function indexContent(): string {
143
+ return [
144
+ '# Knowledge Workspace',
145
+ '',
146
+ '## Published pages',
147
+ '',
148
+ 'No published pages yet.',
149
+ '',
150
+ '## Review queue',
151
+ '',
152
+ 'No pending proposals.',
153
+ '',
154
+ ].join('\n');
155
+ }
156
+
157
+ function logContent(): string {
158
+ return '# Knowledge log\n\n';
159
+ }
160
+
161
+ export async function initializeKnowledgeWorkspace(input: InitializeKnowledgeWorkspaceInput): Promise<KnowledgeWorkspace> {
162
+ if (!input.projectId.trim()) throw new Error('Knowledge workspace project id is required');
163
+ if (!input.dataDir.trim()) throw new Error('Knowledge workspace data directory is required');
164
+
165
+ let rootPath: string;
166
+ let projectRoot: string | undefined;
167
+ if (input.mode === 'local') {
168
+ rootPath = localWorkspacePath(input.dataDir, input.projectId);
169
+ } else {
170
+ if (!input.projectRoot || !input.rootPath) {
171
+ throw new Error('A versioned knowledge workspace requires projectRoot and rootPath');
172
+ }
173
+ projectRoot = path.resolve(input.projectRoot);
174
+ rootPath = ensureVersionedPath(projectRoot, input.rootPath);
175
+ if (await isIgnoredByGit(projectRoot, rootPath)) {
176
+ throw new Error('The selected versioned knowledge workspace is ignored by Git');
177
+ }
178
+ }
179
+
180
+ const paths = getKnowledgeWorkspacePaths({ rootPath });
181
+ await fs.mkdir(paths.pages, { recursive: true });
182
+ await Promise.all([
183
+ fs.mkdir(paths.proposals, { recursive: true }),
184
+ fs.mkdir(paths.workflows, { recursive: true }),
185
+ ]);
186
+ if (projectRoot) await assertNoSymlinkEscape(projectRoot, paths.root);
187
+
188
+ const workspace: KnowledgeWorkspace = {
189
+ id: workspaceId(input.projectId, input.mode),
190
+ projectId: input.projectId,
191
+ dataDir: path.resolve(input.dataDir),
192
+ mode: input.mode,
193
+ rootPath: paths.root,
194
+ ...(projectRoot ? { projectRoot } : {}),
195
+ status: 'ready',
196
+ createdAt: new Date().toISOString(),
197
+ updatedAt: new Date().toISOString(),
198
+ };
199
+
200
+ await withFileLock(paths.root, async () => {
201
+ await writeIfMissing(paths.schema, schemaContent());
202
+ await writeIfMissing(paths.index, indexContent());
203
+ await writeIfMissing(paths.log, logContent());
204
+ });
205
+
206
+ const store = new KnowledgeWorkspaceStore();
207
+ await store.init(input.dataDir);
208
+ return { ...store.upsertWorkspace(workspace), dataDir: workspace.dataDir };
209
+ }
210
+
211
+ export async function loadKnowledgeWorkspace(input: {
212
+ projectId: string;
213
+ dataDir: string;
214
+ mode?: KnowledgeWorkspaceMode;
215
+ }): Promise<KnowledgeWorkspace | undefined> {
216
+ const store = new KnowledgeWorkspaceStore();
217
+ await store.init(input.dataDir);
218
+ const workspace = store.findWorkspace(input.projectId, input.mode ?? 'local');
219
+ return workspace ? { ...workspace, dataDir: path.resolve(input.dataDir) } : undefined;
220
+ }
@@ -56,6 +56,27 @@ export async function createAutoRelations(
56
56
  // Skip self-references
57
57
  const selfName = obs.entityName.toLowerCase();
58
58
 
59
+ const explicitRelated = [...new Set((obs.relatedEntities ?? [])
60
+ .map(name => name.trim())
61
+ .filter(name => name && name.toLowerCase() !== selfName))];
62
+ if (explicitRelated.length > 0) {
63
+ await graphManager.createEntities(explicitRelated.map(name => ({
64
+ name,
65
+ entityType: 'related',
66
+ observations: [],
67
+ })));
68
+ for (const name of explicitRelated) {
69
+ const matchedEntity = graphManager.findEntityByName(name);
70
+ if (matchedEntity) {
71
+ relations.push({
72
+ from: obs.entityName,
73
+ to: matchedEntity.name,
74
+ relationType: 'related_entity',
75
+ });
76
+ }
77
+ }
78
+ }
79
+
59
80
  // Check extracted identifiers against existing entities (O(1) lookups via index)
60
81
  const candidates = [
61
82
  ...extracted.identifiers,
@@ -0,0 +1,46 @@
1
+ export interface GraphScopeObservation {
2
+ entityName?: string;
3
+ relatedEntities?: string[];
4
+ status?: string;
5
+ }
6
+
7
+ export interface ScopedKnowledgeGraph<Entity extends { name: string }, Relation extends { from: string; to: string }> {
8
+ entities: Entity[];
9
+ relations: Relation[];
10
+ entityNames: Set<string>;
11
+ }
12
+
13
+ /** Return the active project entities, including explicit cross-references. */
14
+ export function projectGraphEntityNames(observations: readonly GraphScopeObservation[]): Set<string> {
15
+ const entityNames = new Set<string>();
16
+ for (const observation of observations) {
17
+ if ((observation.status ?? 'active') !== 'active') continue;
18
+ const entityName = observation.entityName?.trim();
19
+ if (entityName) entityNames.add(entityName);
20
+ for (const relatedEntity of observation.relatedEntities ?? []) {
21
+ const name = relatedEntity.trim();
22
+ if (name) entityNames.add(name);
23
+ }
24
+ }
25
+ return entityNames;
26
+ }
27
+
28
+ /**
29
+ * Give every graph surface the same project projection: active entities plus
30
+ * explicit related entities, with only edges whose endpoints remain visible.
31
+ */
32
+ export function scopeKnowledgeGraphToProject<
33
+ Entity extends { name: string },
34
+ Relation extends { from: string; to: string },
35
+ >(
36
+ graph: { entities: readonly Entity[]; relations: readonly Relation[] },
37
+ observations: readonly GraphScopeObservation[],
38
+ ): ScopedKnowledgeGraph<Entity, Relation> {
39
+ const entityNames = projectGraphEntityNames(observations);
40
+ const entities = graph.entities.filter(entity => entityNames.has(entity.name));
41
+ const visibleNames = new Set(entities.map(entity => entity.name));
42
+ const relations = graph.relations.filter(relation =>
43
+ visibleNames.has(relation.from) && visibleNames.has(relation.to),
44
+ );
45
+ return { entities, relations, entityNames };
46
+ }
@@ -31,6 +31,7 @@ import { countTextTokens } from '../compact/token-budget.js';
31
31
  import { extractEntities, enrichConcepts } from './entity-extractor.js';
32
32
  import { getEmbeddingProvider, isEmbeddingExplicitlyDisabled } from '../embedding/provider.js';
33
33
  import { sanitizeCredentials } from './secret-filter.js';
34
+ import { enqueueClaimDerivation } from '../runtime/lifecycle.js';
34
35
 
35
36
  /** In-memory observation list (loaded from persistence on init) */
36
37
  let observations: Observation[] = [];
@@ -120,6 +121,22 @@ async function bindObservationCodeRefsBestEffort(observation: Observation): Prom
120
121
  }
121
122
  }
122
123
 
124
+ function queueClaimDerivation(observation: Observation): void {
125
+ const dataDir = projectDir;
126
+ const isExplicit = observation.sourceDetail === 'explicit';
127
+ const isGit = observation.source === 'git' || observation.sourceDetail === 'git-ingest';
128
+ if (!dataDir || (!isExplicit && !isGit)) return;
129
+ try {
130
+ enqueueClaimDerivation({
131
+ dataDir,
132
+ projectId: observation.projectId,
133
+ observationId: observation.id,
134
+ });
135
+ } catch {
136
+ // The observation remains durable; a later scan can recover its claim.
137
+ }
138
+ }
139
+
123
140
  function isVectorCompatibleWithCurrentIndex(embedding: number[] | null): boolean {
124
141
  if (!embedding) return false;
125
142
  const vectorDimensions = getVectorDimensions();
@@ -431,6 +448,7 @@ export async function storeObservation(input: {
431
448
  }
432
449
 
433
450
  await bindObservationCodeRefsBestEffort(observation);
451
+ queueClaimDerivation(observation);
434
452
 
435
453
  // Generate embedding async (fire-and-forget) — never blocks MCP response
436
454
  // Track in vectorMissingIds until embedding is successfully written.
@@ -585,6 +603,7 @@ async function upsertObservation(
585
603
  }
586
604
 
587
605
  await bindObservationCodeRefsBestEffort(existing);
606
+ queueClaimDerivation(existing);
588
607
 
589
608
  // Generate embedding async (fire-and-forget) — never blocks MCP response
590
609
  const searchableText = [input.title, input.narrative, ...(input.facts ?? [])].join(' ');