fraim-hub 2.0.338 → 2.0.340
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 +153 -0
- package/dist/src/ai-hub/desktop-main.js +46 -0
- package/dist/src/ai-hub/hosts.js +18 -0
- package/dist/src/ai-hub/server.js +67 -69
- package/dist/src/core/handoff-contracts.js +617 -0
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +36 -9
- package/dist/src/local-mcp-server/learning-context-builder.js +145 -27
- package/dist/src/local-mcp-server/learning-usage-events.js +267 -0
- package/dist/src/local-mcp-server/learning-usage-projection.js +25 -21
- package/dist/src/local-mcp-server/learning-usage-store.js +626 -88
- package/package.json +5 -2
- package/public/ai-hub/review.css +378 -0
- package/public/ai-hub/script.js +763 -33
- package/public/ai-hub/styles.css +53 -0
|
@@ -28,9 +28,14 @@ exports.ARTIFACT_RETENTION_CATEGORIES = [
|
|
|
28
28
|
'learning_usage',
|
|
29
29
|
'hub_event_logs',
|
|
30
30
|
];
|
|
31
|
+
function contentRoot(workspaceRoot) {
|
|
32
|
+
const packContent = node_path_1.default.join(workspaceRoot, 'personalized-employee');
|
|
33
|
+
return node_fs_1.default.existsSync(packContent) ? packContent : node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee');
|
|
34
|
+
}
|
|
31
35
|
function readWorkspaceConfig(workspaceRoot) {
|
|
32
36
|
try {
|
|
33
|
-
const configPath = (
|
|
37
|
+
const configPath = contentRoot(workspaceRoot) === node_path_1.default.join(workspaceRoot, 'personalized-employee')
|
|
38
|
+
? node_path_1.default.join(workspaceRoot, 'config.json') : (0, project_fraim_paths_1.getWorkspaceConfigPath)(workspaceRoot);
|
|
34
39
|
if (!node_fs_1.default.existsSync(configPath))
|
|
35
40
|
return {};
|
|
36
41
|
return JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf8'));
|
|
@@ -143,7 +148,7 @@ function loadGitLastChangeDates(workspaceRoot, relativeDir) {
|
|
|
143
148
|
return dates;
|
|
144
149
|
}
|
|
145
150
|
function readFrontmatter(content) {
|
|
146
|
-
const match = content.match(
|
|
151
|
+
const match = content.match(/^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---/);
|
|
147
152
|
if (!match)
|
|
148
153
|
return {};
|
|
149
154
|
const values = {};
|
|
@@ -167,8 +172,20 @@ function synthesizedDate(absolutePath) {
|
|
|
167
172
|
return undefined;
|
|
168
173
|
}
|
|
169
174
|
}
|
|
170
|
-
function userScoped(
|
|
171
|
-
|
|
175
|
+
function userScoped(filePath, activeUserEmail) {
|
|
176
|
+
try {
|
|
177
|
+
const body = node_fs_1.default.readFileSync(filePath, 'utf8');
|
|
178
|
+
const author = readFrontmatter(body).author;
|
|
179
|
+
if (author !== undefined) {
|
|
180
|
+
const value = author.replace(/^(?:"([^"]*)"|'([^']*)')$/, (_match, double, single) => double ?? single);
|
|
181
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) && value === activeUserEmail;
|
|
182
|
+
}
|
|
183
|
+
// Older artifacts used the author in their filename.
|
|
184
|
+
return node_path_1.default.basename(filePath).startsWith(`${activeUserEmail}-`);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
172
189
|
}
|
|
173
190
|
function evidenceEligibilityDate(workspaceRoot, absolutePath, gitDates) {
|
|
174
191
|
const relativePath = normalizeRelative(workspaceRoot, absolutePath);
|
|
@@ -187,17 +204,22 @@ function collectCandidates(workspaceRoot, options) {
|
|
|
187
204
|
eligibilityDate,
|
|
188
205
|
});
|
|
189
206
|
};
|
|
190
|
-
const
|
|
207
|
+
const content = contentRoot(workspaceRoot);
|
|
208
|
+
const archiveDir = node_path_1.default.join(content, 'learnings', 'archive');
|
|
191
209
|
for (const filePath of listFiles(archiveDir)) {
|
|
192
210
|
const fileName = node_path_1.default.basename(filePath);
|
|
193
|
-
if (!fileName.endsWith('.md') || !userScoped(
|
|
211
|
+
if (!fileName.endsWith('.md') || !userScoped(filePath, activeUserEmail))
|
|
194
212
|
continue;
|
|
195
213
|
add('learning_archive', filePath, true, 'Archived learning source for active user.', fileMtime(filePath));
|
|
196
214
|
}
|
|
197
|
-
const
|
|
198
|
-
|
|
215
|
+
const retrospectives = new Set([
|
|
216
|
+
node_path_1.default.join(workspaceRoot, 'docs', 'retrospectives'),
|
|
217
|
+
node_path_1.default.join(workspaceRoot, 'retrospectives'),
|
|
218
|
+
node_path_1.default.join(content, 'retrospectives'),
|
|
219
|
+
].flatMap(listFiles));
|
|
220
|
+
for (const filePath of retrospectives) {
|
|
199
221
|
const fileName = node_path_1.default.basename(filePath);
|
|
200
|
-
if (!fileName.endsWith('.md') || !userScoped(
|
|
222
|
+
if (!fileName.endsWith('.md') || !userScoped(filePath, activeUserEmail))
|
|
201
223
|
continue;
|
|
202
224
|
const date = synthesizedDate(filePath);
|
|
203
225
|
if (date)
|
|
@@ -209,6 +231,11 @@ function collectCandidates(workspaceRoot, options) {
|
|
|
209
231
|
const evidenceGitDates = loadGitLastChangeDates(workspaceRoot, 'docs/evidence');
|
|
210
232
|
for (const filePath of listFiles(evidenceDir)) {
|
|
211
233
|
const fileName = node_path_1.default.basename(filePath);
|
|
234
|
+
// A surviving run journal is unfinished recovery; completion removes it.
|
|
235
|
+
if (fileName.endsWith('.queue.jsonl')) {
|
|
236
|
+
add('evidence', filePath, false, 'Unfinished Sleep run journal; removed by run completion.');
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
212
239
|
const isMarkdown = fileName.endsWith('.md');
|
|
213
240
|
const isJson = fileName.endsWith('.json');
|
|
214
241
|
const eligibilityDate = evidenceEligibilityDate(workspaceRoot, filePath, evidenceGitDates);
|
|
@@ -25,6 +25,7 @@ exports.resolveOrgBrandWriteDir = resolveOrgBrandWriteDir;
|
|
|
25
25
|
exports.countPreservedLearnings = countPreservedLearnings;
|
|
26
26
|
exports.collectBrainLearningDots = collectBrainLearningDots;
|
|
27
27
|
exports.readPreservedLearnings = readPreservedLearnings;
|
|
28
|
+
exports.normalizeLearningDomain = normalizeLearningDomain;
|
|
28
29
|
exports.applyLearningEntryChange = applyLearningEntryChange;
|
|
29
30
|
exports.isTruthyFlag = isTruthyFlag;
|
|
30
31
|
const fs_1 = require("fs");
|
|
@@ -496,6 +497,15 @@ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-pattern
|
|
|
496
497
|
* behaviour instead of merely equivalent.
|
|
497
498
|
*/
|
|
498
499
|
function resolveUsageClock(workspaceRoot) {
|
|
500
|
+
return resolveUsageViews(workspaceRoot).clock;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Issue #1755: one store read, two views of it: the clock the scorer needs and
|
|
504
|
+
* the counters a surface renders. They are resolved together because reading the
|
|
505
|
+
* store twice for the same pass would let the two disagree, and because the
|
|
506
|
+
* identity resolution that finds a record is the projection's, not each caller's.
|
|
507
|
+
*/
|
|
508
|
+
function resolveUsageViews(workspaceRoot) {
|
|
499
509
|
try {
|
|
500
510
|
// Required lazily: the store imports the retention config, which imports the
|
|
501
511
|
// workspace path helpers, and a cycle through this module would be fragile.
|
|
@@ -503,14 +513,31 @@ function resolveUsageClock(workspaceRoot) {
|
|
|
503
513
|
const projection = require('./learning-usage-projection');
|
|
504
514
|
const loaded = store.readUsageStore();
|
|
505
515
|
if (Object.keys(loaded.entries).length === 0)
|
|
506
|
-
return
|
|
516
|
+
return {};
|
|
507
517
|
const lookup = projection.buildUsageLookup(loaded);
|
|
508
|
-
return
|
|
518
|
+
return {
|
|
519
|
+
clock: (id, fileType, title) => projection.lookupUsage(lookup, id, fileType, title)?.lastFired ?? null,
|
|
520
|
+
stats: (id, fileType, title) => {
|
|
521
|
+
const found = projection.lookupUsage(lookup, id, fileType, title);
|
|
522
|
+
if (!found)
|
|
523
|
+
return null;
|
|
524
|
+
return {
|
|
525
|
+
offered: found.offered,
|
|
526
|
+
offeredAboveThreshold: found.offeredAboveThreshold,
|
|
527
|
+
applied: found.byOutcome.applied || 0,
|
|
528
|
+
prevented: found.byOutcome.prevented || 0,
|
|
529
|
+
ignored: found.byOutcome.ignored || 0,
|
|
530
|
+
notApplicable: found.byOutcome['not-applicable'] || 0,
|
|
531
|
+
lastOfferedAt: found.lastOffered,
|
|
532
|
+
lastFiredAt: found.lastFired,
|
|
533
|
+
};
|
|
534
|
+
},
|
|
535
|
+
};
|
|
509
536
|
}
|
|
510
537
|
catch {
|
|
511
538
|
// Usage data is derived and optional. A failure here must never change what
|
|
512
539
|
// the agent is given.
|
|
513
|
-
return
|
|
540
|
+
return {};
|
|
514
541
|
}
|
|
515
542
|
}
|
|
516
543
|
function readFrontmatter(content) {
|
|
@@ -890,8 +917,9 @@ function enumerateFileEntries(file, threshold, usageClock, now) {
|
|
|
890
917
|
out.push({
|
|
891
918
|
// The store owns key construction. Building the composite key by hand here
|
|
892
919
|
// would be a second definition of what an entry's identity is, and the two
|
|
893
|
-
// would drift the first time normalization changed.
|
|
894
|
-
|
|
920
|
+
// would drift the first time normalization changed. Issue #1752 A1 made
|
|
921
|
+
// that one function rather than four copies of the same expression.
|
|
922
|
+
key: (0, learning_usage_store_1.resolveEntryIdentity)({ id: current.id, fileType: file.fileType, title: current.title }).key,
|
|
895
923
|
family: 'learning',
|
|
896
924
|
id: current.id,
|
|
897
925
|
title: current.title,
|
|
@@ -939,9 +967,27 @@ function enumerateFileEntries(file, threshold, usageClock, now) {
|
|
|
939
967
|
* listed. `aboveThreshold` is what separates active guidance from the dormant
|
|
940
968
|
* rollup. Nothing here depends on the agent reading anything.
|
|
941
969
|
*
|
|
942
|
-
*
|
|
943
|
-
*
|
|
944
|
-
*
|
|
970
|
+
* ## Issue #1752 A2 — one lesson, one offer, stamped or not
|
|
971
|
+
*
|
|
972
|
+
* This deduped by usage key, which sounds like the same thing as the context
|
|
973
|
+
* reader's rule and is not. `resolveOrgLearningFileTiers` deliberately returns
|
|
974
|
+
* both the org pack copy and the repo-local copy of an org family, because #1002
|
|
975
|
+
* R4 requires that a promoted entry not hide the rest of its family. The context
|
|
976
|
+
* reader then merges those tiers with `mergeLearningEntriesAcrossTiers`, keyed on
|
|
977
|
+
* the normalized title, so the agent sees one lesson. Keying the offer record on
|
|
978
|
+
* `id ?? titleUsageKey(...)` instead meant that a lesson carrying an `**Id**` line
|
|
979
|
+
* in one tier and not the other produced two keys, and both were recorded.
|
|
980
|
+
*
|
|
981
|
+
* That is defect 4 in #1752, and it is why `standing --changes-only` could
|
|
982
|
+
* recommend promoting and demoting the same lesson in one response: the
|
|
983
|
+
* title-keyed twin collected the offers while the id-keyed one collected the
|
|
984
|
+
* firings (the firing path never split, because `buildEntryIndex` dedups by
|
|
985
|
+
* identity first-wins). Measured live: five titles emitting two keys each in a
|
|
986
|
+
* single derived offer batch.
|
|
987
|
+
*
|
|
988
|
+
* So group by the same normalized title the context reader groups by, resolve one
|
|
989
|
+
* identity per group preferring whichever tier carries an id, and let the later
|
|
990
|
+
* tier win on content exactly as before.
|
|
945
991
|
*/
|
|
946
992
|
function collectOfferedLearningEntries(workspaceRoot, userId, forJob, domain) {
|
|
947
993
|
const roots = getLearningRoots(workspaceRoot);
|
|
@@ -950,13 +996,35 @@ function collectOfferedLearningEntries(workspaceRoot, userId, forJob, domain) {
|
|
|
950
996
|
const usageClock = resolveUsageClock(workspaceRoot);
|
|
951
997
|
const now = new Date();
|
|
952
998
|
const resolved = resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain);
|
|
953
|
-
|
|
999
|
+
// Grouped by family plus normalized title, which is what `titleUsageKey`
|
|
1000
|
+
// already computes. Two families holding the same title are two lessons, and
|
|
1001
|
+
// that is the existing rule rather than a new one.
|
|
1002
|
+
const byLesson = new Map();
|
|
954
1003
|
for (const file of [...resolved.l2, ...resolved.l1]) {
|
|
955
1004
|
for (const entry of enumerateFileEntries(file, threshold, usageClock, now)) {
|
|
956
|
-
|
|
1005
|
+
const lessonKey = (0, learning_usage_store_1.titleUsageKey)(entry.fileType, entry.title);
|
|
1006
|
+
const group = byLesson.get(lessonKey);
|
|
1007
|
+
if (group)
|
|
1008
|
+
group.push(entry);
|
|
1009
|
+
else
|
|
1010
|
+
byLesson.set(lessonKey, [entry]);
|
|
957
1011
|
}
|
|
958
1012
|
}
|
|
959
|
-
|
|
1013
|
+
const out = [];
|
|
1014
|
+
for (const group of byLesson.values()) {
|
|
1015
|
+
// Later tier wins for content, unchanged precedence.
|
|
1016
|
+
const winner = group[group.length - 1];
|
|
1017
|
+
// The id comes from whichever tier carries one, because `stamp-ids` has not
|
|
1018
|
+
// reached the org and manager pack homes for every corpus yet. Latest wins
|
|
1019
|
+
// when more than one tier is stamped.
|
|
1020
|
+
const id = group.reduce((found, entry) => entry.id ?? found, null);
|
|
1021
|
+
out.push({
|
|
1022
|
+
...winner,
|
|
1023
|
+
id,
|
|
1024
|
+
key: (0, learning_usage_store_1.resolveEntryIdentity)({ id, fileType: winner.fileType, title: winner.title }).key,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
return out;
|
|
960
1028
|
}
|
|
961
1029
|
/**
|
|
962
1030
|
* Issue #1103 R3: the auto-loaded Team Context rule files.
|
|
@@ -1175,7 +1243,7 @@ function isTeamContextKey(value) {
|
|
|
1175
1243
|
* user-level `~/.fraim/…` path (where org onboarding writes).
|
|
1176
1244
|
* - Project scope (projectContext/projectBrief/projectRules/projectQa): repo-local only.
|
|
1177
1245
|
*/
|
|
1178
|
-
function resolveTeamContextFile(workspaceRoot, key) {
|
|
1246
|
+
function resolveTeamContextFile(workspaceRoot, key, options = {}) {
|
|
1179
1247
|
const { relativePath, scope } = TEAM_CONTEXT_FILE_MAP[key];
|
|
1180
1248
|
const repoPath = (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', relativePath);
|
|
1181
1249
|
const repoDisplay = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`personalized-employee/${relativePath}`);
|
|
@@ -1204,7 +1272,7 @@ function resolveTeamContextFile(workspaceRoot, key) {
|
|
|
1204
1272
|
// destination (R5.1), so writePath stays empty and the caller must route
|
|
1205
1273
|
// edits through the org backend's propose-and-approve flow.
|
|
1206
1274
|
if (key === 'org' || key === 'orgRules') {
|
|
1207
|
-
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, relativePath);
|
|
1275
|
+
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', options).contentRoot, relativePath);
|
|
1208
1276
|
if ((0, fs_1.existsSync)(cachePath)) {
|
|
1209
1277
|
const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
1210
1278
|
const orgDisplayPath = cachePath.startsWith(fraimDir)
|
|
@@ -1221,7 +1289,7 @@ function resolveTeamContextFile(workspaceRoot, key) {
|
|
|
1221
1289
|
}
|
|
1222
1290
|
}
|
|
1223
1291
|
if (key === 'manager' || key === 'managerRules') {
|
|
1224
|
-
const managerHome = (0, pack_home_1.resolvePackHome)('manager');
|
|
1292
|
+
const managerHome = (0, pack_home_1.resolvePackHome)('manager', options);
|
|
1225
1293
|
const managerCachePath = (0, path_1.join)(managerHome.contentRoot, relativePath);
|
|
1226
1294
|
if ((0, fs_1.existsSync)(managerCachePath)) {
|
|
1227
1295
|
const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
@@ -1656,7 +1724,7 @@ function listDomainLearningFiles(dir, prefix, fileType) {
|
|
|
1656
1724
|
// threshold: entries with score >= threshold (or in a non-gated category) are
|
|
1657
1725
|
// marked injected:true. Callers that don't need scoring pass Infinity so
|
|
1658
1726
|
// all entries are injected.
|
|
1659
|
-
function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = exports.DEFAULT_THRESHOLD, usageClock) {
|
|
1727
|
+
function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = exports.DEFAULT_THRESHOLD, usageClock, usageStats) {
|
|
1660
1728
|
if (!(0, fs_1.existsSync)(filePath))
|
|
1661
1729
|
return [];
|
|
1662
1730
|
let content;
|
|
@@ -1678,6 +1746,10 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
|
|
|
1678
1746
|
const flush = () => {
|
|
1679
1747
|
if (current) {
|
|
1680
1748
|
current.body = bodyLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
1749
|
+
// Issue #1755: resolve usage once per entry, before scoring, so the
|
|
1750
|
+
// counters a surface renders and the clock the score uses come from
|
|
1751
|
+
// the same record rather than two independent lookups.
|
|
1752
|
+
current.usage = usageStats ? usageStats(current.id, fileType, current.title) : null;
|
|
1681
1753
|
if (isScoreGated) {
|
|
1682
1754
|
if (curLastSeen !== null) {
|
|
1683
1755
|
const lastFired = usageClock ? usageClock(current.id, fileType, current.title) : null;
|
|
@@ -1712,7 +1784,7 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
|
|
|
1712
1784
|
title: header[2].trim(),
|
|
1713
1785
|
id: null,
|
|
1714
1786
|
body: '', source: displayPath, category, level, domain,
|
|
1715
|
-
score: null, injected: true,
|
|
1787
|
+
score: null, injected: true, usage: null,
|
|
1716
1788
|
};
|
|
1717
1789
|
if (tier !== undefined)
|
|
1718
1790
|
entry.tier = tier;
|
|
@@ -1758,13 +1830,14 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1758
1830
|
const threshold = getScoreThreshold(workspaceRoot);
|
|
1759
1831
|
// Issue #1103 R8: the Hub's injected flag must agree with the loader's, so both
|
|
1760
1832
|
// read the same clock. One store read serves the whole call.
|
|
1761
|
-
|
|
1833
|
+
// Issue #1755: the same read also yields the counters the Hub renders.
|
|
1834
|
+
const { clock: usageClock, stats: usageStats } = resolveUsageViews(workspaceRoot);
|
|
1762
1835
|
const out = [];
|
|
1763
1836
|
// Helper: parse + merge a list of tier paths for one file family.
|
|
1764
1837
|
// ResolvedLearningTier uses 'manager' to mean machine-level personal; map it to 'machine'.
|
|
1765
1838
|
const tierLevel = (t) => t.level === 'manager' ? 'machine' : t.level;
|
|
1766
1839
|
const parseTiers = (tiers, cat, tierVal, domainVal) => mergeLearningEntriesAcrossTiers(tiers.map((t) => ({
|
|
1767
|
-
entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold, usageClock),
|
|
1840
|
+
entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold, usageClock, usageStats),
|
|
1768
1841
|
})));
|
|
1769
1842
|
if (scope === 'org') {
|
|
1770
1843
|
for (const cat of ['avoid', 'preference', 'repeat', 'coaching']) {
|
|
@@ -1785,7 +1858,7 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1785
1858
|
seenDomainFile.add(fileName);
|
|
1786
1859
|
const domainTiers = resolveOrgLearningFileTiers(roots.repoLearningsBase, fileName);
|
|
1787
1860
|
if (domainTiers.length === 0) {
|
|
1788
|
-
out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, threshold));
|
|
1861
|
+
out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, threshold, usageClock, usageStats));
|
|
1789
1862
|
}
|
|
1790
1863
|
else {
|
|
1791
1864
|
out.push(...parseTiers(domainTiers, cat, undefined, domain));
|
|
@@ -1802,13 +1875,13 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1802
1875
|
for (const cat of cats) {
|
|
1803
1876
|
const fileType = exports.CATEGORY_TO_FILETYPE[cat];
|
|
1804
1877
|
const f = `${resolvedUserId}-${fileType}.md`;
|
|
1805
|
-
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'project', 'global', 'hot', threshold));
|
|
1878
|
+
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'project', 'global', 'hot', threshold, usageClock, usageStats));
|
|
1806
1879
|
const coldF = `${resolvedUserId}-${fileType}${COLD_FILE_SUFFIX}.md`;
|
|
1807
1880
|
if ((0, fs_1.existsSync)((0, path_1.join)(roots.repoLearningsBase, coldF))) {
|
|
1808
|
-
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, coldF), `${REPO_LEARNINGS_REL}/${coldF}`, cat, 'project', 'global', 'cold', threshold));
|
|
1881
|
+
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, coldF), `${REPO_LEARNINGS_REL}/${coldF}`, cat, 'project', 'global', 'cold', threshold, usageClock, usageStats));
|
|
1809
1882
|
}
|
|
1810
1883
|
for (const { fileName, domain } of listDomainLearningFiles(roots.repoLearningsBase, resolvedUserId, fileType)) {
|
|
1811
|
-
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'project', domain, 'hot', threshold));
|
|
1884
|
+
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'project', domain, 'hot', threshold, usageClock, usageStats));
|
|
1812
1885
|
}
|
|
1813
1886
|
}
|
|
1814
1887
|
return out;
|
|
@@ -1839,6 +1912,25 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1839
1912
|
}
|
|
1840
1913
|
return out;
|
|
1841
1914
|
}
|
|
1915
|
+
/** True for a domain value that means "the undecorated global file". */
|
|
1916
|
+
function isGlobalDomain(domain) {
|
|
1917
|
+
return !domain || domain === 'global';
|
|
1918
|
+
}
|
|
1919
|
+
/** Normalize a caller-supplied domain to one of the known domains, or 'global'. */
|
|
1920
|
+
function normalizeLearningDomain(domain) {
|
|
1921
|
+
if (isGlobalDomain(domain))
|
|
1922
|
+
return 'global';
|
|
1923
|
+
const found = learning_domains_1.LEARNING_DOMAINS.find((d) => d === domain);
|
|
1924
|
+
return found ?? 'global';
|
|
1925
|
+
}
|
|
1926
|
+
function learningFileName(userId, fileType, domain) {
|
|
1927
|
+
// Issue #1755 / #754: normalize here rather than trusting the caller. The HTTP
|
|
1928
|
+
// layer already allowlists the domain, but this function builds a path and a
|
|
1929
|
+
// direct caller passing `../../escape` would otherwise write outside the
|
|
1930
|
+
// learnings root. Anything not in the known set resolves to the global file.
|
|
1931
|
+
const safe = normalizeLearningDomain(domain);
|
|
1932
|
+
return safe === 'global' ? `${userId}-${fileType}.md` : `${userId}-${safe}-${fileType}.md`;
|
|
1933
|
+
}
|
|
1842
1934
|
function resolveLearningFilePath(workspaceRoot, userId, ref) {
|
|
1843
1935
|
const roots = getLearningRoots(workspaceRoot);
|
|
1844
1936
|
const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
|
|
@@ -1846,12 +1938,12 @@ function resolveLearningFilePath(workspaceRoot, userId, ref) {
|
|
|
1846
1938
|
// Write org entries to the org pack home — same location share-with-others uses.
|
|
1847
1939
|
// The Hub read path already merges pack home + repo-local, so edits land where
|
|
1848
1940
|
// the agent reads them without needing a repo commit.
|
|
1849
|
-
return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings',
|
|
1941
|
+
return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', learningFileName('org', fileType, ref.domain));
|
|
1850
1942
|
}
|
|
1851
1943
|
const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
|
|
1852
1944
|
// Issue #1070: for the 'machine' level use the configured manager home.
|
|
1853
1945
|
const base = ref.level === 'project' ? roots.repoLearningsBase : roots.managerHomeBase;
|
|
1854
|
-
return (0, path_1.join)(base,
|
|
1946
|
+
return (0, path_1.join)(base, learningFileName(resolvedUserId, fileType, ref.domain));
|
|
1855
1947
|
}
|
|
1856
1948
|
function titleCaseFromFileType(fileType) {
|
|
1857
1949
|
return fileType.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
@@ -1875,9 +1967,10 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
|
|
|
1875
1967
|
const body = String(payload.body || '').trim();
|
|
1876
1968
|
const block = [`#### [${sev}] ${title}`, '', body, '', '---'].join(nl);
|
|
1877
1969
|
const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
|
|
1970
|
+
const addDomain = normalizeLearningDomain(ref.domain);
|
|
1878
1971
|
const preamble = existing.trim()
|
|
1879
1972
|
? existing.replace(/\s*$/, '')
|
|
1880
|
-
: `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileType)}`;
|
|
1973
|
+
: `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileType)}${addDomain === 'global' ? '' : ` (${addDomain})`}`;
|
|
1881
1974
|
write(preamble + nl + nl + block);
|
|
1882
1975
|
return { ok: true, path: filePath };
|
|
1883
1976
|
}
|
|
@@ -1894,8 +1987,12 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
|
|
|
1894
1987
|
write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
|
|
1895
1988
|
return { ok: true, path: filePath };
|
|
1896
1989
|
}
|
|
1897
|
-
// edit: keep the entry's metadata lines (Score/Last seen/Recurrences/...) intact,
|
|
1990
|
+
// edit: keep the entry's metadata lines (Id/Score/Last seen/Recurrences/...) intact,
|
|
1898
1991
|
// replace only the heading (priority + title) and the prose body.
|
|
1992
|
+
//
|
|
1993
|
+
// Issue #1755: `Id` is in this list. It used not to be, so every Hub edit dropped
|
|
1994
|
+
// the entry's stable identifier and the usage record keyed to it (#1752) was
|
|
1995
|
+
// orphaned: silently, and permanently once the title also changed.
|
|
1899
1996
|
const headerMatch = lines[start].match(headingRe);
|
|
1900
1997
|
const sev = (payload.severity || headerMatch[1]);
|
|
1901
1998
|
const title = (payload.title !== undefined ? payload.title : headerMatch[2]).trim();
|
|
@@ -1904,11 +2001,32 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
|
|
|
1904
2001
|
const t = l.trim();
|
|
1905
2002
|
if (t === '---')
|
|
1906
2003
|
continue;
|
|
1907
|
-
if (/^\*\*(Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t) || /^(First|Last) synthesized:/i.test(t))
|
|
2004
|
+
if (/^\*\*(Id|Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t) || /^(First|Last) synthesized:/i.test(t))
|
|
1908
2005
|
meta.push(l);
|
|
1909
2006
|
}
|
|
1910
2007
|
const body = String(payload.body !== undefined ? payload.body : '').trim();
|
|
1911
2008
|
const rebuilt = [`#### [${sev}] ${title}`, '', ...meta, ...(meta.length ? [''] : []), body, '', '---'];
|
|
2009
|
+
// Issue #1755 R2/R4: a domain change is a move between files, because the domain
|
|
2010
|
+
// IS the filename. Cut the rebuilt block from the source file and append it to the
|
|
2011
|
+
// destination, so the entry keeps its id, score and history rather than being
|
|
2012
|
+
// re-created as a new entry with no past.
|
|
2013
|
+
const targetDomain = payload.targetDomain === undefined ? undefined : normalizeLearningDomain(payload.targetDomain);
|
|
2014
|
+
if (targetDomain !== undefined && targetDomain !== normalizeLearningDomain(ref.domain)) {
|
|
2015
|
+
const destPath = resolveLearningFilePath(workspaceRoot, userId, { ...ref, domain: targetDomain });
|
|
2016
|
+
const destExisting = (0, fs_1.existsSync)(destPath) ? (0, fs_1.readFileSync)(destPath, 'utf8') : '';
|
|
2017
|
+
const destNl = destExisting.includes('\r\n') ? '\r\n' : nl;
|
|
2018
|
+
const fileTypeForHeader = exports.CATEGORY_TO_FILETYPE[ref.category];
|
|
2019
|
+
const destPreamble = destExisting.trim()
|
|
2020
|
+
? destExisting.replace(/\s*$/, '')
|
|
2021
|
+
: `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileTypeForHeader)}${targetDomain === 'global' ? '' : ` (${targetDomain})`}`;
|
|
2022
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(destPath), { recursive: true });
|
|
2023
|
+
(0, fs_1.writeFileSync)(destPath, (destPreamble + destNl + destNl + rebuilt.join(destNl)).replace(/\s*$/, '') + destNl, 'utf8');
|
|
2024
|
+
// Remove from the source only after the destination write succeeded, so a
|
|
2025
|
+
// failure mid-move duplicates rather than loses the entry.
|
|
2026
|
+
const without = [...lines.slice(0, start), ...lines.slice(end)];
|
|
2027
|
+
write(without.join(nl).replace(/\n{3,}/g, '\n\n'));
|
|
2028
|
+
return { ok: true, path: destPath };
|
|
2029
|
+
}
|
|
1912
2030
|
const next = [...lines.slice(0, start), ...rebuilt, ...lines.slice(end)];
|
|
1913
2031
|
write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
|
|
1914
2032
|
return { ok: true, path: filePath };
|