memorix 1.1.13 → 1.2.0

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 (88) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +3 -2
  3. package/README.zh-CN.md +3 -2
  4. package/dist/cli/index.js +36313 -31189
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/dashboard/static/app.js +50 -30
  7. package/dist/index.js +5348 -674
  8. package/dist/index.js.map +1 -1
  9. package/dist/maintenance-runner.d.ts +1 -1
  10. package/dist/maintenance-runner.js +3661 -293
  11. package/dist/maintenance-runner.js.map +1 -1
  12. package/dist/memcode-runtime/CHANGELOG.md +19 -0
  13. package/dist/sdk.d.ts +1 -1
  14. package/dist/sdk.js +5346 -672
  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 +25 -6
  30. package/docs/CONFIGURATION.md +21 -3
  31. package/docs/README.md +17 -2
  32. package/docs/dev-log/progress.txt +120 -40
  33. package/llms-full.txt +16 -2
  34. package/llms.txt +9 -4
  35. package/package.json +3 -2
  36. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  37. package/src/cli/capability-map.ts +1 -0
  38. package/src/cli/commands/codegraph.ts +112 -9
  39. package/src/cli/commands/context.ts +2 -0
  40. package/src/cli/commands/doctor.ts +73 -4
  41. package/src/cli/commands/knowledge.ts +282 -0
  42. package/src/cli/commands/serve-http.ts +12 -1
  43. package/src/cli/index.ts +3 -1
  44. package/src/cli/tui/App.tsx +1 -1
  45. package/src/cli/tui/Panels.tsx +8 -8
  46. package/src/cli/tui/theme.ts +1 -1
  47. package/src/cli/tui/views/GraphView.tsx +8 -7
  48. package/src/cli/tui/views/KnowledgeView.tsx +9 -9
  49. package/src/codegraph/auto-context.ts +171 -9
  50. package/src/codegraph/code-state.ts +95 -0
  51. package/src/codegraph/context-pack.ts +82 -1
  52. package/src/codegraph/external-provider.ts +581 -0
  53. package/src/codegraph/lite-provider.ts +64 -19
  54. package/src/codegraph/project-context.ts +9 -1
  55. package/src/codegraph/store.ts +154 -6
  56. package/src/codegraph/types.ts +117 -0
  57. package/src/config/resolved-config.ts +28 -0
  58. package/src/config/toml-loader.ts +3 -0
  59. package/src/config/yaml-loader.ts +6 -0
  60. package/src/dashboard/server.ts +15 -1
  61. package/src/evaluation/workset-evaluation.ts +120 -0
  62. package/src/hooks/handler.ts +48 -6
  63. package/src/knowledge/claim-store.ts +267 -0
  64. package/src/knowledge/claims.ts +537 -0
  65. package/src/knowledge/markdown.ts +129 -0
  66. package/src/knowledge/types.ts +157 -0
  67. package/src/knowledge/wiki.ts +524 -0
  68. package/src/knowledge/workflow-store.ts +168 -0
  69. package/src/knowledge/workflow-types.ts +95 -0
  70. package/src/knowledge/workflows.ts +743 -0
  71. package/src/knowledge/workset.ts +515 -0
  72. package/src/knowledge/workspace-store.ts +220 -0
  73. package/src/knowledge/workspace-types.ts +106 -0
  74. package/src/knowledge/workspace.ts +220 -0
  75. package/src/memory/observations.ts +19 -0
  76. package/src/runtime/control-plane-maintenance.ts +5 -0
  77. package/src/runtime/isolated-maintenance.ts +5 -0
  78. package/src/runtime/lifecycle-status.ts +102 -0
  79. package/src/runtime/lifecycle.ts +107 -0
  80. package/src/runtime/maintenance-jobs.ts +5 -0
  81. package/src/runtime/project-maintenance.ts +190 -0
  82. package/src/server/tool-profile.ts +3 -2
  83. package/src/server.ts +354 -14
  84. package/src/store/file-lock.ts +24 -4
  85. package/src/store/sqlite-db.ts +307 -0
  86. package/src/wiki/generator.ts +4 -2
  87. package/src/wiki/knowledge-graph.ts +7 -4
  88. package/src/wiki/types.ts +16 -4
