fraim-hub 2.0.346 → 2.0.348
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/dist/registry/scripts/learning/completion-state.js +80 -4
- package/dist/registry/scripts/learning/corpus-audit.js +7 -7
- package/dist/registry/scripts/learning/entry-format.js +10 -1
- package/dist/registry/scripts/learning/outcome-types.js +92 -0
- package/dist/registry/scripts/learning/owner-identity.js +11 -0
- package/dist/registry/scripts/learning/proposal-queue.js +81 -0
- package/dist/src/ai-hub/hosts.js +35 -1
- package/dist/src/ai-hub/hub-app-materializer.js +20 -0
- package/dist/src/ai-hub/server.js +9 -3
- package/dist/src/local-mcp-server/learning-context-builder.js +91 -377
- package/dist/src/local-mcp-server/learning-entry-format.js +25 -0
- package/dist/src/local-mcp-server/learning-usage-eligibility.js +8 -14
- package/dist/src/local-mcp-server/learning-usage-projection.js +5 -22
- package/dist/src/local-mcp-server/learning-usage-store.js +93 -393
- package/package.json +7 -2
- package/public/ai-hub/review.css +1 -0
- package/public/ai-hub/script.js +157 -184
- package/public/ai-hub/styles.css +1 -0
|
@@ -4,10 +4,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.COMPLETION_END = exports.COMPLETION_START = exports.UnsupportedRunHistoryError = exports.sourcePathKey = void 0;
|
|
7
|
+
exports.validateDeferredAttempt = validateDeferredAttempt;
|
|
8
|
+
exports.validateDeferredWork = validateDeferredWork;
|
|
7
9
|
exports.cycleRetrospectivePath = cycleRetrospectivePath;
|
|
8
10
|
exports.validateProcessedSources = validateProcessedSources;
|
|
9
11
|
exports.canonicalJournal = canonicalJournal;
|
|
10
12
|
exports.readRunJournal = readRunJournal;
|
|
13
|
+
exports.readRunReservations = readRunReservations;
|
|
11
14
|
exports.readSourceReservations = readSourceReservations;
|
|
12
15
|
exports.normalizeSourceParticipants = normalizeSourceParticipants;
|
|
13
16
|
exports.sourceAuthorAllowed = sourceAuthorAllowed;
|
|
@@ -17,6 +20,33 @@ const node_fs_1 = __importDefault(require("node:fs"));
|
|
|
17
20
|
const node_crypto_1 = require("node:crypto");
|
|
18
21
|
const artifact_paths_1 = require("../artifact-paths");
|
|
19
22
|
const corpus_audit_1 = require("./corpus-audit");
|
|
23
|
+
const outcome_types_1 = require("./outcome-types");
|
|
24
|
+
function validateDeferredAttempt(value) {
|
|
25
|
+
if (!keys(value, ['at', 'evidenceHash', 'deferred', 'reason']) || !text(value.at) || !Number.isFinite(Date.parse(value.at)) || !hash(value.evidenceHash))
|
|
26
|
+
throw new Error('invalid-deferred-attempt');
|
|
27
|
+
// Older journals embedded the checkpoint snapshot; keep accepting those exact records.
|
|
28
|
+
if (value.deferred !== undefined && value.reason === undefined)
|
|
29
|
+
validateDeferredWork(value.deferred);
|
|
30
|
+
else if (value.deferred !== undefined || !text(value.reason))
|
|
31
|
+
throw new Error('invalid-deferred-attempt');
|
|
32
|
+
}
|
|
33
|
+
function validateDeferredWork(value) {
|
|
34
|
+
if (!keys(value, ['actions', 'sources', 'corpusComplete', 'corpusErrors']) || value.corpusComplete !== false || !Array.isArray(value.actions) || !Array.isArray(value.sources))
|
|
35
|
+
throw new Error('invalid-deferred-work');
|
|
36
|
+
if (value.corpusErrors !== undefined && (!Array.isArray(value.corpusErrors) || value.corpusErrors.some(item => !keys(item, ['path', 'reason']) || !text(item.path) || !node_path_1.default.isAbsolute(item.path) || !text(item.reason))))
|
|
37
|
+
throw new Error('invalid-deferred-work');
|
|
38
|
+
const actions = new Set(), sources = new Set();
|
|
39
|
+
for (const item of value.actions) {
|
|
40
|
+
if (!keys(item, ['actionId', 'revision', 'digest', 'reason']) || !text(item.actionId) || !Number.isInteger(item.revision) || item.revision < 1 || !hash(item.digest) || !text(item.reason) || actions.has(item.actionId))
|
|
41
|
+
throw new Error('invalid-deferred-work');
|
|
42
|
+
actions.add(item.actionId);
|
|
43
|
+
}
|
|
44
|
+
for (const item of value.sources) {
|
|
45
|
+
if (!keys(item, ['path', 'owner', 'reason', 'sha256']) || !text(item.path) || !node_path_1.default.isAbsolute(item.path) || /[\x00-\x1f]/.test(item.path) || !ownerValid(item.owner) || !text(item.reason) || (item.sha256 !== undefined && !hash(item.sha256)) || sources.has((0, exports.sourcePathKey)(item.path)))
|
|
46
|
+
throw new Error('invalid-deferred-work');
|
|
47
|
+
sources.add((0, exports.sourcePathKey)(item.path));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
20
50
|
const sourcePathKey = (file) => {
|
|
21
51
|
const value = node_path_1.default.resolve(file).replace(/\\/g, '/');
|
|
22
52
|
return process.platform === 'win32' ? value.toLowerCase() : value;
|
|
@@ -145,9 +175,10 @@ function readRunJournal(file) {
|
|
|
145
175
|
}
|
|
146
176
|
return { events, lastHash, format: 'current' };
|
|
147
177
|
}
|
|
148
|
-
/**
|
|
149
|
-
function
|
|
178
|
+
/** Current archive plans and prepared assessments share one verified reservation inventory. */
|
|
179
|
+
function readRunReservations(runDirectories, excludingRunFile) {
|
|
150
180
|
const reserved = new Set();
|
|
181
|
+
const assessmentKeys = new Set();
|
|
151
182
|
for (const directory of new Set(runDirectories.map(dir => node_path_1.default.resolve(dir)))) {
|
|
152
183
|
let names;
|
|
153
184
|
try {
|
|
@@ -168,6 +199,23 @@ function readSourceReservations(runDirectories, excludingRunFile) {
|
|
|
168
199
|
const journal = readRunJournal(file);
|
|
169
200
|
if (journal.format === 'historical')
|
|
170
201
|
continue;
|
|
202
|
+
const assessmentSources = new Map();
|
|
203
|
+
for (const event of journal.events) {
|
|
204
|
+
if (event.payload.type === 'assessment-prepared') {
|
|
205
|
+
const { record, sourcePaths } = event.payload.data;
|
|
206
|
+
(0, outcome_types_1.validateOutcomeAssessmentRecord)(record);
|
|
207
|
+
if (!Array.isArray(sourcePaths) || sourcePaths.length !== record.sourceRefs.length || sourcePaths.some(file => typeof file !== 'string' || !node_path_1.default.isAbsolute(file)))
|
|
208
|
+
throw new Error('invalid-assessment-sources');
|
|
209
|
+
assessmentSources.set(record.digest, { paths: sourcePaths, key: (0, outcome_types_1.assessmentKey)(record) });
|
|
210
|
+
}
|
|
211
|
+
else if (event.payload.type === 'assessment-committed')
|
|
212
|
+
assessmentSources.delete(event.payload.data.digest);
|
|
213
|
+
}
|
|
214
|
+
for (const reservation of assessmentSources.values()) {
|
|
215
|
+
assessmentKeys.add(reservation.key);
|
|
216
|
+
for (const source of reservation.paths)
|
|
217
|
+
reserved.add((0, exports.sourcePathKey)(source));
|
|
218
|
+
}
|
|
171
219
|
for (const event of journal.events)
|
|
172
220
|
if (event.payload.type === 'source-completion') {
|
|
173
221
|
for (const source of event.payload.data) {
|
|
@@ -183,7 +231,10 @@ function readSourceReservations(runDirectories, excludingRunFile) {
|
|
|
183
231
|
}
|
|
184
232
|
}
|
|
185
233
|
}
|
|
186
|
-
return [...reserved].sort();
|
|
234
|
+
return { sources: [...reserved].sort(), assessmentKeys: [...assessmentKeys].sort() };
|
|
235
|
+
}
|
|
236
|
+
function readSourceReservations(runDirectories, excludingRunFile) {
|
|
237
|
+
return readRunReservations(runDirectories, excludingRunFile).sources;
|
|
187
238
|
}
|
|
188
239
|
exports.COMPLETION_START = '<!-- fraim-sleep-decisions:v1 -->';
|
|
189
240
|
exports.COMPLETION_END = '<!-- /fraim-sleep-decisions -->';
|
|
@@ -234,7 +285,7 @@ function decodeCompletion(markdown, expected = {}) {
|
|
|
234
285
|
catch {
|
|
235
286
|
fail();
|
|
236
287
|
}
|
|
237
|
-
if (!keys(state, ['schemaVersion', 'runId', 'owner', 'finalizationState', 'decisions', 'sourceDispositions', 'cleanup', 'reconciliations', 'retrospective', 'processedSources', 'sourceParticipants', 'corpusAudit', 'reconciliationHistory']) || state.schemaVersion !== 1 || !text(state.runId) || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(state.runId) || !ownerValid(state.owner)
|
|
288
|
+
if (!keys(state, ['schemaVersion', 'runId', 'owner', 'finalizationState', 'decisions', 'sourceDispositions', 'cleanup', 'reconciliations', 'retrospective', 'processedSources', 'sourceParticipants', 'corpusAudit', 'reconciliationHistory', 'deferred', 'deferredHistory', 'deferredAttempts', 'outcomeAssessments']) || state.schemaVersion !== 1 || !text(state.runId) || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(state.runId) || !ownerValid(state.owner)
|
|
238
289
|
|| !['pending', 'complete'].includes(state.finalizationState) || !Array.isArray(state.decisions) || !Array.isArray(state.sourceDispositions) || !Array.isArray(state.cleanup))
|
|
239
290
|
fail();
|
|
240
291
|
if ((expected.runId && expected.runId !== state.runId) || (expected.owner && !sameOwner(expected.owner, state.owner)))
|
|
@@ -249,8 +300,31 @@ function decodeCompletion(markdown, expected = {}) {
|
|
|
249
300
|
fail();
|
|
250
301
|
if (state.processedSources !== undefined)
|
|
251
302
|
validateProcessedSources(state.processedSources);
|
|
303
|
+
if (state.outcomeAssessments !== undefined) {
|
|
304
|
+
if (!Array.isArray(state.outcomeAssessments))
|
|
305
|
+
fail();
|
|
306
|
+
const digests = new Set();
|
|
307
|
+
for (const assessment of state.outcomeAssessments) {
|
|
308
|
+
(0, outcome_types_1.validateOutcomeAssessmentRecord)(assessment);
|
|
309
|
+
if (assessment.runId !== state.runId || digests.has(assessment.digest))
|
|
310
|
+
fail();
|
|
311
|
+
digests.add(assessment.digest);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
252
314
|
if (state.sourceParticipants !== undefined)
|
|
253
315
|
normalizeSourceParticipants(state.sourceParticipants, state.owner);
|
|
316
|
+
if (state.deferred !== undefined)
|
|
317
|
+
validateDeferredWork(state.deferred);
|
|
318
|
+
if (state.deferredHistory !== undefined) {
|
|
319
|
+
if (!Array.isArray(state.deferredHistory))
|
|
320
|
+
fail();
|
|
321
|
+
state.deferredHistory.forEach(validateDeferredWork);
|
|
322
|
+
}
|
|
323
|
+
if (state.deferredAttempts !== undefined) {
|
|
324
|
+
if (!Array.isArray(state.deferredAttempts))
|
|
325
|
+
fail();
|
|
326
|
+
state.deferredAttempts.forEach(validateDeferredAttempt);
|
|
327
|
+
}
|
|
254
328
|
const ids = new Set();
|
|
255
329
|
for (const item of state.decisions) {
|
|
256
330
|
if (!keys(item, ['actionId', 'revision', 'digest', 'owner', 'claimKey', 'evidenceFingerprint', 'sourceRefs', 'decision', 'publicationRef', 'target', 'dependencies']) || !text(item.actionId) || ids.has(item.actionId) || !Number.isInteger(item.revision) || item.revision < 1 || !hash(item.digest) || !ownerValid(item.owner)
|
|
@@ -282,6 +356,8 @@ function decodeCompletion(markdown, expected = {}) {
|
|
|
282
356
|
fail();
|
|
283
357
|
}
|
|
284
358
|
const decisions = new Map(state.decisions.map((item) => [item.actionId, item]));
|
|
359
|
+
if (state.deferred?.actions.some((item) => decisions.has(item.actionId)))
|
|
360
|
+
fail();
|
|
285
361
|
if (state.corpusAudit !== undefined) {
|
|
286
362
|
try {
|
|
287
363
|
(0, corpus_audit_1.validateCorpusAudit)(state.corpusAudit);
|
|
@@ -10,7 +10,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
|
|
|
10
10
|
const node_path_1 = __importDefault(require("node:path"));
|
|
11
11
|
const node_crypto_1 = require("node:crypto");
|
|
12
12
|
const owner_identity_1 = require("./owner-identity");
|
|
13
|
-
const { learningEntryBlocks } = require('./entry-format');
|
|
13
|
+
const { learningEntryBlocks, learningEntryIdentity, isLearningEntryId } = require('./entry-format');
|
|
14
14
|
const stable = (value) => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
|
|
15
15
|
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
|
|
16
16
|
const hash = (value) => (0, node_crypto_1.createHash)('sha256').update(value).digest('hex');
|
|
@@ -63,9 +63,8 @@ function collectLearningCorpus(homes, subjects, readerRoots = []) {
|
|
|
63
63
|
for (const block of blocks) {
|
|
64
64
|
// Match the runtime reader's handwritten identity contract.
|
|
65
65
|
// Approved mutations still use the author's narrower generated-ID schema.
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
if ((declarations.length && (!id || declarations.length !== 1)) || (id && ids.has(id))
|
|
66
|
+
const { id, ambiguous } = learningEntryIdentity(block.text);
|
|
67
|
+
if (ambiguous || (id && ids.has(id))
|
|
69
68
|
|| !/^#{2,6}\s+\[P-(CRITICAL|HIGH|MED|LOW)\]\s+\S/.test(block.text)) {
|
|
70
69
|
errors.push({ path: file.replace(/\\/g, '/'), reason: 'ambiguous-corpus-entry' });
|
|
71
70
|
continue;
|
|
@@ -157,8 +156,9 @@ function collectLearningCorpus(homes, subjects, readerRoots = []) {
|
|
|
157
156
|
/** Shared by journal replay and the strict compact receipt decoder. No filesystem access. */
|
|
158
157
|
function validateCorpusAudit(audit, reviewer) {
|
|
159
158
|
const fail = () => { throw new Error('invalid-corpus-audit'); };
|
|
160
|
-
if (!keys(audit, ['snapshot', 'reviewer', 'dispositions']) || typeof audit.reviewer !== 'string' || !audit.reviewer.trim() || (reviewer && audit.reviewer !== reviewer)
|
|
161
|
-
|| !keys(audit.snapshot, ['schemaVersion', 'homes', 'entries', 'duplicates', 'fingerprint', 'complete', 'errors']) || audit.snapshot.schemaVersion !== 1 || audit.snapshot.complete !==
|
|
159
|
+
if (!keys(audit, ['snapshot', 'reviewer', 'dispositions', 'partial']) || typeof audit.reviewer !== 'string' || !audit.reviewer.trim() || (reviewer && audit.reviewer !== reviewer) || (audit.partial !== undefined && audit.partial !== true)
|
|
160
|
+
|| !keys(audit.snapshot, ['schemaVersion', 'homes', 'entries', 'duplicates', 'fingerprint', 'complete', 'errors']) || audit.snapshot.schemaVersion !== 1 || typeof audit.snapshot.complete !== 'boolean' || (!audit.partial && !audit.snapshot.complete) || !Array.isArray(audit.snapshot.errors) || (!audit.partial && audit.snapshot.errors.length)
|
|
161
|
+
|| audit.snapshot.errors.some(item => !keys(item, ['path', 'reason']) || typeof item.path !== 'string' || !node_path_1.default.isAbsolute(item.path) || typeof item.reason !== 'string' || !item.reason.trim())
|
|
162
162
|
|| !Array.isArray(audit.snapshot.homes) || !Array.isArray(audit.snapshot.entries) || !Array.isArray(audit.dispositions))
|
|
163
163
|
fail();
|
|
164
164
|
const snapshot = audit.snapshot;
|
|
@@ -172,7 +172,7 @@ function validateCorpusAudit(audit, reviewer) {
|
|
|
172
172
|
fail();
|
|
173
173
|
(0, owner_identity_1.validateOwner)(entry.ref?.owner);
|
|
174
174
|
if (!snapshot.homes.some(owner => (0, owner_identity_1.ownerKey)(owner) === (0, owner_identity_1.ownerKey)(entry.ref.owner))
|
|
175
|
-
|| (entry.ref.entryId === undefined ? !Number.isSafeInteger(entry.occurrence) || entry.occurrence < 0 :
|
|
175
|
+
|| (entry.ref.entryId === undefined ? !Number.isSafeInteger(entry.occurrence) || entry.occurrence < 0 : !isLearningEntryId(entry.ref.entryId) || entry.occurrence !== undefined)
|
|
176
176
|
|| !/^learnings\//.test(entry.ref.relativePath) || /[\\:]/.test(entry.ref.relativePath) || entry.ref.relativePath.split('/').some(part => !part || part === '.' || part === '..')
|
|
177
177
|
|| !/^[a-f0-9]{64}$/.test(entry.sha256) || entries.has(corpusEntryKey(entry)))
|
|
178
178
|
fail();
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const LEARNING_ENTRY_ID_LINE = /^\*\*Id\*\*:\s*(L-[0-9A-Za-z]{6,24})\s*$/;
|
|
4
|
+
function isLearningEntryId(value) { return typeof value === 'string' && /^L-[0-9A-Za-z]{6,24}$/.test(value); }
|
|
5
|
+
/** Reader/corpus identity accepts stable handwritten IDs; approved mutation IDs remain narrower below. */
|
|
6
|
+
function learningEntryIdentity(content) {
|
|
7
|
+
const declarations = content.split(/\r?\n/).map(line => line.trim()).filter(line => /^\*\*Id\*\*:/i.test(line));
|
|
8
|
+
const id = declarations[0]?.match(LEARNING_ENTRY_ID_LINE)?.[1] ?? null;
|
|
9
|
+
return { id: declarations.length === 1 ? id : null, ambiguous: declarations.length > 0 && (!id || declarations.length !== 1) };
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
// Read the shared author's current entry format for identity and update guards.
|
|
4
13
|
function learningEntryBlocks(body) {
|
|
5
14
|
const headings = [...body.matchAll(/^#{2,6}\s+\[P-[A-Z]+\].*$/gm)];
|
|
@@ -24,4 +33,4 @@ function learningEntryMetadata(content) {
|
|
|
24
33
|
return metadata;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
|
-
module.exports = { learningEntryMetadata, learningEntryBlocks };
|
|
36
|
+
module.exports = { learningEntryMetadata, learningEntryBlocks, learningEntryIdentity, isLearningEntryId, LEARNING_ENTRY_ID_LINE };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.matchesOutcomeScope = matchesOutcomeScope;
|
|
4
|
+
exports.assessmentKey = assessmentKey;
|
|
5
|
+
exports.assessmentContent = assessmentContent;
|
|
6
|
+
exports.outcomeAssessmentDigest = outcomeAssessmentDigest;
|
|
7
|
+
exports.validateOutcomeAssessmentInput = validateOutcomeAssessmentInput;
|
|
8
|
+
exports.validateOutcomeAssessmentRecord = validateOutcomeAssessmentRecord;
|
|
9
|
+
exports.summarizeLearningOutcomes = summarizeLearningOutcomes;
|
|
10
|
+
const node_crypto_1 = require("node:crypto");
|
|
11
|
+
const owner_identity_1 = require("./owner-identity");
|
|
12
|
+
const { isLearningEntryId } = require('./entry-format');
|
|
13
|
+
function matchesOutcomeScope(record, scope) {
|
|
14
|
+
return (scope.key === undefined || assessmentKey(record) === scope.key)
|
|
15
|
+
&& (scope.principal === undefined || record.learning.learningSubject === scope.principal)
|
|
16
|
+
&& (scope.owners === undefined || scope.owners.some(owner => (0, owner_identity_1.ownerKey)(owner) === (0, owner_identity_1.ownerKey)(record.learning.owner)));
|
|
17
|
+
}
|
|
18
|
+
const canonical = (value) => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
|
|
19
|
+
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
|
|
20
|
+
const text = (value) => typeof value === 'string' && !!value.trim() && !/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(value);
|
|
21
|
+
const hash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
22
|
+
const relative = (value) => text(value) && !/[\\:\x00-\x1f]/.test(value) && value.split('/').every(part => !!part && part !== '.' && part !== '..');
|
|
23
|
+
const date = (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z)?$/.test(value)
|
|
24
|
+
&& Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value.slice(0, 10);
|
|
25
|
+
/** Semantic identity comes from Sleep; paths are provenance, never a title or move-sensitive join. */
|
|
26
|
+
function assessmentKey(input) {
|
|
27
|
+
return canonical([(0, owner_identity_1.ownerKey)(input.learning.owner), input.learning.learningSubject, input.learning.entryId, input.incidentId]);
|
|
28
|
+
}
|
|
29
|
+
function assessmentContent(input) {
|
|
30
|
+
return canonical({ learning: input.learning, incidentId: input.incidentId, observedAt: input.observedAt, outcome: input.outcome,
|
|
31
|
+
reportedHelp: input.reportedHelp, explicitlySkipped: input.explicitlySkipped,
|
|
32
|
+
sourceRefs: [...input.sourceRefs].sort((a, b) => canonical(a).localeCompare(canonical(b))), rationale: input.rationale,
|
|
33
|
+
guidanceExistedAt: input.guidanceExistedAt });
|
|
34
|
+
}
|
|
35
|
+
function outcomeAssessmentDigest(record) {
|
|
36
|
+
return (0, node_crypto_1.createHash)('sha256').update(canonical({ content: assessmentContent(record), revision: record.revision,
|
|
37
|
+
expectedPriorDigest: record.expectedPriorDigest, reviewer: record.reviewer })).digest('hex');
|
|
38
|
+
}
|
|
39
|
+
function validateOutcomeAssessmentInput(input) {
|
|
40
|
+
if (!input || !input.learning)
|
|
41
|
+
throw new Error('invalid-outcome-assessment');
|
|
42
|
+
(0, owner_identity_1.validateOwner)(input.learning.owner);
|
|
43
|
+
if (!relative(input.learning.relativePath) || !isLearningEntryId(input.learning.entryId)
|
|
44
|
+
|| !text(input.learning.learningSubject) || !text(input.incidentId) || input.incidentId.length > 512
|
|
45
|
+
|| !date(input.observedAt) || !date(input.guidanceExistedAt)
|
|
46
|
+
|| !['success', 'miss', 'unknown'].includes(input.outcome) || typeof input.reportedHelp !== 'boolean'
|
|
47
|
+
|| typeof input.explicitlySkipped !== 'boolean' || !text(input.rationale)
|
|
48
|
+
|| (input.expectedPriorDigest !== null && !hash(input.expectedPriorDigest))
|
|
49
|
+
|| !Array.isArray(input.sourceRefs) || !input.sourceRefs.length)
|
|
50
|
+
throw new Error('invalid-outcome-assessment');
|
|
51
|
+
if (input.reportedHelp && input.outcome !== 'success' || input.explicitlySkipped && input.outcome !== 'miss')
|
|
52
|
+
throw new Error('invalid-outcome-qualification');
|
|
53
|
+
if (Date.parse(input.guidanceExistedAt) > Date.parse(input.observedAt))
|
|
54
|
+
throw new Error('guidance-after-incident');
|
|
55
|
+
for (const source of input.sourceRefs) {
|
|
56
|
+
if (!source)
|
|
57
|
+
throw new Error('invalid-assessment-source');
|
|
58
|
+
(0, owner_identity_1.validateOwner)(source.owner);
|
|
59
|
+
if (!relative(source.relativePath) || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(source.sourceId)
|
|
60
|
+
|| !hash(source.sha256))
|
|
61
|
+
throw new Error('invalid-assessment-source');
|
|
62
|
+
}
|
|
63
|
+
if (new Set(input.sourceRefs.map(source => canonical([(0, owner_identity_1.ownerKey)(source.owner), source.sourceId]))).size !== input.sourceRefs.length)
|
|
64
|
+
throw new Error('duplicate-assessment-source');
|
|
65
|
+
}
|
|
66
|
+
function validateOutcomeAssessmentRecord(record) {
|
|
67
|
+
validateOutcomeAssessmentInput(record);
|
|
68
|
+
if (!Number.isInteger(record.revision) || record.revision < 1 || !date(record.assessedAt)
|
|
69
|
+
|| !text(record.reviewer) || !text(record.runId) || record.digest !== outcomeAssessmentDigest(record))
|
|
70
|
+
throw new Error('invalid-outcome-record');
|
|
71
|
+
}
|
|
72
|
+
function summarizeLearningOutcomes(records, learning) {
|
|
73
|
+
const selected = new Map();
|
|
74
|
+
for (const record of records) {
|
|
75
|
+
if ((0, owner_identity_1.ownerKey)(record.learning.owner) !== (0, owner_identity_1.ownerKey)(learning.owner) || record.learning.entryId !== learning.entryId
|
|
76
|
+
|| record.learning.learningSubject !== learning.learningSubject)
|
|
77
|
+
continue;
|
|
78
|
+
validateOutcomeAssessmentRecord(record);
|
|
79
|
+
const key = assessmentKey(record), prior = selected.get(key);
|
|
80
|
+
if (!prior || record.revision > prior.revision)
|
|
81
|
+
selected.set(key, record);
|
|
82
|
+
else if (record.revision === prior.revision && record.digest !== prior.digest)
|
|
83
|
+
throw new Error('assessment-history-conflict');
|
|
84
|
+
}
|
|
85
|
+
if (!selected.size)
|
|
86
|
+
return undefined;
|
|
87
|
+
const current = [...selected.values()], observed = current.map(record => record.observedAt).sort();
|
|
88
|
+
return { success: current.filter(record => record.outcome === 'success').length,
|
|
89
|
+
reportedHelp: current.filter(record => record.outcome === 'success' && record.reportedHelp).length,
|
|
90
|
+
miss: current.filter(record => record.outcome === 'miss').length, unknown: current.filter(record => record.outcome === 'unknown').length,
|
|
91
|
+
firstObservedAt: observed[0], lastObservedAt: observed[observed.length - 1], assessedAt: current.map(record => record.assessedAt).sort().at(-1) };
|
|
92
|
+
}
|
|
@@ -8,6 +8,7 @@ exports.ownerPath = ownerPath;
|
|
|
8
8
|
exports.validateOwner = validateOwner;
|
|
9
9
|
exports.ownerKey = ownerKey;
|
|
10
10
|
exports.assertSameOwner = assertSameOwner;
|
|
11
|
+
exports.assertLearningTargetPath = assertLearningTargetPath;
|
|
11
12
|
exports.resolveOwnerHome = resolveOwnerHome;
|
|
12
13
|
exports.ownerArtifactPath = ownerArtifactPath;
|
|
13
14
|
exports.configuredArtifactPath = configuredArtifactPath;
|
|
@@ -34,6 +35,16 @@ function assertSameOwner(expected, actual) {
|
|
|
34
35
|
if (ownerKey(expected) !== ownerKey(actual))
|
|
35
36
|
throw new Error('owner-path-conflict: the approved home differs from the configured path');
|
|
36
37
|
}
|
|
38
|
+
/** Shared authored-family identity check. Handwritten entries can omit Domain metadata. */
|
|
39
|
+
function assertLearningTargetPath(ref, domain) {
|
|
40
|
+
const subject = ref.owner.level === 'Org' ? 'org' : ref.learningSubject;
|
|
41
|
+
if (!subject || !/^[a-zA-Z0-9@._+-]+$/.test(subject))
|
|
42
|
+
throw new Error('learning-subject-required');
|
|
43
|
+
const escaped = subject.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
44
|
+
const match = ref.relativePath.match(new RegExp(`^learnings/${escaped}-((?:[a-z][a-z-]*-)?)(preferences|mistake-patterns|validated-patterns|manager-coaching)(\\.cold)?\\.md$`));
|
|
45
|
+
if (!match || (domain !== undefined && match[1] !== (domain === 'global' ? '' : domain + '-')))
|
|
46
|
+
throw new Error('domain-target-mismatch: learning subject, domain and family must match its file');
|
|
47
|
+
}
|
|
37
48
|
/** Pure read: resolve existing configured homes without migration or identity configuration. */
|
|
38
49
|
function resolveOwnerHome(projectPath, level, options) {
|
|
39
50
|
const load = (relative) => options.runtimeModule(projectPath, relative);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.hashContent = void 0;
|
|
7
|
+
exports.atomicWrite = atomicWrite;
|
|
8
|
+
exports.withFileLock = withFileLock;
|
|
9
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const node_crypto_1 = require("node:crypto");
|
|
12
|
+
const node_os_1 = require("node:os");
|
|
13
|
+
const hashContent = (text) => (0, node_crypto_1.createHash)('sha256').update(text).digest('hex');
|
|
14
|
+
exports.hashContent = hashContent;
|
|
15
|
+
function atomicWrite(file, text) {
|
|
16
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
|
|
17
|
+
const temporary = file + '.' + (0, node_crypto_1.randomUUID)() + '.tmp';
|
|
18
|
+
try {
|
|
19
|
+
node_fs_1.default.writeFileSync(temporary, text, { encoding: 'utf8', flag: 'wx' });
|
|
20
|
+
const fd = node_fs_1.default.openSync(temporary, 'r+');
|
|
21
|
+
try {
|
|
22
|
+
node_fs_1.default.fsyncSync(fd);
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
node_fs_1.default.closeSync(fd);
|
|
26
|
+
}
|
|
27
|
+
node_fs_1.default.renameSync(temporary, file);
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
if (node_fs_1.default.existsSync(temporary))
|
|
31
|
+
node_fs_1.default.unlinkSync(temporary);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function withFileLock(file, fn) {
|
|
35
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
|
|
36
|
+
let fd;
|
|
37
|
+
try {
|
|
38
|
+
fd = node_fs_1.default.openSync(file + '.lock', 'wx');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Serialize recovery so two contenders cannot unlink each other's new lock.
|
|
42
|
+
let recovery;
|
|
43
|
+
try {
|
|
44
|
+
recovery = node_fs_1.default.openSync(file + '.lock.recovery', 'wx');
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error('busy: lock recovery is already in progress');
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const owner = JSON.parse(node_fs_1.default.readFileSync(file + '.lock', 'utf8'));
|
|
51
|
+
let stale = false;
|
|
52
|
+
if (owner.host === (0, node_os_1.hostname)() && Number.isInteger(owner.pid) && owner.pid > 0) {
|
|
53
|
+
try {
|
|
54
|
+
process.kill(owner.pid, 0);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
stale = error.code === 'ESRCH';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!stale)
|
|
61
|
+
throw new Error('busy');
|
|
62
|
+
node_fs_1.default.unlinkSync(file + '.lock');
|
|
63
|
+
fd = node_fs_1.default.openSync(file + '.lock', 'wx');
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new Error('busy: reconcile outstanding lock before retry');
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
node_fs_1.default.closeSync(recovery);
|
|
70
|
+
node_fs_1.default.unlinkSync(file + '.lock.recovery');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
node_fs_1.default.writeFileSync(fd, JSON.stringify({ pid: process.pid, host: (0, node_os_1.hostname)(), at: new Date().toISOString() }));
|
|
74
|
+
try {
|
|
75
|
+
return fn();
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
node_fs_1.default.closeSync(fd);
|
|
79
|
+
node_fs_1.default.unlinkSync(file + '.lock');
|
|
80
|
+
}
|
|
81
|
+
}
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -31,6 +31,7 @@ exports.buildDirectStartPlan = buildDirectStartPlan;
|
|
|
31
31
|
exports.buildDirectContinuePlan = buildDirectContinuePlan;
|
|
32
32
|
exports.parseHostLine = parseHostLine;
|
|
33
33
|
exports.detectCodexCompactTaskFailure = detectCodexCompactTaskFailure;
|
|
34
|
+
exports.wireHostProcess = wireHostProcess;
|
|
34
35
|
exports.describeSpawnError = describeSpawnError;
|
|
35
36
|
exports.hostDefaultUnsetEnv = hostDefaultUnsetEnv;
|
|
36
37
|
exports.buildHostSpawnEnv = buildHostSpawnEnv;
|
|
@@ -2398,6 +2399,32 @@ function detectCodexCompactTaskFailure(line) {
|
|
|
2398
2399
|
return null;
|
|
2399
2400
|
}
|
|
2400
2401
|
function wireHostProcess(hostId, child, handlers, spawnCwd) {
|
|
2402
|
+
// Exec's JSON error has no auth code. Require independent provider stderr
|
|
2403
|
+
// provenance AND the same terminal protocol error; never scan tool/prose text.
|
|
2404
|
+
const codexRefreshFailure = 'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.';
|
|
2405
|
+
let codexAuthDiagnostic = false;
|
|
2406
|
+
let codexTerminalAuthFailure = false;
|
|
2407
|
+
const observeCodexAuth = (line, channel) => {
|
|
2408
|
+
if (hostId !== 'codex')
|
|
2409
|
+
return;
|
|
2410
|
+
if (channel === 'stderr') {
|
|
2411
|
+
const clean = line.replace(/\x1b\[[0-9;]*m/g, '').trim();
|
|
2412
|
+
const diagnostic = clean.match(/^(?:\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+)?ERROR\s+codex_login::auth::manager:\s+Failed to refresh token: (.+)$/);
|
|
2413
|
+
if (diagnostic?.[1] === codexRefreshFailure)
|
|
2414
|
+
codexAuthDiagnostic = true;
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
try {
|
|
2418
|
+
const event = JSON.parse(line);
|
|
2419
|
+
if (event.type === 'turn.failed' || event.type === 'error') {
|
|
2420
|
+
codexTerminalAuthFailure = (event.type === 'turn.failed' ? event.error?.message : event.message) === codexRefreshFailure;
|
|
2421
|
+
}
|
|
2422
|
+
else if (event.type === 'turn.started' || event.type === 'turn.completed') {
|
|
2423
|
+
codexTerminalAuthFailure = false;
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
catch { /* Plain stdout cannot establish authentication failure. */ }
|
|
2427
|
+
};
|
|
2401
2428
|
const wire = (buffer, channel) => {
|
|
2402
2429
|
let pending = '';
|
|
2403
2430
|
let suppressCodexModelRefreshStderr = false;
|
|
@@ -2406,6 +2433,7 @@ function wireHostProcess(hostId, child, handlers, spawnCwd) {
|
|
|
2406
2433
|
const lines = pending.split(/\r?\n/);
|
|
2407
2434
|
pending = lines.pop() || '';
|
|
2408
2435
|
for (const line of lines) {
|
|
2436
|
+
observeCodexAuth(line, channel);
|
|
2409
2437
|
if (hostId === 'codex' && channel === 'stderr') {
|
|
2410
2438
|
if (line.includes('codex_models_manager::manager: failed to refresh available models:')) {
|
|
2411
2439
|
suppressCodexModelRefreshStderr = true;
|
|
@@ -2431,6 +2459,7 @@ function wireHostProcess(hostId, child, handlers, spawnCwd) {
|
|
|
2431
2459
|
});
|
|
2432
2460
|
buffer.on('end', () => {
|
|
2433
2461
|
if (pending.trim().length > 0) {
|
|
2462
|
+
observeCodexAuth(pending, channel);
|
|
2434
2463
|
const parsed = parseHostLine(hostId, pending);
|
|
2435
2464
|
if (parsed.message || parsed.sessionId || parsed.raw) {
|
|
2436
2465
|
handlers.onEvent(parsed, channel);
|
|
@@ -2444,7 +2473,12 @@ function wireHostProcess(hostId, child, handlers, spawnCwd) {
|
|
|
2444
2473
|
// signal='SIGSEGV') are NOT treated as clean exits by classifyExit's
|
|
2445
2474
|
// `exitCode ?? 0` guard. Pass -1 for any signal-terminated child so the
|
|
2446
2475
|
// non-zero branch fires and recovery is attempted.
|
|
2447
|
-
child.on('close', (code, signal) =>
|
|
2476
|
+
child.on('close', (code, signal) => {
|
|
2477
|
+
if (!signal && code !== null && code !== 0 && codexAuthDiagnostic && codexTerminalAuthFailure) {
|
|
2478
|
+
handlers.onEvent({ hostAuthenticationRequired: true, hostError: codexRefreshFailure, raw: codexRefreshFailure }, 'system');
|
|
2479
|
+
}
|
|
2480
|
+
handlers.onExit(signal ? -1 : code);
|
|
2481
|
+
});
|
|
2448
2482
|
// Issue #1327: without this listener, an OS-level spawn failure (EAGAIN,
|
|
2449
2483
|
// ENOENT, EACCES) fires an 'error' event on the ChildProcess. An unhandled
|
|
2450
2484
|
// EventEmitter 'error' throws synchronously and crashes the Hub process.
|
|
@@ -10,6 +10,7 @@ exports.resolveHubAppCacheDir = resolveHubAppCacheDir;
|
|
|
10
10
|
exports.isHubAppCacheComplete = isHubAppCacheComplete;
|
|
11
11
|
exports.listCachedHubAppVersions = listCachedHubAppVersions;
|
|
12
12
|
exports.runNpmInstall = runNpmInstall;
|
|
13
|
+
exports.pruneHubAppCache = pruneHubAppCache;
|
|
13
14
|
exports.ensureMaterializedHubApp = ensureMaterializedHubApp;
|
|
14
15
|
exports.resolveHubAppEntry = resolveHubAppEntry;
|
|
15
16
|
const fs_1 = __importDefault(require("fs"));
|
|
@@ -209,6 +210,24 @@ const defaultInstallPackage = async (version, destDir) => {
|
|
|
209
210
|
}
|
|
210
211
|
fs_1.default.rmSync(installedRoot, { recursive: true, force: true });
|
|
211
212
|
};
|
|
213
|
+
const HUB_APP_CACHE_MAX_VERSIONS = 3;
|
|
214
|
+
/** Prune the hub-app cache to at most HUB_APP_CACHE_MAX_VERSIONS entries, always preserving keepVersion. Non-fatal. */
|
|
215
|
+
function pruneHubAppCache(fraimDir, keepVersion) {
|
|
216
|
+
try {
|
|
217
|
+
const all = listCachedHubAppVersions(fraimDir);
|
|
218
|
+
const sorted = all.map((e) => e.version).sort((a, b) => (semver_1.default.gt(a, b) ? -1 : 1));
|
|
219
|
+
const toKeep = new Set(sorted.slice(0, HUB_APP_CACHE_MAX_VERSIONS));
|
|
220
|
+
toKeep.add(keepVersion);
|
|
221
|
+
for (const v of sorted) {
|
|
222
|
+
if (!toKeep.has(v)) {
|
|
223
|
+
fs_1.default.rmSync(resolveHubAppCacheDir(v, fraimDir), { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// Prune failure must never break a launch.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
212
231
|
/**
|
|
213
232
|
* Return the entry path for `version`, materializing it first if it is not already a complete
|
|
214
233
|
* cache entry. Returns immediately (no npm invocation) when a complete entry already exists.
|
|
@@ -248,6 +267,7 @@ async function ensureMaterializedHubApp(version, options = {}) {
|
|
|
248
267
|
finally {
|
|
249
268
|
fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
|
|
250
269
|
}
|
|
270
|
+
pruneHubAppCache(fraimDir, version);
|
|
251
271
|
return entryPath;
|
|
252
272
|
}
|
|
253
273
|
/**
|
|
@@ -2596,7 +2596,9 @@ function summarizeHostErrorForThread(error) {
|
|
|
2596
2596
|
// noise that the system handled on its own.
|
|
2597
2597
|
function pushTerminalFailureToThread(run, exitCode) {
|
|
2598
2598
|
if (exitCode !== 0 && run.lastHostError) {
|
|
2599
|
-
|
|
2599
|
+
const detail = detectLoginFailureSignature(run, exitCode)
|
|
2600
|
+
? run.lastHostError : summarizeHostErrorForThread(run.lastHostError);
|
|
2601
|
+
run.messages.push((0, hosts_1.createHubMessage)('system', `This run stopped: ${detail}`));
|
|
2600
2602
|
}
|
|
2601
2603
|
}
|
|
2602
2604
|
function createHubRecoveryEvent(run, exitCode, attempt) {
|
|
@@ -3017,6 +3019,7 @@ class AiHubServer {
|
|
|
3017
3019
|
this.latestVersionPollTimer = null;
|
|
3018
3020
|
this.catalogSyncChild = null;
|
|
3019
3021
|
this.attachmentPolicy = (0, attachment_store_1.attachmentPolicy)();
|
|
3022
|
+
this.configureAgentSurface = options.configureAgentSurface ?? configureFraimForHubAgent;
|
|
3020
3023
|
this.openTerminal = options.openTerminal ?? hubOpenTerminal;
|
|
3021
3024
|
this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
|
|
3022
3025
|
const launchResolution = options.projectPath
|
|
@@ -4254,7 +4257,10 @@ class AiHubServer {
|
|
|
4254
4257
|
const option = hubAgentOption(run.hostId);
|
|
4255
4258
|
run.status = 'failed';
|
|
4256
4259
|
run.pauseReason = 'awaiting_user';
|
|
4257
|
-
run.loginRequired = option ? {
|
|
4260
|
+
run.loginRequired = option ? {
|
|
4261
|
+
agentLabel: option.label,
|
|
4262
|
+
...(run.hostId === 'codex' && run.lastHostError ? { remediation: run.lastHostError } : {}),
|
|
4263
|
+
} : undefined;
|
|
4258
4264
|
}
|
|
4259
4265
|
if (run.status !== 'running') {
|
|
4260
4266
|
run.eventLogRefs = (run.eventLogRefs || []).map((ref) => {
|
|
@@ -7013,7 +7019,7 @@ class AiHubServer {
|
|
|
7013
7019
|
return res.json({ ok: true, configured: [], message: 'No ready CLI agents to configure.' });
|
|
7014
7020
|
}
|
|
7015
7021
|
const results = await Promise.all(readyIds.map(async (hubId) => {
|
|
7016
|
-
const result = await
|
|
7022
|
+
const result = await this.configureAgentSurface(hubId);
|
|
7017
7023
|
return { hubId, ...result };
|
|
7018
7024
|
}));
|
|
7019
7025
|
const failed = results.filter((r) => !r.configured);
|