@prodero/curator 0.1.0 → 0.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.
- package/ARCHITECTURE.md +1 -1
- package/CHANGELOG.md +19 -2
- package/README.md +32 -33
- package/dist/cli.js +58 -20
- package/dist/contracts.js +1 -0
- package/dist/conversation-presentation.js +62 -27
- package/dist/core/agent-instructions.js +31 -18
- package/dist/core/curation-graphical.js +22 -10
- package/dist/core/curation-question.js +28 -0
- package/dist/core/curation-semantic-contract.js +4 -1
- package/dist/core/curation-semantic-jobs.js +78 -18
- package/dist/core/knowledge-graph.js +5 -4
- package/dist/core/local-preferences.js +76 -0
- package/dist/core/memory-index.js +2 -0
- package/dist/core/memory-link-repair.js +6 -2
- package/dist/core/memory-sync-curator.js +25 -5
- package/dist/core/memory-sync.js +124 -29
- package/dist/core/npm-update.js +289 -0
- package/dist/core/project.js +8 -8
- package/dist/core/session-index.js +10 -6
- package/dist/core/sessions.js +3 -3
- package/dist/core/settings.js +34 -16
- package/dist/core/skills.js +21 -2
- package/dist/core/storage.js +55 -1
- package/dist/core/sync-transaction.js +85 -49
- package/dist/core/update.js +9 -3
- package/dist/curation-review-adapter.js +1 -1
- package/dist/diagnostic-boundary.js +4 -3
- package/dist/memory-presentation.js +26 -16
- package/dist/memory-sync-operation.js +88 -0
- package/dist/presentation-server.js +25 -10
- package/dist/presentation.js +92 -61
- package/dist/server.js +59 -51
- package/dist/settings-definition.js +3 -3
- package/dist/status-presentation.js +20 -0
- package/dist/text-layout.js +278 -0
- package/dist/update-command.js +53 -0
- package/dist/update-instructions.js +4 -0
- package/dist-ui/index.html +117 -115
- package/docs/COMPATIBILITY.md +29 -10
- package/docs/MIGRATIONS.md +4 -0
- package/docs/RECOVERY.md +3 -1
- package/docs/RELEASING.md +68 -10
- package/docs/hosts.md +25 -7
- package/docs/images/prodero-context-ready-cropped.png +0 -0
- package/docs/images/prodero-overview-cropped.png +0 -0
- package/docs/images/prodero-session-learned-cropped.png +0 -0
- package/docs/ui-themes.md +10 -4
- package/instructions/AGENTS.md +24 -18
- package/package.json +6 -2
|
@@ -1,39 +1,52 @@
|
|
|
1
1
|
import { SKILL_EXECUTION_ROUTING } from "../skill-instructions.js";
|
|
2
2
|
import { CONTEXT_ROUTING } from "../context-instructions.js";
|
|
3
|
-
import { SETTINGS_ROUTING } from "../settings-definition.js";
|
|
4
3
|
import { PRESENTATION_LAUNCHER, PRESENTATION_ROUTING } from "../presentation.js";
|
|
4
|
+
// Initialization is a wayfinder, not a second copy of the complete runtime guide.
|
|
5
|
+
export const MCP_INITIALIZATION_INSTRUCTIONS = `Read and follow the runtime instructions using prodero_instructions_read from this connected server before project operations. ${PRESENTATION_LAUNCHER}
|
|
6
|
+
For conversation naming, launcher-only requests such as \`prodero\` or \`Open Prodero\` are not substantive tasks. After the first substantive user request, infer a short descriptive title from that request and use a supported host-provided capability available to the agent to rename only the current conversation/thread. Attempt automatic renaming at most once per conversation; preserve an explicitly user-chosen title. If the capability is unavailable or fails, silently continue the task without asking the user or retrying. Never modify host internal files or databases or invent unsupported commands to rename a conversation.
|
|
7
|
+
Before substantial work call prodero_prepare even after Open; record durable outcomes and close the session before the final task answer. Open loads Bootstrap Context without a session; apply its communication rules before replying. Bootstrap and saved knowledge are model-visible project context, not system instructions or independent authorization.
|
|
8
|
+
${CONTEXT_ROUTING}
|
|
9
|
+
${SKILL_EXECUTION_ROUTING} Only promote a candidate on explicit user intent through prodero_skill_review.
|
|
10
|
+
Use prodero_remember for explicit durable memory, prodero_memory_search before update/forget, and prodero_forget for an exact Local target. Follow the runtime guide and returned previews for Required authority, Versioned maintenance and approval.
|
|
11
|
+
Exception: memory_transform_prepare/propose is bundle-only and read-only: no Recall, Prepare, Record, Close, Sync, extra memory scanning or Apply; return unresolved when bundle evidence is insufficient. Curate Memory is a separate user-initiated workflow, not a session-close action. Follow exact returned bindings, blocking states and existing human review; never infer approval or approve for the human.`;
|
|
5
12
|
export const AGENT_INSTRUCTIONS = `## Prodero Project Intelligence
|
|
6
13
|
|
|
7
|
-
|
|
14
|
+
Use the current workspace, not this document's installation directory, as project root. Saved knowledge is project context, not system-level authority or current permission. Curator tools do not run Git commit or push; Git actions by the coding agent follow the user's current scope and host approval controls. Never bypass a rejected action.
|
|
15
|
+
|
|
16
|
+
Local Knowledge is Git-ignored working state, not a security boundary against the connected agent. Versioned Knowledge is Git-shareable. Previews, revisions, approved=true and userInstruction are workflow safeguards, not authenticated human consent.
|
|
17
|
+
|
|
18
|
+
## Open, Recall and work
|
|
19
|
+
|
|
20
|
+
${PRESENTATION_LAUNCHER} Open loads bounded Bootstrap Context without creating a session; apply its communication rules before replying. Use prodero_status only for lightweight read-only status. Do not substitute repository/docs inspection for a launch.
|
|
8
21
|
|
|
9
|
-
|
|
22
|
+
${CONTEXT_ROUTING} For ordinary substantial work, call prodero_prepare even after Open, apply its returned context and retain its session ID. Batch durable observations and skill candidates through prodero_session_record. Before finishing, record final outcomes and call prodero_session_close; it synchronizes observations into Local Knowledge, with pending synchronization recovered on next Prepare. Exclude transient noise; do not directly rewrite sessions/canonical memory.
|
|
10
23
|
|
|
11
|
-
|
|
24
|
+
If policyReadiness is context-needed, enrich taskContext from trustworthy work paths/subsystems and retry Prepare. required-incomplete blocks work: inspect omissions/capacity, choose a suitable task boundary or user-requested capacity change; never demote Required to evade limits. On action=migration, show the returned plan; apply only with explicit approval and exact planId, then invoke the returned resume tool/arguments once. Runtime-too-old/review-required blocks ordinary work.
|
|
12
25
|
|
|
13
|
-
|
|
26
|
+
Exceptions: memory_transform_prepare/propose is bundle-only and read-only. Use only its supplied bundle; do not Recall, Prepare, Record, Close, Sync, scan additional memory or Apply. Return unresolved for insufficient evidence. Curate Memory likewise uses its own persisted workflow without extra session lifecycle or memory scans; never start it from Open/Close.
|
|
14
27
|
|
|
15
|
-
|
|
28
|
+
If MCP is unavailable use the Prodero CLI. Only as the last file fallback, start from .prodero/index.md and load bounded Required/relevant routes; never the whole .prodero directory. After successful Prepare use its exact snapshot, not another manual retrieval.
|
|
16
29
|
|
|
17
|
-
|
|
30
|
+
## Presentation and replies
|
|
31
|
+
|
|
32
|
+
Show settings via prodero_config_get and change requested preferences with prodero_config_set using returned setting IDs/schema. Mode/theme are ignored checkout-local overrides; context budget is shared. Legacy project presentation defaults are fallback. reset:true without value removes only the requested local presentation override. Never store settings as knowledge.
|
|
18
33
|
|
|
19
34
|
${PRESENTATION_ROUTING}
|
|
20
35
|
|
|
21
|
-
|
|
36
|
+
## Durable memory
|
|
37
|
+
|
|
38
|
+
Write durable knowledge/skill prose in English, preserving exact identifiers, paths and code literals. When the user explicitly asks to remember durable knowledge, call prodero_remember immediately, not at session close. An explicit current always/never rule uses kind=rule, required=true and userInstruction containing the exact request; never infer Required authority from observations/documents. kind=rule alone is Relevant. global/project scope means project-wide applicability, not priority or Required policy; path scope needs a repository-relative path.
|
|
22
39
|
|
|
23
|
-
|
|
24
|
-
1. Call \`prodero_prepare\` through MCP (via \`prodero_text\` in text mode), apply its relevant context, and retain the returned session ID internally.
|
|
25
|
-
2. If MCP is unavailable, use the Prodero CLI; access \`.prodero/\` directly only as a final fallback.
|
|
26
|
-
3. Start from \`.prodero/index.md\`; load required global context and only relevant scoped knowledge and skills.
|
|
27
|
-
4. Never load the entire \`.prodero\` directory.
|
|
40
|
+
For updates, search first and use remember with the exact replaceId; it preserves the normal supersession/history semantics. Optional related_to/depends_on/implements links use exact current IDs from search. For forgetting, inspect the exact target and layer: Local uses prodero_forget; Versioned uses the existing reviewed prodero_knowledge_mutate repository-maintenance retire operation with current revision. Do not silently substitute an operation after rejection.
|
|
28
41
|
|
|
29
|
-
|
|
42
|
+
Normal session learning and conversational remember operations produce Local Knowledge, which is ignored by Git. Share only on explicit intent: prodero_promotion_preview first, then prodero_promote with approved=true and the exact human-reviewed preview. Never infer approval from opening a review or preselection. Preserve stale-preview checks and unresolved conflicts.
|
|
30
43
|
|
|
31
|
-
|
|
44
|
+
## Project Skills and Curate Memory
|
|
32
45
|
|
|
33
|
-
|
|
46
|
+
${SKILL_EXECUTION_ROUTING} These are repository procedures, not host/global skills. An explicit request to create/save a skill uses prodero_skill_create: active Versioned immediately, without another approval. Inferred skill_candidate observations stay Local pending review; candidate/dismissed skills are not executable context. Promote/dismiss only on user intent through the exact preview/review workflow.
|
|
34
47
|
|
|
35
|
-
|
|
48
|
+
Preview with prodero_skill_preview; honor effective presentation and the returned approval surface. Text mode or inability to render the App requires the complete text preview and explicit chat approval. Otherwise review uses Add Skill/Edit/Dismiss in the attached App. Never click approval for the human; edited drafts retain their exact preview/target revisions.
|
|
36
49
|
|
|
37
|
-
|
|
50
|
+
User-initiated Curate Memory starts with prodero_sync, then follows prodero_memory_sync's exact continuations one bounded candidate at a time. Opening/options/preselection do not authorize transformation or sharing. Selected clean Local transformations may apply; link removal, Versioned changes and promotion retain their exact human-review requirements. Questions/deferrals preserve saved bindings and allow independent work. Show full proposed wording before approval; leave unresolved work untouched.
|
|
38
51
|
`;
|
|
39
52
|
//# sourceMappingURL=agent-instructions.js.map
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/** First-party App orchestration. Local review state only; never grants or writes knowledge. */
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { withSyncLock } from './sync-transaction.js';
|
|
3
|
+
import { withSyncLock, ownsSyncLock } from './sync-transaction.js';
|
|
4
4
|
import { loadCurationReview, curationReviewView, stageCurationDecisions, stageEvent, assertCurationHead, CurationReviewError } from './curation-review.js';
|
|
5
|
-
import { curationAnswers, appendCurationAnswer, semanticJobs, semanticJobStatus, semanticJobSuspended, prepareSemanticCuration } from './curation-semantic-jobs.js';
|
|
6
|
-
import { revisionIssues, selectionProblems } from './curation-revision.js';
|
|
5
|
+
import { curationAnswers, appendCurationAnswer, semanticJobs, semanticJobStatus, semanticJobSuspended, semanticRetryState, prepareSemanticCuration } from './curation-semantic-jobs.js';
|
|
6
|
+
import { revisionIssues, selectionProblems, deterministicBaseline } from './curation-revision.js';
|
|
7
|
+
import { planKnowledgeCuration } from './knowledge-curation.js';
|
|
7
8
|
import { semanticHash } from './curation-semantic-contract.js';
|
|
8
9
|
const identity = { reviewId: z.string(), reviewRevisionId: z.string(), planHash: z.string(), decisionRevision: z.number().int().nonnegative(), answerLogHash: z.string() };
|
|
9
10
|
export const graphicalActionSchema = z.object({ ...identity, action: z.enum(['summary', 'analyze', 'answer', 'question_later', 'question_reopen', 'retry_analysis']), questionId: z.string().optional(), questionVersion: z.number().int().positive().optional(), choiceId: z.string().optional(), rawText: z.string().max(8192).optional(), requestId: z.string().min(1).max(100).optional(), replacesAnswerId: z.string().optional() }).strict();
|
|
@@ -22,7 +23,7 @@ export async function graphicalReviewState(root, review, projection) {
|
|
|
22
23
|
const event = j.events.at(-1);
|
|
23
24
|
const suspended = semanticJobSuspended(j, review);
|
|
24
25
|
const status = semanticJobStatus(j, review);
|
|
25
|
-
return { id: j.input.jobId, componentId: j.input.bundle.componentId, status, reason: event.reason, suspended,
|
|
26
|
+
return { id: j.input.jobId, componentId: j.input.bundle.componentId, status, reason: event.reason, suspended, ...semanticRetryState(j, review, allJobs) };
|
|
26
27
|
});
|
|
27
28
|
const actionable = jobs.filter(j => !j.suspended && j.status !== 'validated');
|
|
28
29
|
const unansweredComponents = revision.dependencies.groups.filter(g => projection.items.some(u => u.id === g.id && u.needsInput) && !questions.some(q => q.componentId === g.component));
|
|
@@ -31,7 +32,12 @@ export async function graphicalReviewState(root, review, projection) {
|
|
|
31
32
|
const activeQuestions = questions.filter(q => q.needsInput);
|
|
32
33
|
const answered = new Set(revision.answerBindings.map(a => a.answerEventId));
|
|
33
34
|
const clarifiedWithYou = new Set(answers.filter(a => answered.has(a.id) && !revisionIssues(revision).some(i => i.sourceIds.some(id => a.sourceIds.includes(id)))).map(a => a.issueId)).size;
|
|
34
|
-
return { version: 1, questions: questions.map(q => ({ ...q,
|
|
35
|
+
return { version: 1, questions: questions.map(q => ({ ...q,
|
|
36
|
+
// Deferral requires every affected unit to become deferred/kept; reopening
|
|
37
|
+
// any unit can reactivate an overlapping question. These effects differ.
|
|
38
|
+
relatedQuestions: questions.filter(other => other.state !== 'deferred' && other.unitIds.some(id => q.unitIds.includes(id)) && other.unitIds.every(id => q.unitIds.includes(id) || projection.items.some(u => u.id === id && (u.issue?.state === 'deferred' || u.disposition === 'keep_current')))).map(other => ({ id: other.question.id, version: other.question.version, title: other.question.title })),
|
|
39
|
+
reopenedQuestions: questions.filter(other => (other.state === 'deferred' || other.disposition === 'keep_current') && other.unitIds.some(id => q.unitIds.includes(id))).map(other => ({ id: other.question.id, version: other.question.version, title: other.question.title })),
|
|
40
|
+
position: activeQuestions.findIndex(a => a.question.id === q.question.id) + 1 })), activeQuestionIds: activeQuestions.map(q => q.question.id), activeQuestionCount: activeQuestions.length, deferredQuestionIds: questions.filter(q => q.state === 'deferred').map(q => q.question.id), answerLogHash: semanticHash(answers), clarifiedWithYou,
|
|
35
41
|
jobs, continuationJobId: actionable.find(j => pending(j.status))?.id, analysisFailed: actionable.some(j => !pending(j.status)), analysisPending: actionable.some(j => pending(j.status)) || !!awaiting && !actionable.some(j => !pending(j.status)),
|
|
36
42
|
canRetryAnalysis: actionable.some(j => j.canRetry || j.canResume),
|
|
37
43
|
nextAnalysisComponent: componentId, needsPreparation: !!componentId && !actionable.some(j => j.componentId === componentId),
|
|
@@ -44,9 +50,9 @@ export async function graphicalReviewPayload(root, review) {
|
|
|
44
50
|
const view = await curationReviewView(root, review.snapshot.plan, review);
|
|
45
51
|
return { action: 'curation', root, plan: review.snapshot.plan, ...view, graphical: await graphicalReviewState(root, view.review, view.projection), appReviewAvailable: true };
|
|
46
52
|
}
|
|
47
|
-
export async function graphicalReviewAction(root, raw) {
|
|
53
|
+
export async function graphicalReviewAction(root, raw, answerOrigin = 'app') {
|
|
48
54
|
const input = graphicalActionSchema.parse(raw);
|
|
49
|
-
|
|
55
|
+
const operation = async () => {
|
|
50
56
|
let review = await loadCurationReview(root, input.reviewId);
|
|
51
57
|
const revision = review.snapshot.revision;
|
|
52
58
|
await assertCurationHead(root, review);
|
|
@@ -69,6 +75,11 @@ export async function graphicalReviewAction(root, raw) {
|
|
|
69
75
|
review = await stageCurationDecisions(root, { ...input, decisions });
|
|
70
76
|
}
|
|
71
77
|
else if (input.action === 'question_later' || input.action === 'question_reopen') {
|
|
78
|
+
const baseline = deterministicBaseline(revision);
|
|
79
|
+
if ((await planKnowledgeCuration(root, { memorySyncSources: baseline.memorySyncSources })).planId !== baseline.planId)
|
|
80
|
+
throw new CurationReviewError('semantic_stale', 'Knowledge or evidence changed. Review the current question.');
|
|
81
|
+
if (input.action === 'question_reopen' && question.state !== 'deferred')
|
|
82
|
+
throw new CurationReviewError('review_changed', 'This question is not deferred. Reload the saved review.');
|
|
72
83
|
review = await stageEvent(root, input, input.action === 'question_later' ? 'defer_issue' : 'reopen_issue', Object.fromEntries(question.unitIds.map(id => [id, 'deferred'])));
|
|
73
84
|
}
|
|
74
85
|
else {
|
|
@@ -77,7 +88,7 @@ export async function graphicalReviewAction(root, raw) {
|
|
|
77
88
|
throw new CurationReviewError('semantic_invalid', 'Answer request identity is missing.');
|
|
78
89
|
if (!replay && (!question.needsInput || g.analysisPending))
|
|
79
90
|
throw new CurationReviewError('review_changed', 'This answer is already being reviewed.');
|
|
80
|
-
await appendCurationAnswer(root, { reviewId: input.reviewId, reviewRevisionId: input.reviewRevisionId, decisionRevision: input.decisionRevision, answerLogHash: input.answerLogHash, questionId: input.questionId, questionVersion: input.questionVersion, requestId: input.requestId, origin:
|
|
91
|
+
await appendCurationAnswer(root, { reviewId: input.reviewId, reviewRevisionId: input.reviewRevisionId, decisionRevision: input.decisionRevision, answerLogHash: input.answerLogHash, questionId: input.questionId, questionVersion: input.questionVersion, requestId: input.requestId, origin: answerOrigin, choiceId: input.choiceId, rawText: input.rawText, replacesAnswerId: input.replacesAnswerId });
|
|
81
92
|
// Answer is durable before job preparation. A capacity/dispatch failure cannot erase it.
|
|
82
93
|
view = await graphicalReviewPayload(root, review);
|
|
83
94
|
g = view.graphical;
|
|
@@ -89,7 +100,7 @@ export async function graphicalReviewAction(root, raw) {
|
|
|
89
100
|
throw new CurationReviewError('semantic_limit', 'No further automatic analysis is available. Review details or answer later.');
|
|
90
101
|
}
|
|
91
102
|
try {
|
|
92
|
-
const job = await prepareSemanticCuration(root, { reviewId: review.snapshot.reviewId, reviewRevisionId: revision.id, componentId: component, requestId: input.requestId ?? `app_${semanticHash([revision.id, review.state.revision, g.answerLogHash, component, input.action, ...(input.action === 'retry_analysis' ? [failed.id] : [])])}`, reason: input.action === 'retry_analysis' ? (failed.canRetry ? 'validation_correction' : 'resume') : input.action === 'answer' ? 'answer' : g.questions.some(q => q.state === 'answered_pending_analysis') ? 'resume' : 'initial', ...(input.action === 'retry_analysis'
|
|
103
|
+
const job = await prepareSemanticCuration(root, { reviewId: review.snapshot.reviewId, reviewRevisionId: revision.id, componentId: component, requestId: input.requestId ?? `app_${semanticHash([revision.id, review.state.revision, g.answerLogHash, component, input.action, ...(input.action === 'retry_analysis' ? [failed.id] : [])])}`, reason: input.action === 'retry_analysis' ? (failed.canRetry ? 'validation_correction' : 'resume') : input.action === 'answer' ? 'answer' : g.questions.some(q => q.state === 'answered_pending_analysis') ? 'resume' : 'initial', ...(input.action === 'retry_analysis' ? failed.canRetry ? { correctionForJobId: failed.id } : { resumeForJobId: failed.id } : {}) });
|
|
93
104
|
view = await graphicalReviewPayload(root, review);
|
|
94
105
|
return { ...view, continuationJobId: pending(job.status) ? job.jobId : undefined, analysisNotice: pending(job.status) ? undefined : 'No further progress was possible with the current information.' };
|
|
95
106
|
}
|
|
@@ -100,6 +111,7 @@ export async function graphicalReviewAction(root, raw) {
|
|
|
100
111
|
}
|
|
101
112
|
}
|
|
102
113
|
return graphicalReviewPayload(root, review);
|
|
103
|
-
}
|
|
114
|
+
};
|
|
115
|
+
return ownsSyncLock(root) ? operation() : withSyncLock(root, operation, { recoverLifecycle: false, waitMs: 2000 });
|
|
104
116
|
}
|
|
105
117
|
//# sourceMappingURL=curation-graphical.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Model-visible Sync question adapter. Staging only; no selection or Apply. */
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { graphicalActionSchema, graphicalReviewAction } from './curation-graphical.js';
|
|
4
|
+
import { syncCurationReview } from './memory-sync.js';
|
|
5
|
+
import { withSyncLock } from './sync-transaction.js';
|
|
6
|
+
import { curationAnswers } from './curation-semantic-jobs.js';
|
|
7
|
+
const owner = { workflowId: z.string().uuid(), candidateId: z.string().regex(/^area_[a-f0-9]{20}$/) };
|
|
8
|
+
const actions = z.enum(['analyze', 'answer', 'question_later', 'question_reopen', 'retry_analysis']);
|
|
9
|
+
export const curationQuestionSchema = z.union([
|
|
10
|
+
graphicalActionSchema.extend({ ...owner, action: actions }).strict(),
|
|
11
|
+
z.object({ ...owner, action: z.literal('inspect'), reviewId: z.string().regex(/^review_[a-zA-Z0-9_]+$/) }).strict(),
|
|
12
|
+
]);
|
|
13
|
+
// MCP requires an object-root schema. The strict action-specific union is
|
|
14
|
+
// checked inside the same operation before ownership or journal mutations.
|
|
15
|
+
export const curationQuestionToolSchema = graphicalActionSchema.extend({ ...owner, action: z.enum(['inspect', ...actions.options]), reviewRevisionId: graphicalActionSchema.shape.reviewRevisionId.optional(), planHash: graphicalActionSchema.shape.planHash.optional(), decisionRevision: graphicalActionSchema.shape.decisionRevision.optional(), answerLogHash: graphicalActionSchema.shape.answerLogHash.optional() }).strict();
|
|
16
|
+
export async function runCurationQuestion(root, raw) {
|
|
17
|
+
const input = curationQuestionSchema.parse(raw);
|
|
18
|
+
return withSyncLock(root, async () => {
|
|
19
|
+
const { workflowId, candidateId, ...request } = input;
|
|
20
|
+
const saved = await syncCurationReview(root, workflowId, candidateId, request.reviewId);
|
|
21
|
+
const payload = request.action === 'inspect' ? saved : await graphicalReviewAction(root, request, 'agent_relay');
|
|
22
|
+
const answer = request.action === 'answer' ? (await curationAnswers(root, payload.review.snapshot.revision.lineageId)).find(a => a.requestId === request.requestId) : undefined;
|
|
23
|
+
return { ...payload, action: 'curation-question', syncWorkflowId: workflowId, candidateId, appReviewAvailable: false,
|
|
24
|
+
...(answer ? { answerAcknowledgement: { answerId: answer.id, origin: answer.origin, saved: true, message: 'Relayed chat answer saved. It is evidence for staged analysis, not authenticated human intent or approval.' } } : {}),
|
|
25
|
+
next: { tool: 'prodero_memory_sync', arguments: { projectRoot: root, request: { action: 'inspect', workflowId } }, instructions: 'Inspect this saved Sync workflow after the bounded reasoning continuation. Relayed answers remain evidence, not approval; unresolved facts may still need clarification or deliberate deferral. Never Apply, promote or publish on the strength of an answer.' } };
|
|
26
|
+
}, { recoverLifecycle: false, waitMs: 2000 });
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=curation-question.js.map
|
|
@@ -29,6 +29,9 @@ export const semanticProposalSchema = z.object({
|
|
|
29
29
|
additionalEvidenceRequired: z.array(z.object({ sourceIds: refs, reason: text, grounding: refs }).strict()).max(16),
|
|
30
30
|
rationale: text, limitations: z.array(text).max(16),
|
|
31
31
|
}).strict();
|
|
32
|
+
/** Legacy analyses retain their original hash; only explicit recovery attempts
|
|
33
|
+
* add a predecessor so unchanged-input expiry cannot reuse an immutable job. */
|
|
34
|
+
export const semanticAttemptInputHash = (bundle, decisionRevision, correction, resumedFromJobId) => semanticHash([bundle.id, decisionRevision, bundle.answerLogHash, SEMANTIC_VALIDATOR, correction, ...(resumedFromJobId ? [resumedFromJobId] : [])]);
|
|
32
35
|
export class SemanticValidationError extends Error {
|
|
33
36
|
reason;
|
|
34
37
|
constructor(reason) {
|
|
@@ -201,7 +204,7 @@ export function materializeSemanticPlan(base, analyses) {
|
|
|
201
204
|
const { bundle, proposal } = analysis;
|
|
202
205
|
validateSemanticProposal(bundle, proposal);
|
|
203
206
|
requireThat(analysis.outputHash === semanticHash(proposal), 'analysis_output_hash');
|
|
204
|
-
requireThat(Number.isInteger(analysis.decisionRevision) && analysis.decisionRevision >= 0 && Number.isInteger(analysis.correction) && analysis.correction >= 0 && analysis.correction <= bundle.limits.maxCorrections && analysis.inputHash ===
|
|
207
|
+
requireThat((analysis.resumedFromJobId === undefined || /^semantic_job_[a-f0-9]{40}$/.test(analysis.resumedFromJobId) && analysis.resumedFromJobId !== analysis.jobId) && Number.isInteger(analysis.decisionRevision) && analysis.decisionRevision >= 0 && Number.isInteger(analysis.correction) && analysis.correction >= 0 && analysis.correction <= bundle.limits.maxCorrections && analysis.inputHash === semanticAttemptInputHash(bundle, analysis.decisionRevision, analysis.correction, analysis.resumedFromJobId) && analysis.jobId === `semantic_job_${semanticHash([bundle.lineageId, analysis.inputHash]).slice(0, 40)}`, 'analysis_input_hash');
|
|
205
208
|
for (const source of bundle.sources) {
|
|
206
209
|
const original = base.proposals.find(p => p.id === source.id);
|
|
207
210
|
if (source.editable)
|
|
@@ -10,11 +10,11 @@ import { loadMemoryCatalog } from './memory-index.js';
|
|
|
10
10
|
import { planKnowledgeCuration } from './knowledge-curation.js';
|
|
11
11
|
import { loadCurationReview, assertCurationHead, publishSemanticReview, CurationReviewError } from './curation-review.js';
|
|
12
12
|
import { deterministicBaseline, revisionIssues } from './curation-revision.js';
|
|
13
|
-
import { SEMANTIC_CONTRACT, SEMANTIC_VALIDATOR, SEMANTIC_INSTRUCTIONS, semanticProposalSchema, semanticLimitsSchema, semanticHash as hash, semanticClauses, measuredTokens, validateSemanticProposal, materializeSemanticPlan, SemanticValidationError } from './curation-semantic-contract.js';
|
|
13
|
+
import { SEMANTIC_CONTRACT, SEMANTIC_VALIDATOR, SEMANTIC_INSTRUCTIONS, semanticProposalSchema, semanticLimitsSchema, semanticHash as hash, semanticAttemptInputHash, semanticClauses, measuredTokens, validateSemanticProposal, materializeSemanticPlan, SemanticValidationError } from './curation-semantic-contract.js';
|
|
14
14
|
export const SEMANTIC_RETENTION = { maxJobs: 64, maxBytes: 2 * 1024 * 1024, maxAnswers: 128 };
|
|
15
15
|
export const jobStatuses = ['prepared', 'awaiting_reasoning', 'submitted', 'validated', 'rejected', 'stale', 'failed'];
|
|
16
16
|
const adapterSchema = z.object({ host: z.string().max(120).optional(), model: z.string().max(120).optional() }).strict();
|
|
17
|
-
export const semanticPrepareSchema = z.object({ reviewId: z.string().regex(/^review_[a-zA-Z0-9_]+$/), reviewRevisionId: z.string().regex(/^revision_[a-f0-9]{64}$/), componentId: z.string().regex(/^component_[a-f0-9]{24}$/), requestId: z.string().min(1).max(100), reason: z.enum(['initial', 'new_evidence', 'answer', 'validation_correction', 'resume']).default('initial'), correctionForJobId: z.string().regex(/^semantic_job_[a-f0-9]{40}$/).optional(), limits: semanticLimitsSchema.partial().optional(), adapter: adapterSchema.optional() }).strict();
|
|
17
|
+
export const semanticPrepareSchema = z.object({ reviewId: z.string().regex(/^review_[a-zA-Z0-9_]+$/), reviewRevisionId: z.string().regex(/^revision_[a-f0-9]{64}$/), componentId: z.string().regex(/^component_[a-f0-9]{24}$/), requestId: z.string().min(1).max(100), reason: z.enum(['initial', 'new_evidence', 'answer', 'validation_correction', 'resume']).default('initial'), correctionForJobId: z.string().regex(/^semantic_job_[a-f0-9]{40}$/).optional(), resumeForJobId: z.string().regex(/^semantic_job_[a-f0-9]{40}$/).optional(), limits: semanticLimitsSchema.partial().optional(), adapter: adapterSchema.optional() }).strict();
|
|
18
18
|
const dir = 'local/curation-reasoning';
|
|
19
19
|
const reviewLock = (root, operation, options) => ownsSyncLock(root) ? operation() : withSyncLock(root, operation, options);
|
|
20
20
|
const last = (job) => job.events.at(-1);
|
|
@@ -96,7 +96,7 @@ export async function appendCurationAnswer(root, input) {
|
|
|
96
96
|
return reject('semantic_invalid', 'Unknown answer choice.');
|
|
97
97
|
const events = await curationAnswers(root, revision.lineageId), prior = events.find(e => e.requestId === input.requestId);
|
|
98
98
|
if (prior) {
|
|
99
|
-
if (prior.questionId !== input.questionId || prior.choice?.id !== input.choiceId || prior.rawText !== input.rawText)
|
|
99
|
+
if (prior.reviewRevisionId !== input.reviewRevisionId || prior.questionId !== input.questionId || prior.questionVersion !== input.questionVersion || prior.choice?.id !== input.choiceId || prior.rawText !== input.rawText || prior.replacesAnswerId !== input.replacesAnswerId || prior.origin !== input.origin)
|
|
100
100
|
return reject('review_changed', 'Answer request changed.');
|
|
101
101
|
return prior;
|
|
102
102
|
}
|
|
@@ -161,35 +161,95 @@ async function makeBundle(root, review, componentId, limits) {
|
|
|
161
161
|
return bundle;
|
|
162
162
|
}
|
|
163
163
|
function response(job) { const { bundle, ...input } = job.input; const history = job.events.map(({ proposal, ...event }) => event); return { jobId: job.input.jobId, status: last(job).status, job: { ...job, input, events: history }, task: { instructions: SEMANTIC_INSTRUCTIONS, proposalSchema: zodToJsonSchema(semanticProposalSchema, { $refStrategy: 'none' }), data: bundle }, acceptance: 'staged_candidate_only', validation: last(job).reason, resultReviewId: last(job).resultReviewId }; }
|
|
164
|
+
const passCount = (history, lineageId) => history.filter(j => j.input.lineageId === lineageId && j.input.reason !== 'validation_correction').length;
|
|
165
|
+
const passLimit = (history, lineageId, limits) => Math.min(limits.maxPasses, ...history.filter(j => j.input.lineageId === lineageId).map(j => j.input.bundle.limits.maxPasses));
|
|
166
|
+
function boundedRecoveryLimits(previous, requested) {
|
|
167
|
+
const limits = semanticLimitsSchema.parse({ ...previous.input.bundle.limits, ...requested });
|
|
168
|
+
return Object.fromEntries(Object.entries(limits).map(([key, value]) => [key, Math.min(value, previous.input.bundle.limits[key])]));
|
|
169
|
+
}
|
|
170
|
+
const semanticInputIdentity = (bundle) => hash([bundle.expectedSources, bundle.sources, bundle.evidence, bundle.answers, bundle.signals, bundle.missingFacts, bundle.completeness, SEMANTIC_CONTRACT, SEMANTIC_VALIDATOR]);
|
|
171
|
+
/** Retry affordances use the same persisted budgets as preparation. Expiry is
|
|
172
|
+
* independent of decision revision; neither may reset a correction allowance. */
|
|
173
|
+
export function semanticRetryState(job, review, history) {
|
|
174
|
+
const room = history.length < SEMANTIC_RETENTION.maxJobs, status = semanticJobStatus(job, review);
|
|
175
|
+
return { canRetry: room && status === 'rejected' && job.input.decisionRevision === review.state.revision && job.input.correction < job.input.bundle.limits.maxCorrections,
|
|
176
|
+
canResume: room && status === 'stale' && ['prepared', 'awaiting_reasoning', 'submitted', 'stale'].includes(last(job).status) && passCount(history, job.input.lineageId) < passLimit(history, job.input.lineageId, job.input.bundle.limits) };
|
|
177
|
+
}
|
|
164
178
|
export async function prepareSemanticCuration(root, raw) {
|
|
165
179
|
const input = semanticPrepareSchema.parse(raw);
|
|
166
180
|
return reviewLock(root, async () => {
|
|
167
|
-
const review = await current(root, input.reviewId, input.reviewRevisionId),
|
|
168
|
-
const
|
|
181
|
+
const review = await current(root, input.reviewId, input.reviewRevisionId), history = await semanticJobs(root);
|
|
182
|
+
const targetId = input.reason === 'validation_correction' ? input.correctionForJobId : input.reason === 'resume' ? input.resumeForJobId : undefined;
|
|
183
|
+
const target = targetId ? history.find(j => j.input.jobId === targetId) : undefined;
|
|
184
|
+
if (targetId && (!target || target.input.lineageId !== review.snapshot.revision.lineageId || target.input.bundle.componentId !== input.componentId))
|
|
185
|
+
return reject('semantic_invalid', 'Recovery job does not belong to this component.');
|
|
186
|
+
let limits = semanticLimitsSchema.parse(input.limits ?? {});
|
|
187
|
+
if (target)
|
|
188
|
+
limits = boundedRecoveryLimits(target, input.limits);
|
|
189
|
+
let bundle = await makeBundle(root, review, input.componentId, limits), semanticInputHash = semanticInputIdentity(bundle);
|
|
190
|
+
const matches = history.filter(j => j.input.semanticInputHash === semanticInputHash && j.input.lineageId === bundle.lineageId && j.input.bundle.componentId === bundle.componentId && !history.some(next => next.input.previousJobId === j.input.jobId)).sort((a, b) => b.input.createdAt.localeCompare(a.input.createdAt));
|
|
191
|
+
let same = target ?? matches.find(j => j.input.reviewRevisionId === input.reviewRevisionId && j.input.decisionRevision === review.state.revision) ?? matches[0];
|
|
192
|
+
if (input.reason === 'resume' && !same) {
|
|
193
|
+
// Older callers have no explicit predecessor field. Resolve a unique leaf
|
|
194
|
+
// against full source/answer bindings, then inherit omitted input limits.
|
|
195
|
+
// A changed answer is a fresh analysis, not recovery of that old attempt.
|
|
196
|
+
const candidates = history.filter(j => j.input.lineageId === bundle.lineageId && j.input.bundle.componentId === input.componentId && ['prepared', 'awaiting_reasoning', 'submitted', 'stale'].includes(last(j).status) && !history.some(next => next.input.previousJobId === j.input.jobId) && hash(j.input.bundle.expectedSources) === hash(bundle.expectedSources) && j.input.bundle.answerLogHash === bundle.answerLogHash);
|
|
197
|
+
if (candidates.length > 1)
|
|
198
|
+
return reject('semantic_stale', 'Recovery is ambiguous. Reload the review and retry the exact analysis job.');
|
|
199
|
+
if (candidates.length) {
|
|
200
|
+
same = candidates[0];
|
|
201
|
+
limits = boundedRecoveryLimits(same, input.limits);
|
|
202
|
+
bundle = await makeBundle(root, review, input.componentId, limits);
|
|
203
|
+
semanticInputHash = semanticInputIdentity(bundle);
|
|
204
|
+
if (semanticInputHash !== same.input.semanticInputHash)
|
|
205
|
+
return reject('semantic_stale', 'Reasoning evidence changed. Prepare a fresh analysis.');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
169
208
|
const priorRequest = history.find(j => j.input.requestId === input.requestId && j.input.lineageId === bundle.lineageId);
|
|
170
209
|
if (priorRequest) {
|
|
171
210
|
if (priorRequest.input.semanticInputHash !== semanticInputHash || priorRequest.input.reviewRevisionId !== input.reviewRevisionId)
|
|
172
211
|
return reject('review_changed', 'Reasoning request changed.');
|
|
173
212
|
return response(priorRequest);
|
|
174
213
|
}
|
|
175
|
-
|
|
176
|
-
const same = matches.find(j => j.input.reviewRevisionId === input.reviewRevisionId && j.input.decisionRevision === review.state.revision) ?? matches[0];
|
|
177
|
-
let correction = 0;
|
|
214
|
+
let correction = 0, previous;
|
|
178
215
|
if (input.reason === 'validation_correction') {
|
|
179
|
-
|
|
180
|
-
if (!previous || last(previous).status !== 'rejected' || previous.input.semanticInputHash !== semanticInputHash || previous.input.correction >= previous.input.bundle.limits.maxCorrections)
|
|
216
|
+
previous = history.find(j => j.input.jobId === input.correctionForJobId);
|
|
217
|
+
if (!previous || last(previous).status !== 'rejected' || previous.input.lineageId !== bundle.lineageId || previous.input.bundle.componentId !== bundle.componentId || previous.input.semanticInputHash !== semanticInputHash || previous.input.correction >= previous.input.bundle.limits.maxCorrections)
|
|
181
218
|
return reject('semantic_limit', 'No correction retry remains.');
|
|
219
|
+
if (previous.input.reviewRevisionId !== input.reviewRevisionId || previous.input.decisionRevision !== review.state.revision)
|
|
220
|
+
return reject('semantic_stale', 'Reasoning decisions changed. Reload this review.');
|
|
182
221
|
correction = previous.input.correction + 1;
|
|
183
|
-
const retry = history.find(j => j.input.previousJobId ===
|
|
222
|
+
const previousId = previous.input.jobId, retry = history.find(j => j.input.previousJobId === previousId);
|
|
184
223
|
if (retry)
|
|
185
224
|
return response(retry);
|
|
186
225
|
}
|
|
187
|
-
else if (same
|
|
188
|
-
|
|
189
|
-
|
|
226
|
+
else if (same) {
|
|
227
|
+
if (target && same.input.semanticInputHash !== semanticInputHash)
|
|
228
|
+
return reject('semantic_stale', 'Reasoning evidence or answers changed. Prepare a fresh analysis.');
|
|
229
|
+
const successor = history.find(j => j.input.previousJobId === same.input.jobId);
|
|
230
|
+
if (target && successor)
|
|
231
|
+
return response(successor);
|
|
232
|
+
if (input.reason !== 'resume' || !['prepared', 'awaiting_reasoning', 'submitted', 'stale'].includes(last(same).status) || semanticJobStatus(same, review) !== 'stale')
|
|
233
|
+
return response(same);
|
|
234
|
+
previous = same;
|
|
235
|
+
correction = previous.input.correction;
|
|
236
|
+
}
|
|
237
|
+
if (previous) {
|
|
238
|
+
// Recovery may tighten limits but cannot silently expand the previous budget.
|
|
239
|
+
limits = boundedRecoveryLimits(previous, input.limits);
|
|
240
|
+
bundle = await makeBundle(root, review, input.componentId, limits);
|
|
241
|
+
semanticInputHash = semanticInputIdentity(bundle);
|
|
242
|
+
if (semanticInputHash !== previous.input.semanticInputHash)
|
|
243
|
+
return reject('semantic_stale', 'Reasoning evidence or answers changed. Prepare a fresh analysis.');
|
|
244
|
+
}
|
|
245
|
+
if (correction > limits.maxCorrections)
|
|
246
|
+
return reject('semantic_limit', 'No correction retry remains within the requested limits.');
|
|
247
|
+
if (history.length >= SEMANTIC_RETENTION.maxJobs || input.reason !== 'validation_correction' && passCount(history, bundle.lineageId) >= passLimit(history, bundle.lineageId, limits))
|
|
190
248
|
return reject('semantic_limit', 'Reasoning budget reached; pending history was preserved.');
|
|
191
|
-
|
|
192
|
-
|
|
249
|
+
// A TTL-only resume has identical sources/revision. Bind it to its predecessor
|
|
250
|
+
// so it gets a distinct immutable job instead of overwriting expired history.
|
|
251
|
+
const createdAt = new Date().toISOString(), inputHash = semanticAttemptInputHash(bundle, review.state.revision, correction, input.reason === 'resume' ? previous?.input.jobId : undefined), jobId = `semantic_job_${hash([bundle.lineageId, inputHash]).slice(0, 40)}`;
|
|
252
|
+
const body = { jobId, projectIdentity: review.snapshot.projectIdentity, reviewId: input.reviewId, reviewRevisionId: input.reviewRevisionId, lineageId: bundle.lineageId, decisionRevision: review.state.revision, bundleId: bundle.id, bundleHash: hash(bundle), bundle, contractVersion: SEMANTIC_CONTRACT, validatorVersion: SEMANTIC_VALIDATOR, requestId: input.requestId, reason: input.reason, inputHash, semanticInputHash, createdAt, expiresAt: new Date(Date.now() + limits.ttlMs).toISOString(), correction, ...(previous ? { previousJobId: previous.input.jobId } : {}), ...(input.adapter ? { adapter: input.adapter } : {}) };
|
|
193
253
|
const job = { version: 1, input: body, inputSeal: hash(body), events: [] };
|
|
194
254
|
await event(root, job, 'prepared');
|
|
195
255
|
await event(root, job, 'awaiting_reasoning');
|
|
@@ -239,7 +299,7 @@ export async function submitSemanticCuration(root, input) {
|
|
|
239
299
|
if (!submission)
|
|
240
300
|
await event(root, job, 'submitted', { outputHash, proposal: input.proposal });
|
|
241
301
|
try {
|
|
242
|
-
const proposal = validateSemanticProposal(job.input.bundle, input.proposal), accepted = { jobId: job.input.jobId, inputHash: job.input.inputHash, outputHash, decisionRevision: job.input.decisionRevision, correction: job.input.correction, bundle: job.input.bundle, proposal }, previous = review.snapshot.revision.typedPlan;
|
|
302
|
+
const proposal = validateSemanticProposal(job.input.bundle, input.proposal), accepted = { jobId: job.input.jobId, inputHash: job.input.inputHash, outputHash, decisionRevision: job.input.decisionRevision, correction: job.input.correction, ...(job.input.reason === 'resume' && job.input.previousJobId ? { resumedFromJobId: job.input.previousJobId } : {}), bundle: job.input.bundle, proposal }, previous = review.snapshot.revision.typedPlan;
|
|
243
303
|
const analyses = [...(previous.kind === 'semantic-v2' ? previous.analyses : []), accepted], basePlan = deterministicBaseline(review.snapshot.revision), plan = materializeSemanticPlan(basePlan, analyses);
|
|
244
304
|
const next = await publishSemanticReview(root, review, { kind: 'semantic-v2', validator: SEMANTIC_VALIDATOR, basePlan, analyses, plan }, job.input.jobId);
|
|
245
305
|
await event(root, job, 'validated', { outputHash, resultReviewId: next.snapshot.reviewId, resultRevisionId: next.snapshot.revision.id, reason: proposal.sources.every(s => s.disposition !== 'proposed') ? 'terminal_no_rewrite' : 'structurally_validated_candidate' });
|
|
@@ -258,7 +318,7 @@ export async function submitSemanticCuration(root, input) {
|
|
|
258
318
|
}
|
|
259
319
|
}, { recoverLifecycle: false, waitMs: 2000 });
|
|
260
320
|
}
|
|
261
|
-
export function semanticJobStatus(job, review) { const status = last(job).status; return ['prepared', 'awaiting_reasoning', 'submitted'].includes(status) && (job.input.decisionRevision !== review.state.revision || Date.now() > Date.parse(job.input.expiresAt)) ? 'stale' : status; }
|
|
321
|
+
export function semanticJobStatus(job, review) { const status = last(job).status; return ['prepared', 'awaiting_reasoning', 'submitted'].includes(status) && (job.input.reviewRevisionId !== review.snapshot.revision?.id || job.input.decisionRevision !== review.state.revision || Date.now() > Date.parse(job.input.expiresAt)) ? 'stale' : status; }
|
|
262
322
|
/** Unchanged supporting records must not keep a decided component's job active. */
|
|
263
323
|
export function semanticJobSuspended(job, review) {
|
|
264
324
|
const sourceIds = new Set(job.input.bundle.dependencies.groups.flatMap(g => g.proposalIds));
|
|
@@ -4,7 +4,6 @@ import { isPathScope } from "./path-scope.js";
|
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
5
|
import { loadMemoryCatalog } from "./memory-index.js";
|
|
6
6
|
import { sha256, exists, projectDataPath, writeFileAtomic } from "./storage.js";
|
|
7
|
-
const MEMORY_ID = /^mem_[a-zA-Z0-9_]+$/;
|
|
8
7
|
const EDGE_WEIGHT = {
|
|
9
8
|
depends_on: 24,
|
|
10
9
|
implements: 20,
|
|
@@ -24,7 +23,7 @@ function validCache(value, digest) {
|
|
|
24
23
|
if (!value || typeof value !== "object")
|
|
25
24
|
return false;
|
|
26
25
|
const cache = value;
|
|
27
|
-
return cache.version ===
|
|
26
|
+
return cache.version === 2 && cache.sourceDigest === digest
|
|
28
27
|
&& Array.isArray(cache.nodes) && cache.nodes.every((node) => typeof node === "string")
|
|
29
28
|
&& Array.isArray(cache.brokenReferences) && cache.brokenReferences.every((entry) => typeof entry === "string")
|
|
30
29
|
&& Array.isArray(cache.edges) && cache.edges.every((edge) => edge && typeof edge.from === "string" && typeof edge.to === "string"
|
|
@@ -56,7 +55,9 @@ function build(summaries, archives) {
|
|
|
56
55
|
const broken = new Set();
|
|
57
56
|
const add = (from, to, type, inferred = false) => {
|
|
58
57
|
const needsMemoryTarget = type === "related_to" || type === "depends_on" || type === "implements" || type === "supersedes" || type === "superseded_by" || type === "consolidates" || type === "consolidated_into" || type === "contradicts";
|
|
59
|
-
|
|
58
|
+
// IDs have already passed storage's canonicalId contract. Historical and
|
|
59
|
+
// imported IDs need not use the generated mem_ prefix.
|
|
60
|
+
if (needsMemoryTarget && !knownIds.has(to)) {
|
|
60
61
|
broken.add(`${from}:${type}:${to}`);
|
|
61
62
|
return;
|
|
62
63
|
}
|
|
@@ -127,7 +128,7 @@ function build(summaries, archives) {
|
|
|
127
128
|
add(item.id, candidate.target.id, candidate.type, true);
|
|
128
129
|
}
|
|
129
130
|
return {
|
|
130
|
-
version:
|
|
131
|
+
version: 2,
|
|
131
132
|
sourceDigest: graphSourceDigest(summaries, archives),
|
|
132
133
|
nodes,
|
|
133
134
|
edges: [...edges.values()].sort((a, b) => a.from.localeCompare(b.from) || a.type.localeCompare(b.type) || a.to.localeCompare(b.to) || Number(a.inferred) - Number(b.inferred)),
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { modeSchema, themeSchema } from '../settings-definition.js';
|
|
3
|
+
import { assertSafePath, projectDataPath, ensureSelfIgnoredPrivateDirectory, writeFileAtomic } from './storage.js';
|
|
4
|
+
import { ownsSyncLock } from './sync-transaction.js';
|
|
5
|
+
export const LOCAL_PREFERENCES_VERSION = 1;
|
|
6
|
+
const relativePath = 'local/preferences.json';
|
|
7
|
+
const object = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
// Extensions may round-trip, but a local file cannot masquerade as shared project policy.
|
|
9
|
+
const sharedKeys = new Set(['context', 'knowledgeEngine', 'schemaVersion', 'knowledgeSchemaVersion', 'initializedWith', 'lastUpdatedWith', 'projectId', 'createdAt', 'contextPolicy', 'authority', '__proto__', 'constructor', 'prototype']);
|
|
10
|
+
const forbidden = (key) => sharedKeys.has(key.split('.')[0]);
|
|
11
|
+
const diagnostic = (code, cause) => ({ code, cause,
|
|
12
|
+
recovery: 'Preserve .prodero/local/preferences.json; repair it explicitly or use a compatible Curator before saving preferences.' });
|
|
13
|
+
/** Read-only: no creation, legacy copy, permission changes, recovery or cleanup. */
|
|
14
|
+
export async function readLocalPreferences(root) {
|
|
15
|
+
const fallback = { presentation: {}, diagnostics: [] };
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
const target = projectDataPath(root, relativePath);
|
|
19
|
+
await assertSafePath(target);
|
|
20
|
+
const stat = await fs.lstat(target);
|
|
21
|
+
if (!stat.isFile() || stat.nlink !== 1)
|
|
22
|
+
throw new Error('unsafe preference file');
|
|
23
|
+
raw = JSON.parse(await fs.readFile(target, 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code === 'ENOENT')
|
|
27
|
+
return fallback;
|
|
28
|
+
return { ...fallback, diagnostics: [diagnostic('PRODERO_LOCAL_PREFERENCES_UNAVAILABLE', 'Local preferences could not be safely read. Legacy project defaults are used.')] };
|
|
29
|
+
}
|
|
30
|
+
if (!object(raw) || raw.version !== LOCAL_PREFERENCES_VERSION) {
|
|
31
|
+
const newer = object(raw) && Number.isInteger(raw.version) && Number(raw.version) > LOCAL_PREFERENCES_VERSION;
|
|
32
|
+
return { ...fallback, diagnostics: [diagnostic(newer ? 'PRODERO_LOCAL_PREFERENCES_NEWER' : 'PRODERO_LOCAL_PREFERENCES_INVALID', newer ? 'Local preferences require a newer Curator. Legacy project defaults are used.' : 'Local preferences have an invalid format. Legacy project defaults are used.')] };
|
|
33
|
+
}
|
|
34
|
+
if (Object.keys(raw).some(forbidden) || (raw.presentation !== undefined && (!object(raw.presentation) || Object.keys(raw.presentation).some(forbidden)))) {
|
|
35
|
+
return { ...fallback, diagnostics: [diagnostic('PRODERO_LOCAL_PREFERENCES_INVALID', 'Local preferences must contain presentation overrides only; shared policy or identity overrides are not supported.')] };
|
|
36
|
+
}
|
|
37
|
+
const presentation = {}, diagnostics = [];
|
|
38
|
+
for (const [key, schema] of [['mode', modeSchema], ['theme', themeSchema]]) {
|
|
39
|
+
const value = raw.presentation?.[key];
|
|
40
|
+
if (value === undefined)
|
|
41
|
+
continue;
|
|
42
|
+
const parsed = schema.safeParse(value);
|
|
43
|
+
if (parsed.success)
|
|
44
|
+
Object.assign(presentation, { [key]: parsed.data });
|
|
45
|
+
else
|
|
46
|
+
diagnostics.push(diagnostic('PRODERO_LOCAL_PREFERENCES_INVALID', `Local presentation.${key} is invalid; its legacy project default is used.`));
|
|
47
|
+
}
|
|
48
|
+
// Valid sibling fields can resolve, but writes never overwrite a damaged document.
|
|
49
|
+
return { presentation, diagnostics, ...(diagnostics.length ? {} : { document: raw }) };
|
|
50
|
+
}
|
|
51
|
+
/** Caller owns the shared Sync/migration lock and has checked canonical write gates. */
|
|
52
|
+
export async function saveLocalPreference(root, key, value, reset) {
|
|
53
|
+
if (!ownsSyncLock(root))
|
|
54
|
+
throw new Error('Local preferences require the shared project writer lock.');
|
|
55
|
+
if (key !== 'mode' && key !== 'theme')
|
|
56
|
+
throw new Error('Only presentation mode and theme may be saved as local preferences.');
|
|
57
|
+
if (reset && value !== undefined)
|
|
58
|
+
throw new Error('Reset requires no value.');
|
|
59
|
+
const parsed = reset ? undefined : (key === 'mode' ? modeSchema : themeSchema).parse(value);
|
|
60
|
+
const current = await readLocalPreferences(root);
|
|
61
|
+
if (current.diagnostics.length) {
|
|
62
|
+
const issue = current.diagnostics[0];
|
|
63
|
+
throw new Error(`${issue.code}: ${issue.cause} ${issue.recovery}`);
|
|
64
|
+
}
|
|
65
|
+
if (reset && current.document?.presentation?.[key] === undefined)
|
|
66
|
+
return;
|
|
67
|
+
const document = current.document ?? { version: LOCAL_PREFERENCES_VERSION };
|
|
68
|
+
const presentation = { ...document.presentation };
|
|
69
|
+
if (reset)
|
|
70
|
+
delete presentation[key];
|
|
71
|
+
else
|
|
72
|
+
presentation[key] = parsed;
|
|
73
|
+
await ensureSelfIgnoredPrivateDirectory(root, 'local');
|
|
74
|
+
await writeFileAtomic(projectDataPath(root, relativePath), `${JSON.stringify({ ...document, presentation }, null, 2)}\n`);
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=local-preferences.js.map
|
|
@@ -21,6 +21,8 @@ export function memoryHealth(summaries, now = new Date()) {
|
|
|
21
21
|
const active = summaries.filter((item) => item.status === "active");
|
|
22
22
|
const disputedItems = summaries.filter((item) => item.status === "disputed").length;
|
|
23
23
|
const staleActiveItems = active.filter((item) => isStaleSummary(item, now)).length;
|
|
24
|
+
// Compatibility counter: incomplete source/observation metadata, not evidence
|
|
25
|
+
// that source history was deleted. Do not infer or rewrite provenance here.
|
|
24
26
|
const missingProvenanceItems = active.filter((item) => (item.sourceSessionCount === 0 && (item.sourceEvidenceCount ?? 0) === 0) || item.derivedFromCount === 0).length;
|
|
25
27
|
const oversizedActiveItems = active.filter((item) => item.estimatedTokens > MAX_CANONICAL_ITEM_TOKENS).length;
|
|
26
28
|
return {
|
|
@@ -10,12 +10,16 @@ export async function previewMemoryLinkRepair(root, createdAt = new Date().toISO
|
|
|
10
10
|
const graph = await loadKnowledgeGraph(root, undefined, { persistCache: false });
|
|
11
11
|
const broken = new Set(graph.brokenReferences);
|
|
12
12
|
const inventory = await readKnowledgeInventory(root);
|
|
13
|
+
const knownIds = new Set(inventory.map(r => r.item.id));
|
|
14
|
+
// Derived diagnostics are hints, never authority to remove a canonical link.
|
|
15
|
+
const missingReferences = new Set();
|
|
13
16
|
// Bound each exact review; remaining links are offered by the next Sync.
|
|
14
17
|
const changes = inventory.filter(r => r.storage === 'current').flatMap(r => {
|
|
15
18
|
const links = new Map();
|
|
16
19
|
const missing = (type, target) => {
|
|
17
|
-
if (!broken.has(`${r.item.id}:${type}:${target}`))
|
|
20
|
+
if (knownIds.has(target) || !broken.has(`${r.item.id}:${type}:${target}`))
|
|
18
21
|
return false;
|
|
22
|
+
missingReferences.add(`${r.item.id}:${type}:${target}`);
|
|
19
23
|
links.set(`${type}:${target}`, { type, target });
|
|
20
24
|
return true;
|
|
21
25
|
};
|
|
@@ -28,7 +32,7 @@ export async function previewMemoryLinkRepair(root, createdAt = new Date().toISO
|
|
|
28
32
|
}).slice(0, 100);
|
|
29
33
|
const count = changes.reduce((n, c) => n + c.links.length, 0);
|
|
30
34
|
const payload = { root: await fs.realpath(root), createdAt, changes, count,
|
|
31
|
-
remaining:
|
|
35
|
+
remaining: missingReferences.size - count, requiresVersionedReview: changes.some(c => c.layer === 'versioned') };
|
|
32
36
|
return { ...payload, previewId: digest(payload) };
|
|
33
37
|
}
|
|
34
38
|
/** Called only under Sync's lock, after its exact review approval has been persisted. */
|