@@ -0,0 +1,537 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { countTextTokens } from '../compact/token-budget.js';
3
+ import type { CodeGraphStore } from '../codegraph/store.js';
4
+ import type { CodeStateSnapshot, ObservationCodeRef } from '../codegraph/types.js';
5
+ import { sanitizeCredentials } from '../memory/secret-filter.js';
6
+ import type { Observation, ObservationType } from '../types.js';
7
+ import { ClaimStore } from './claim-store.js';
8
+ import type {
9
+ ClaimConflict,
10
+ ClaimEvidenceInput,
11
+ ClaimEvidenceRef,
12
+ ClaimSelection,
13
+ ClaimWriteResult,
14
+ KnowledgeClaim,
15
+ KnowledgeClaimInput,
16
+ KnowledgeClaimReviewState,
17
+ KnowledgeClaimStatus,
18
+ } from './types.js';
19
+
20
+ const MAX_TERM_LENGTH = 2_000;
21
+ const MAX_EVIDENCE_ID_LENGTH = 1_000;
22
+ const TOKEN_PATTERN = /[\p{L}\p{N}_./:-]+/gu;
23
+
24
+ const PREDICATE_BY_OBSERVATION_TYPE: Partial<Record<ObservationType, string>> = {
25
+ decision: 'decision',
26
+ 'trade-off': 'trade-off',
27
+ 'why-it-exists': 'rationale',
28
+ 'what-changed': 'changed',
29
+ 'problem-solution': 'workaround',
30
+ gotcha: 'caution',
31
+ 'how-it-works': 'behavior',
32
+ discovery: 'finding',
33
+ reasoning: 'rationale',
34
+ };
35
+
36
+ function compactText(value: string, field: string): string {
37
+ if (typeof value !== 'string') throw new Error('Claim ' + field + ' must be text');
38
+ const sanitized = sanitizeCredentials(value).trim().replace(/\s+/g, ' ');
39
+ if (!sanitized) throw new Error('Claim ' + field + ' is required');
40
+ if (sanitized.length > MAX_TERM_LENGTH) throw new Error('Claim ' + field + ' is too long');
41
+ return sanitized;
42
+ }
43
+
44
+ function normalizedTerm(value: string): string {
45
+ return compactText(value, 'identity').toLocaleLowerCase('en-US');
46
+ }
47
+
48
+ function hashIdentity(parts: string[]): string {
49
+ return createHash('sha256').update(parts.join(String.fromCharCode(31))).digest('hex');
50
+ }
51
+
52
+ export function buildClaimIdentity(input: Pick<KnowledgeClaimInput, 'subject' | 'predicate' | 'objectValue' | 'scope'>): {
53
+ claimKey: string;
54
+ conflictKey: string;
55
+ } {
56
+ const subject = normalizedTerm(input.subject);
57
+ const predicate = normalizedTerm(input.predicate);
58
+ const objectValue = normalizedTerm(input.objectValue);
59
+ return {
60
+ claimKey: hashIdentity([subject, predicate, objectValue, input.scope]),
61
+ conflictKey: hashIdentity([subject, predicate, input.scope]),
62
+ };
63
+ }
64
+
65
+ function clampConfidence(value: number | undefined): number {
66
+ if (value === undefined) return 0.7;
67
+ if (!Number.isFinite(value) || value < 0 || value > 1) {
68
+ throw new Error('Claim confidence must be a number between 0 and 1');
69
+ }
70
+ return value;
71
+ }
72
+
73
+ function validateEvidence(input: ClaimEvidenceInput[]): ClaimEvidenceInput[] {
74
+ if (!Array.isArray(input) || input.length === 0) {
75
+ throw new Error('A knowledge claim requires at least one evidence reference');
76
+ }
77
+ return input.map((evidence) => {
78
+ if (!evidence || typeof evidence !== 'object') {
79
+ throw new Error('Claim evidence must be an object');
80
+ }
81
+ const evidenceId = compactText(evidence.evidenceId, 'evidence id');
82
+ if (evidenceId.length > MAX_EVIDENCE_ID_LENGTH) {
83
+ throw new Error('Claim evidence id is too long');
84
+ }
85
+ if (!['observation', 'git', 'code', 'test', 'document', 'workflow', 'run'].includes(evidence.evidenceKind)) {
86
+ throw new Error('Claim evidence kind is invalid');
87
+ }
88
+ if (!['supports', 'contradicts', 'derives', 'verifies'].includes(evidence.relation)) {
89
+ throw new Error('Claim evidence relation is invalid');
90
+ }
91
+ return {
92
+ evidenceKind: evidence.evidenceKind,
93
+ evidenceId,
94
+ relation: evidence.relation,
95
+ ...(evidence.snapshotId ? { snapshotId: compactText(evidence.snapshotId, 'evidence snapshot id') } : {}),
96
+ ...(evidence.locator ? { locator: compactText(evidence.locator, 'evidence locator') } : {}),
97
+ ...(evidence.capturedHash ? { capturedHash: compactText(evidence.capturedHash, 'evidence hash') } : {}),
98
+ };
99
+ });
100
+ }
101
+
102
+ function normalizeClaimInput(input: KnowledgeClaimInput): {
103
+ claim: KnowledgeClaim;
104
+ evidence: ClaimEvidenceInput[];
105
+ } {
106
+ if (!['project', 'workspace', 'team', 'workflow', 'task'].includes(input.scope)) {
107
+ throw new Error('Claim scope is invalid');
108
+ }
109
+ const projectId = compactText(input.projectId, 'project id');
110
+ const subject = compactText(input.subject, 'subject');
111
+ const predicate = compactText(input.predicate, 'predicate');
112
+ const objectValue = compactText(input.objectValue, 'object value');
113
+ const identity = buildClaimIdentity({ subject, predicate, objectValue, scope: input.scope });
114
+ const origin = input.origin ?? 'explicit';
115
+ let reviewState: KnowledgeClaimReviewState = input.reviewState ?? 'approved';
116
+ let status: KnowledgeClaimStatus = input.status ?? 'active';
117
+ if (origin === 'model') {
118
+ if (input.reviewState === 'approved' || input.status === 'active') {
119
+ throw new Error('A model-origin claim cannot be approved or active without explicit review');
120
+ }
121
+ reviewState = input.reviewState ?? 'draft';
122
+ status = input.status ?? 'unknown';
123
+ }
124
+ if (!['approved', 'needs-review', 'draft', 'rejected'].includes(reviewState)) {
125
+ throw new Error('Claim review state is invalid');
126
+ }
127
+ if (!['active', 'superseded', 'disputed', 'unknown'].includes(status)) {
128
+ throw new Error('Claim status is invalid');
129
+ }
130
+ const now = new Date().toISOString();
131
+ return {
132
+ claim: {
133
+ id: randomUUID(),
134
+ projectId,
135
+ subject,
136
+ predicate,
137
+ objectValue,
138
+ scope: input.scope,
139
+ claimKey: identity.claimKey,
140
+ conflictKey: identity.conflictKey,
141
+ status,
142
+ confidence: clampConfidence(input.confidence),
143
+ observedAt: input.observedAt ?? now,
144
+ ...(input.validFrom ? { validFrom: input.validFrom } : {}),
145
+ ...(input.validTo ? { validTo: input.validTo } : {}),
146
+ reviewState,
147
+ origin,
148
+ createdAt: now,
149
+ updatedAt: now,
150
+ },
151
+ evidence: validateEvidence(input.evidence),
152
+ };
153
+ }
154
+
155
+ function activeConflictClaims(store: ClaimStore, projectId: string, conflictKey: string): KnowledgeClaim[] {
156
+ return store.listClaimsByConflictKey(projectId, conflictKey).filter(claim =>
157
+ (claim.status === 'active' || claim.status === 'disputed') && claim.reviewState === 'approved');
158
+ }
159
+
160
+ function reconcileConflicts(
161
+ store: ClaimStore,
162
+ projectId: string,
163
+ conflictKey: string,
164
+ now: string,
165
+ ): ClaimConflict[] {
166
+ const claims = activeConflictClaims(store, projectId, conflictKey);
167
+ const hasConflict = new Set(claims.map(claim => claim.claimKey)).size > 1;
168
+ for (const claim of claims) {
169
+ const nextStatus: KnowledgeClaimStatus = hasConflict ? 'disputed' : 'active';
170
+ if (claim.status === nextStatus) continue;
171
+ store.updateClaim({ ...claim, status: nextStatus, updatedAt: now });
172
+ store.recordEvent({
173
+ projectId,
174
+ claimId: claim.id,
175
+ kind: hasConflict ? 'conflicted' : 'requalified',
176
+ fromStatus: claim.status,
177
+ toStatus: nextStatus,
178
+ detail: hasConflict
179
+ ? 'A different approved assertion has the same subject, predicate, and scope.'
180
+ : 'No remaining approved competing assertion exists.',
181
+ createdAt: now,
182
+ });
183
+ }
184
+ return store.listConflicts(projectId).filter(conflict => conflict.conflictKey === conflictKey);
185
+ }
186
+
187
+ function persistEvidence(store: ClaimStore, claim: KnowledgeClaim, evidence: ClaimEvidenceInput[], now: string): void {
188
+ let added = false;
189
+ for (const item of evidence) {
190
+ added = store.insertEvidence({ claimId: claim.id, ...item, createdAt: now }) || added;
191
+ }
192
+ if (added) {
193
+ store.touchClaim(claim.id, now);
194
+ store.recordEvent({
195
+ projectId: claim.projectId,
196
+ claimId: claim.id,
197
+ kind: 'evidence-added',
198
+ detail: 'Additional source evidence was attached.',
199
+ createdAt: now,
200
+ });
201
+ }
202
+ }
203
+
204
+ /** Write one claim without allowing a summary to replace its source evidence. */
205
+ export function writeClaim(store: ClaimStore, input: KnowledgeClaimInput): ClaimWriteResult {
206
+ const normalized = normalizeClaimInput(input);
207
+ return store.transaction(() => {
208
+ const existing = store.findReusableClaim(normalized.claim.projectId, normalized.claim.claimKey);
209
+ if (existing) {
210
+ persistEvidence(store, existing, normalized.evidence, normalized.claim.updatedAt);
211
+ return {
212
+ claim: store.getClaim(existing.id) ?? existing,
213
+ created: false,
214
+ conflicts: store.listConflicts(existing.projectId)
215
+ .filter(conflict => conflict.conflictKey === existing.conflictKey),
216
+ };
217
+ }
218
+
219
+ store.insertClaim(normalized.claim);
220
+ for (const evidence of normalized.evidence) {
221
+ store.insertEvidence({ claimId: normalized.claim.id, ...evidence, createdAt: normalized.claim.createdAt });
222
+ }
223
+ store.recordEvent({
224
+ projectId: normalized.claim.projectId,
225
+ claimId: normalized.claim.id,
226
+ kind: 'created',
227
+ toStatus: normalized.claim.status,
228
+ detail: 'Claim created with source evidence.',
229
+ createdAt: normalized.claim.createdAt,
230
+ });
231
+ const conflicts = reconcileConflicts(
232
+ store,
233
+ normalized.claim.projectId,
234
+ normalized.claim.conflictKey,
235
+ normalized.claim.updatedAt,
236
+ );
237
+ return {
238
+ claim: store.getClaim(normalized.claim.id) ?? normalized.claim,
239
+ created: true,
240
+ conflicts,
241
+ };
242
+ });
243
+ }
244
+
245
+ export function supersedeClaim(
246
+ store: ClaimStore,
247
+ input: {
248
+ claimId: string;
249
+ replacementClaimId: string;
250
+ evidence: ClaimEvidenceInput[];
251
+ },
252
+ ): { superseded: KnowledgeClaim; replacement: KnowledgeClaim } {
253
+ const evidence = validateEvidence(input.evidence);
254
+ return store.transaction(() => {
255
+ const claim = store.getClaim(input.claimId);
256
+ const replacement = store.getClaim(input.replacementClaimId);
257
+ if (!claim || !replacement) throw new Error('Both claims must exist before supersession');
258
+ if (claim.projectId !== replacement.projectId || claim.conflictKey !== replacement.conflictKey) {
259
+ throw new Error('A replacement claim must address the same project assertion');
260
+ }
261
+ if (claim.id === replacement.id) throw new Error('A claim cannot supersede itself');
262
+ const now = new Date().toISOString();
263
+ persistEvidence(store, claim, evidence, now);
264
+ const updated: KnowledgeClaim = {
265
+ ...claim,
266
+ status: 'superseded',
267
+ validTo: now,
268
+ supersededBy: replacement.id,
269
+ updatedAt: now,
270
+ };
271
+ store.updateClaim(updated);
272
+ store.recordEvent({
273
+ projectId: claim.projectId,
274
+ claimId: claim.id,
275
+ kind: 'superseded',
276
+ fromStatus: claim.status,
277
+ toStatus: 'superseded',
278
+ relatedClaimId: replacement.id,
279
+ detail: 'Superseded by an evidence-backed replacement claim.',
280
+ createdAt: now,
281
+ });
282
+ reconcileConflicts(store, claim.projectId, claim.conflictKey, now);
283
+ return {
284
+ superseded: store.getClaim(claim.id) ?? updated,
285
+ replacement: store.getClaim(replacement.id) ?? replacement,
286
+ };
287
+ });
288
+ }
289
+
290
+ function hashObservation(observation: Observation): string {
291
+ return createHash('sha256').update(JSON.stringify([
292
+ observation.title,
293
+ observation.narrative,
294
+ observation.facts,
295
+ observation.filesModified,
296
+ observation.updatedAt ?? observation.createdAt,
297
+ ])).digest('hex');
298
+ }
299
+
300
+ function evidenceFromCodeRef(ref: ObservationCodeRef): ClaimEvidenceInput {
301
+ return {
302
+ evidenceKind: 'code',
303
+ evidenceId: 'code-ref:' + ref.id,
304
+ relation: 'supports',
305
+ ...(ref.snapshotId ? { snapshotId: ref.snapshotId } : {}),
306
+ locator: 'code-ref/' + ref.id,
307
+ ...(ref.capturedSymbolHash || ref.capturedFileHash
308
+ ? { capturedHash: ref.capturedSymbolHash ?? ref.capturedFileHash }
309
+ : {}),
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Converts only explicit or Git-ingested observations into conservative,
315
+ * reviewable source claims. Hook/model-derived memories remain ordinary memory
316
+ * until an agent or operator explicitly promotes them.
317
+ */
318
+ export function deriveLowRiskClaimsFromObservation(
319
+ store: ClaimStore,
320
+ observation: Observation,
321
+ codeStore?: CodeGraphStore,
322
+ ): KnowledgeClaim[] {
323
+ const predicate = PREDICATE_BY_OBSERVATION_TYPE[observation.type];
324
+ const isExplicit = observation.sourceDetail === 'explicit';
325
+ const isGit = observation.source === 'git' || observation.sourceDetail === 'git-ingest';
326
+ if (!predicate || observation.status !== 'active' || (!isExplicit && !isGit)) return [];
327
+
328
+ const evidence: ClaimEvidenceInput[] = [{
329
+ evidenceKind: 'observation',
330
+ evidenceId: 'observation:' + observation.id,
331
+ relation: 'supports',
332
+ locator: 'observation/' + observation.id,
333
+ capturedHash: hashObservation(observation),
334
+ }];
335
+ if (observation.commitHash) {
336
+ evidence.push({
337
+ evidenceKind: 'git',
338
+ evidenceId: 'git:' + observation.commitHash,
339
+ relation: 'verifies',
340
+ locator: 'git:' + observation.commitHash,
341
+ capturedHash: observation.commitHash,
342
+ });
343
+ }
344
+ const refs = codeStore?.listObservationRefs(observation.projectId, observation.id) ?? [];
345
+ for (const ref of refs) evidence.push(evidenceFromCodeRef(ref));
346
+
347
+ const confidence = Math.min(0.9, (isGit ? 0.75 : 0.7) + refs.filter(ref => ref.status === 'current').length * 0.05);
348
+ const result = writeClaim(store, {
349
+ projectId: observation.projectId,
350
+ subject: observation.entityName,
351
+ predicate,
352
+ objectValue: observation.title,
353
+ scope: 'project',
354
+ confidence,
355
+ observedAt: observation.updatedAt ?? observation.createdAt,
356
+ reviewState: 'approved',
357
+ origin: isGit ? 'git' : 'derived',
358
+ evidence,
359
+ });
360
+ return [result.claim];
361
+ }
362
+
363
+ function incompleteSnapshot(snapshot: CodeStateSnapshot | undefined): boolean {
364
+ if (!snapshot) return false;
365
+ return snapshot.completeness.skippedOversizedFiles > 0
366
+ || (snapshot.completeness.unreadableFiles ?? 0) > 0
367
+ || snapshot.completeness.removalScanDeferred;
368
+ }
369
+
370
+ function requalification(
371
+ claim: KnowledgeClaim,
372
+ refs: Map<string, ObservationCodeRef>,
373
+ evidence: ClaimEvidenceRef[],
374
+ snapshot: CodeStateSnapshot | undefined,
375
+ ): { status: KnowledgeClaimStatus; confidence: number; reviewState: KnowledgeClaimReviewState; reason?: string } | undefined {
376
+ if (claim.status === 'superseded' || claim.reviewState === 'draft' || claim.reviewState === 'rejected') return undefined;
377
+ const codeEvidence = evidence.filter(item => item.evidenceKind === 'code');
378
+ if (codeEvidence.length === 0) return undefined;
379
+ const linkedRefs = codeEvidence
380
+ .map(item => refs.get(item.evidenceId.replace(/^code-ref:/, '')))
381
+ .filter((ref): ref is ObservationCodeRef => !!ref);
382
+ const missingOrStale = linkedRefs.length !== codeEvidence.length || linkedRefs.some(ref => ref.status === 'stale');
383
+ if (missingOrStale) {
384
+ return {
385
+ status: 'unknown',
386
+ confidence: Math.min(claim.confidence, 0.2),
387
+ reviewState: 'needs-review',
388
+ reason: 'Bound code evidence is no longer current.',
389
+ };
390
+ }
391
+ if (linkedRefs.some(ref => ref.status === 'suspect')) {
392
+ return {
393
+ status: claim.status === 'disputed' ? 'disputed' : 'active',
394
+ confidence: Math.min(claim.confidence, 0.5),
395
+ reviewState: 'needs-review',
396
+ reason: 'Bound file evidence changed and needs review.',
397
+ };
398
+ }
399
+ if (incompleteSnapshot(snapshot)) {
400
+ return {
401
+ status: claim.status === 'disputed' ? 'disputed' : 'active',
402
+ confidence: Math.min(claim.confidence, 0.55),
403
+ reviewState: 'needs-review',
404
+ reason: 'The latest code scan is incomplete.',
405
+ };
406
+ }
407
+ return undefined;
408
+ }
409
+
410
+ /** Re-score code-bound claims after a completed CodeGraph refresh. */
411
+ export function requalifyClaimsForCodeState(
412
+ store: ClaimStore,
413
+ codeStore: CodeGraphStore,
414
+ projectId: string,
415
+ ): { requalified: number; incompleteSnapshot: boolean } {
416
+ const snapshot = codeStore.latestSnapshot(projectId);
417
+ const refs = new Map(codeStore.listProjectObservationRefs(projectId).map(ref => [ref.id, ref]));
418
+ let requalified = 0;
419
+ store.transaction(() => {
420
+ for (const claim of store.listClaims(projectId)) {
421
+ const next = requalification(claim, refs, store.listEvidence(claim.id), snapshot);
422
+ if (!next) continue;
423
+ if (
424
+ next.status === claim.status
425
+ && next.confidence === claim.confidence
426
+ && next.reviewState === claim.reviewState
427
+ ) {
428
+ continue;
429
+ }
430
+ const updated = {
431
+ ...claim,
432
+ status: next.status,
433
+ confidence: next.confidence,
434
+ reviewState: next.reviewState,
435
+ updatedAt: new Date().toISOString(),
436
+ };
437
+ store.updateClaim(updated);
438
+ store.recordEvent({
439
+ projectId,
440
+ claimId: claim.id,
441
+ kind: 'requalified',
442
+ fromStatus: claim.status,
443
+ toStatus: next.status,
444
+ detail: next.reason,
445
+ createdAt: updated.updatedAt,
446
+ });
447
+ requalified += 1;
448
+ }
449
+ });
450
+ return { requalified, incompleteSnapshot: incompleteSnapshot(snapshot) };
451
+ }
452
+
453
+ function normalizedTokens(value: string): Set<string> {
454
+ const raw = sanitizeCredentials(value)
455
+ .toLocaleLowerCase('en-US')
456
+ .match(TOKEN_PATTERN)
457
+ ?? [];
458
+ return new Set(raw
459
+ .map(token => token.replace(/^[,;:!?]+|[,;:!?.]+$/g, ''))
460
+ .filter(token => token.length > 1));
461
+ }
462
+
463
+ function taskTokens(task: string): Set<string> {
464
+ return normalizedTokens(task);
465
+ }
466
+
467
+ function scoreClaim(claim: KnowledgeClaim, tokens: Set<string>): number {
468
+ const text = [claim.subject, claim.predicate, claim.objectValue]
469
+ .join(' ')
470
+ .toLocaleLowerCase('en-US');
471
+ const words = normalizedTokens(text);
472
+ let matches = 0;
473
+ for (const token of tokens) {
474
+ if (words.has(token)) matches += 1;
475
+ }
476
+ if (matches === 0) return 0;
477
+ const statusWeight = claim.status === 'active' ? 0.25 : claim.status === 'disputed' ? 0.2 : 0.1;
478
+ return matches * 10 + claim.confidence + statusWeight;
479
+ }
480
+
481
+ function formatClaim(claim: KnowledgeClaim): string {
482
+ return sanitizeCredentials([claim.subject, claim.predicate, claim.objectValue].join(' '));
483
+ }
484
+
485
+ /**
486
+ * A deterministic compact selector used by the later Workset builder. It
487
+ * returns no generic claim dump when no task terms match.
488
+ */
489
+ export function selectClaimsForTask(
490
+ store: ClaimStore,
491
+ input: { projectId: string; task: string; limit?: number; maxTokens?: number },
492
+ ): ClaimSelection {
493
+ const limit = Math.max(1, Math.min(12, Math.floor(input.limit ?? 4)));
494
+ const maxTokens = Math.max(16, Math.min(1_000, Math.floor(input.maxTokens ?? 160)));
495
+ const tokens = taskTokens(input.task);
496
+ if (tokens.size === 0) return { claims: [], cautions: [], tokenCount: 0, reasons: {} };
497
+
498
+ const candidates = store.listClaims(input.projectId, {
499
+ statuses: ['active', 'disputed', 'unknown'],
500
+ limit: 1_000,
501
+ }).filter(claim => claim.reviewState !== 'draft' && claim.reviewState !== 'rejected');
502
+ const scored = candidates
503
+ .map(claim => ({ claim, score: scoreClaim(claim, tokens) }))
504
+ .filter(item => item.score > 0)
505
+ .sort((left, right) => right.score - left.score || left.claim.id.localeCompare(right.claim.id));
506
+ if (scored.length === 0) return { claims: [], cautions: [], tokenCount: 0, reasons: {} };
507
+
508
+ const selected = new Map<string, KnowledgeClaim>();
509
+ for (const { claim } of scored.slice(0, limit)) selected.set(claim.id, claim);
510
+ for (const claim of [...selected.values()]) {
511
+ for (const sibling of store.listClaimsByConflictKey(input.projectId, claim.conflictKey)) {
512
+ if (sibling.status === 'active' || sibling.status === 'disputed') selected.set(sibling.id, sibling);
513
+ }
514
+ }
515
+
516
+ const ordered = [...selected.values()]
517
+ .sort((left, right) => scoreClaim(right, tokens) - scoreClaim(left, tokens) || left.id.localeCompare(right.id));
518
+ const included: KnowledgeClaim[] = [];
519
+ let tokenCount = 0;
520
+ for (const claim of ordered) {
521
+ const nextTokens = countTextTokens(formatClaim(claim));
522
+ if (included.length > 0 && tokenCount + nextTokens > maxTokens) continue;
523
+ included.push(claim);
524
+ tokenCount += nextTokens;
525
+ }
526
+
527
+ const cautions = new Set<string>();
528
+ const reasons: Record<string, string> = {};
529
+ for (const claim of included) {
530
+ reasons[claim.id] = 'task terms matched source-qualified claim';
531
+ if (claim.status === 'disputed') cautions.add('claim-conflict');
532
+ if (claim.status === 'unknown' || claim.reviewState === 'needs-review') {
533
+ cautions.add('claim-needs-review');
534
+ }
535
+ }
536
+ return { claims: included, cautions: [...cautions].sort(), tokenCount, reasons };
537
+ }
@@ -0,0 +1,129 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import matter from 'gray-matter';
5
+ import { atomicWriteFile } from '../store/file-lock.js';
6
+ import type { KnowledgePage, KnowledgePageFrontmatter, KnowledgePageKind, KnowledgePageReviewState, KnowledgePageStatus } from './workspace-types.js';
7
+
8
+ const PAGE_KINDS: KnowledgePageKind[] = ['topic', 'decision', 'risk', 'index', 'schema', 'log'];
9
+ const PAGE_STATUSES: KnowledgePageStatus[] = ['active', 'proposed'];
10
+ const REVIEW_STATES: KnowledgePageReviewState[] = ['approved', 'needs-review'];
11
+ const LINK_PATTERN = /\[[^\]]+\]\(([^)]+)\)/g;
12
+
13
+ function hashContent(content: string): string {
14
+ return createHash('sha256').update(content).digest('hex');
15
+ }
16
+
17
+ function stringField(data: Record<string, unknown>, key: string): string {
18
+ const value = data[key];
19
+ if (typeof value !== 'string' || !value.trim()) {
20
+ throw new Error('missing required field ' + key);
21
+ }
22
+ return value.trim();
23
+ }
24
+
25
+ function stringArray(data: Record<string, unknown>, key: string): string[] {
26
+ const value = data[key];
27
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
28
+ throw new Error('field ' + key + ' must be a string array');
29
+ }
30
+ return value.map(item => item.trim()).filter(Boolean);
31
+ }
32
+
33
+ function optionalString(data: Record<string, unknown>, key: string): string | undefined {
34
+ const value = data[key];
35
+ if (value === undefined || value === null || value === '') return undefined;
36
+ if (typeof value !== 'string') throw new Error('field ' + key + ' must be text');
37
+ return value.trim() || undefined;
38
+ }
39
+
40
+ export function validateKnowledgePageFrontmatter(data: Record<string, unknown>): KnowledgePageFrontmatter {
41
+ const kind = stringField(data, 'kind') as KnowledgePageKind;
42
+ const status = stringField(data, 'status') as KnowledgePageStatus;
43
+ const reviewState = stringField(data, 'reviewState') as KnowledgePageReviewState;
44
+ if (!PAGE_KINDS.includes(kind)) throw new Error('field kind is invalid');
45
+ if (!PAGE_STATUSES.includes(status)) throw new Error('field status is invalid');
46
+ if (!REVIEW_STATES.includes(reviewState)) throw new Error('field reviewState is invalid');
47
+ return {
48
+ id: stringField(data, 'id'),
49
+ title: stringField(data, 'title'),
50
+ kind,
51
+ status,
52
+ reviewState,
53
+ claimIds: stringArray(data, 'claimIds'),
54
+ evidenceRefs: stringArray(data, 'evidenceRefs'),
55
+ ...(optionalString(data, 'snapshotId') ? { snapshotId: optionalString(data, 'snapshotId') } : {}),
56
+ tags: stringArray(data, 'tags'),
57
+ sourceHash: stringField(data, 'sourceHash'),
58
+ generatedAt: stringField(data, 'generatedAt'),
59
+ updatedAt: stringField(data, 'updatedAt'),
60
+ };
61
+ }
62
+
63
+ export function extractInternalMarkdownLinks(body: string): string[] {
64
+ const links = new Set<string>();
65
+ for (const match of body.matchAll(LINK_PATTERN)) {
66
+ const raw = match[1].trim();
67
+ if (!raw || raw.startsWith('#') || /^[a-z][a-z0-9+.-]*:/i.test(raw)) continue;
68
+ const pathPart = raw.split('#', 1)[0].replace(/\\/g, '/');
69
+ if (!pathPart.endsWith('.md')) continue;
70
+ links.add(pathPart);
71
+ }
72
+ return [...links].sort();
73
+ }
74
+
75
+ export function resolvePageLink(sourceRelativePath: string, target: string): string | undefined {
76
+ const sourceDir = path.posix.dirname(sourceRelativePath.replace(/\\/g, '/'));
77
+ const normalized = path.posix.normalize(path.posix.join(sourceDir, target.replace(/\\/g, '/')));
78
+ if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) return undefined;
79
+ return normalized;
80
+ }
81
+
82
+ export function renderKnowledgePage(frontmatter: KnowledgePageFrontmatter, body: string): string {
83
+ const normalizedBody = body.trimEnd() + '\n';
84
+ return matter.stringify(normalizedBody, {
85
+ id: frontmatter.id,
86
+ title: frontmatter.title,
87
+ kind: frontmatter.kind,
88
+ status: frontmatter.status,
89
+ reviewState: frontmatter.reviewState,
90
+ claimIds: frontmatter.claimIds,
91
+ evidenceRefs: frontmatter.evidenceRefs,
92
+ ...(frontmatter.snapshotId ? { snapshotId: frontmatter.snapshotId } : {}),
93
+ tags: frontmatter.tags,
94
+ sourceHash: frontmatter.sourceHash,
95
+ generatedAt: frontmatter.generatedAt,
96
+ updatedAt: frontmatter.updatedAt,
97
+ });
98
+ }
99
+
100
+ export async function readKnowledgePage(absolutePath: string, workspaceRoot?: string): Promise<KnowledgePage> {
101
+ const raw = await fs.readFile(absolutePath, 'utf8');
102
+ try {
103
+ const parsed = matter(raw);
104
+ const frontmatter = validateKnowledgePageFrontmatter(parsed.data as Record<string, unknown>);
105
+ return {
106
+ absolutePath,
107
+ relativePath: workspaceRoot
108
+ ? path.relative(workspaceRoot, absolutePath).split(path.sep).join('/')
109
+ : path.basename(absolutePath),
110
+ frontmatter,
111
+ body: parsed.content.trim(),
112
+ contentHash: hashContent(raw),
113
+ links: extractInternalMarkdownLinks(parsed.content),
114
+ };
115
+ } catch (error) {
116
+ const detail = error instanceof Error ? error.message : 'invalid page data';
117
+ throw new Error('Malformed knowledge page frontmatter in ' + absolutePath + ': ' + detail);
118
+ }
119
+ }
120
+
121
+ export async function writeKnowledgePage(absolutePath: string, content: string): Promise<string> {
122
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
123
+ await atomicWriteFile(absolutePath, content);
124
+ return hashContent(content);
125
+ }
126
+
127
+ export function pageContentHash(content: string): string {
128
+ return hashContent(content);
129
+ }