fraim 2.0.161 → 2.0.163
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/src/ai-hub/conversation-store.js +164 -0
- package/dist/src/ai-hub/desktop-main.js +24 -1
- package/dist/src/ai-hub/hosts.js +474 -26
- package/dist/src/ai-hub/managed-browser.js +269 -0
- package/dist/src/ai-hub/manager-turns.js +13 -0
- package/dist/src/ai-hub/preferences.js +11 -2
- package/dist/src/ai-hub/server.js +1228 -65
- package/dist/src/cli/commands/init-project.js +7 -1
- package/dist/src/cli/doctor/check-runner.js +3 -1
- package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +261 -2
- package/dist/src/cli/utils/agent-adapters.js +1 -1
- package/dist/src/core/fraim-config-schema.generated.js +50 -13
- package/dist/src/first-run/types.js +8 -0
- package/dist/src/local-mcp-server/agent-token-prices.js +53 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +438 -2
- package/dist/src/local-mcp-server/stdio-server.js +45 -6
- package/package.json +5 -4
- package/public/ai-hub/index.html +459 -10
- package/public/ai-hub/review.css +354 -0
- package/public/ai-hub/script.js +6007 -1274
- package/public/ai-hub/styles.css +1838 -16
|
@@ -6,8 +6,18 @@
|
|
|
6
6
|
* workspace root on the user's machine.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.CATEGORY_TO_FILETYPE = exports.LEARNING_PRIORITIES = void 0;
|
|
10
|
+
exports.learningEntryHeadingRegex = learningEntryHeadingRegex;
|
|
9
11
|
exports.computeEffectiveScore = computeEffectiveScore;
|
|
10
12
|
exports.buildLearningContextSection = buildLearningContextSection;
|
|
13
|
+
exports.buildTeamContextSection = buildTeamContextSection;
|
|
14
|
+
exports.resolveTeamContextFiles = resolveTeamContextFiles;
|
|
15
|
+
exports.isTeamContextKey = isTeamContextKey;
|
|
16
|
+
exports.resolveTeamContextFile = resolveTeamContextFile;
|
|
17
|
+
exports.countPreservedLearnings = countPreservedLearnings;
|
|
18
|
+
exports.readPreservedLearnings = readPreservedLearnings;
|
|
19
|
+
exports.applyLearningEntryChange = applyLearningEntryChange;
|
|
20
|
+
exports.isTruthyFlag = isTruthyFlag;
|
|
11
21
|
const fs_1 = require("fs");
|
|
12
22
|
const path_1 = require("path");
|
|
13
23
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
@@ -17,6 +27,22 @@ const AGING_HORIZON_DAYS = 7;
|
|
|
17
27
|
const MAX_ENTRIES_SCANNED = 200;
|
|
18
28
|
const BACKLOG_MIN = 5;
|
|
19
29
|
const OLDEST_AGE_DAYS_TRIGGER = 3;
|
|
30
|
+
// ── Single source of truth for the learning-entry format contract (#533) ──────
|
|
31
|
+
// The `## [P-…] <title>` entry format is a tight contract shared by three sides:
|
|
32
|
+
// 1. EMIT — the synthesis jobs (sleep-on-learnings, organizational-learning-
|
|
33
|
+
// synthesis) instruct the agent to write entries in this shape.
|
|
34
|
+
// 2. SCORE — the decay scorer below reads severity + Last seen + Recurrences to
|
|
35
|
+
// decide which learnings stay active and get injected into agent context.
|
|
36
|
+
// 3. READ — the counters and the Hub (readPreservedLearnings) display them.
|
|
37
|
+
// These previously drifted (each accepted a different priority set / heading
|
|
38
|
+
// level), which silently dropped P-CRITICAL learnings. Everything now derives
|
|
39
|
+
// from LEARNING_PRIORITIES, and a build-time contract test
|
|
40
|
+
// (tests/isolated/test-learning-format-contract.ts) asserts all three sides agree.
|
|
41
|
+
exports.LEARNING_PRIORITIES = ['P-CRITICAL', 'P-HIGH', 'P-MED', 'P-LOW'];
|
|
42
|
+
/** Canonical entry-heading matcher: `##`..`######` + `[P-X]` + optional title. */
|
|
43
|
+
function learningEntryHeadingRegex(flags = '') {
|
|
44
|
+
return new RegExp(`^#{2,}\\s+\\[(${exports.LEARNING_PRIORITIES.join('|')})\\]\\s*(.*)$`, flags);
|
|
45
|
+
}
|
|
20
46
|
function getLearningRoots(workspaceRoot) {
|
|
21
47
|
return {
|
|
22
48
|
globalPersonalBase: (0, project_fraim_paths_1.getConfiguredPortableLearningsDir)(workspaceRoot),
|
|
@@ -152,7 +178,7 @@ function getScoreThreshold(workspaceRoot) {
|
|
|
152
178
|
* calculations). Defaults to the current wall clock.
|
|
153
179
|
*/
|
|
154
180
|
function computeEffectiveScore(severity, lastSeenDate, recurrences, fileType, now = new Date()) {
|
|
155
|
-
const baseScore = severity === 'P-HIGH' ? 8 : severity === 'P-MED' ? 5 : 3;
|
|
181
|
+
const baseScore = severity === 'P-CRITICAL' ? 10 : severity === 'P-HIGH' ? 8 : severity === 'P-MED' ? 5 : 3;
|
|
156
182
|
// Mistake patterns decay faster (90d) — they're tied to environments that change.
|
|
157
183
|
// Preferences, manager-coaching, and validated-patterns express durable judgment (180d half-life).
|
|
158
184
|
const halfLife = fileType === 'mistake-patterns' ? 90 : 180;
|
|
@@ -210,7 +236,7 @@ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-pattern
|
|
|
210
236
|
for (const line of lines) {
|
|
211
237
|
if (scanned >= MAX_ENTRIES_SCANNED)
|
|
212
238
|
break;
|
|
213
|
-
const headerMatch = line.match(
|
|
239
|
+
const headerMatch = line.match(learningEntryHeadingRegex());
|
|
214
240
|
if (headerMatch) {
|
|
215
241
|
flush();
|
|
216
242
|
inEntry = true;
|
|
@@ -438,6 +464,416 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
|
|
|
438
464
|
}
|
|
439
465
|
return section;
|
|
440
466
|
}
|
|
467
|
+
/**
|
|
468
|
+
* Resolve an organization/manager-scope context file. These are user-level and
|
|
469
|
+
* portable across repos, but a repo-local copy (under `fraim/…`) wins when
|
|
470
|
+
* present — mirroring how personal learnings layer (repo-local shadows
|
|
471
|
+
* user-level) in `resolvePersonalLearningFile`.
|
|
472
|
+
*/
|
|
473
|
+
function resolveOrgContextFile(workspaceRoot, relativePath) {
|
|
474
|
+
try {
|
|
475
|
+
const repoPath = (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', relativePath);
|
|
476
|
+
if ((0, fs_1.existsSync)(repoPath)) {
|
|
477
|
+
return {
|
|
478
|
+
present: true,
|
|
479
|
+
displayPath: (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`personalized-employee/${relativePath}`)
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
const userPath = (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee', relativePath);
|
|
483
|
+
if ((0, fs_1.existsSync)(userPath)) {
|
|
484
|
+
return {
|
|
485
|
+
present: true,
|
|
486
|
+
displayPath: (0, project_fraim_paths_1.getUserFraimDisplayPath)(`personalized-employee/${relativePath}`)
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
// Fall through to absent.
|
|
492
|
+
}
|
|
493
|
+
return { present: false, displayPath: '' };
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Resolve a project-scope context file. These are repo-local only — they
|
|
497
|
+
* describe a single engagement and never layer up to the user level.
|
|
498
|
+
*/
|
|
499
|
+
function resolveProjectContextFile(workspaceRoot, relativePath) {
|
|
500
|
+
try {
|
|
501
|
+
const repoPath = (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', relativePath);
|
|
502
|
+
if ((0, fs_1.existsSync)(repoPath)) {
|
|
503
|
+
return {
|
|
504
|
+
present: true,
|
|
505
|
+
displayPath: (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`personalized-employee/${relativePath}`)
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// Fall through to absent.
|
|
511
|
+
}
|
|
512
|
+
return { present: false, displayPath: '' };
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Builds the team-context section pointing the agent at the org/manager/project
|
|
516
|
+
* context files that exist on disk (issue #512, three-layer Context
|
|
517
|
+
* Architecture). Mirrors `buildLearningContextSection`: pure, side-effect-free,
|
|
518
|
+
* lists only present files, and returns '' when none exist.
|
|
519
|
+
*/
|
|
520
|
+
function buildTeamContextSection(workspaceRoot, forJob) {
|
|
521
|
+
// Organization layer (user-level, portable; repo-local override wins).
|
|
522
|
+
const orgContext = resolveOrgContextFile(workspaceRoot, 'context/org_context.md');
|
|
523
|
+
const managerContext = resolveOrgContextFile(workspaceRoot, 'context/manager_context.md');
|
|
524
|
+
const orgRules = resolveOrgContextFile(workspaceRoot, 'rules/org_rules.md');
|
|
525
|
+
const managerRules = resolveOrgContextFile(workspaceRoot, 'rules/manager_rules.md');
|
|
526
|
+
// Project layer (repo-local only).
|
|
527
|
+
const projectContext = resolveProjectContextFile(workspaceRoot, 'context/project_context.md');
|
|
528
|
+
const projectBrief = resolveProjectContextFile(workspaceRoot, 'context/project_brief.md');
|
|
529
|
+
const projectRules = resolveProjectContextFile(workspaceRoot, 'rules/project_rules.md');
|
|
530
|
+
const projectQa = resolveProjectContextFile(workspaceRoot, 'context/project_qa.md');
|
|
531
|
+
const hasOrg = orgContext.present || managerContext.present || orgRules.present || managerRules.present;
|
|
532
|
+
const hasProject = projectContext.present || projectBrief.present || projectRules.present || projectQa.present;
|
|
533
|
+
if (!hasOrg && !hasProject)
|
|
534
|
+
return '';
|
|
535
|
+
let section = forJob
|
|
536
|
+
? '\n\n## Team Context for This Job\n\n'
|
|
537
|
+
: '\n\n## Team Context\n\n';
|
|
538
|
+
if (hasOrg) {
|
|
539
|
+
section += '### Organization\n';
|
|
540
|
+
if (orgContext.present)
|
|
541
|
+
section += `\`${orgContext.displayPath}\`\n`;
|
|
542
|
+
if (managerContext.present)
|
|
543
|
+
section += `\`${managerContext.displayPath}\`\n`;
|
|
544
|
+
if (orgRules.present)
|
|
545
|
+
section += `\`${orgRules.displayPath}\`\n`;
|
|
546
|
+
if (managerRules.present)
|
|
547
|
+
section += `\`${managerRules.displayPath}\`\n`;
|
|
548
|
+
section += '\n';
|
|
549
|
+
}
|
|
550
|
+
if (hasProject) {
|
|
551
|
+
section += '### Project\n';
|
|
552
|
+
if (projectContext.present)
|
|
553
|
+
section += `\`${projectContext.displayPath}\`\n`;
|
|
554
|
+
if (projectBrief.present)
|
|
555
|
+
section += `\`${projectBrief.displayPath}\`\n`;
|
|
556
|
+
if (projectRules.present)
|
|
557
|
+
section += `\`${projectRules.displayPath}\`\n`;
|
|
558
|
+
if (projectQa.present)
|
|
559
|
+
section += `\`${projectQa.displayPath}\`\n`;
|
|
560
|
+
section += '\n';
|
|
561
|
+
}
|
|
562
|
+
section += 'Read the relevant context before doing work.\n';
|
|
563
|
+
return section;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Resolve presence + display paths for every three-layer Team Context file
|
|
567
|
+
* (org/manager/project). Reuses resolveOrgContextFile / resolveProjectContextFile
|
|
568
|
+
* so layering (repo-local shadows user-level for org/manager; project is
|
|
569
|
+
* repo-local only) matches buildTeamContextSection exactly.
|
|
570
|
+
*/
|
|
571
|
+
function resolveTeamContextFiles(workspaceRoot) {
|
|
572
|
+
return {
|
|
573
|
+
orgContext: resolveOrgContextFile(workspaceRoot, 'context/org_context.md'),
|
|
574
|
+
managerContext: resolveOrgContextFile(workspaceRoot, 'context/manager_context.md'),
|
|
575
|
+
orgRules: resolveOrgContextFile(workspaceRoot, 'rules/org_rules.md'),
|
|
576
|
+
managerRules: resolveOrgContextFile(workspaceRoot, 'rules/manager_rules.md'),
|
|
577
|
+
projectContext: resolveProjectContextFile(workspaceRoot, 'context/project_context.md'),
|
|
578
|
+
projectBrief: resolveProjectContextFile(workspaceRoot, 'context/project_brief.md'),
|
|
579
|
+
projectQa: resolveProjectContextFile(workspaceRoot, 'context/project_qa.md'),
|
|
580
|
+
projectRules: resolveProjectContextFile(workspaceRoot, 'rules/project_rules.md')
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
/** Map a key to its (relativePath, scope). Single source of truth. */
|
|
584
|
+
const TEAM_CONTEXT_FILE_MAP = {
|
|
585
|
+
org: { relativePath: 'context/org_context.md', scope: 'org' },
|
|
586
|
+
manager: { relativePath: 'context/manager_context.md', scope: 'org' },
|
|
587
|
+
orgRules: { relativePath: 'rules/org_rules.md', scope: 'org' },
|
|
588
|
+
managerRules: { relativePath: 'rules/manager_rules.md', scope: 'org' },
|
|
589
|
+
projectContext: { relativePath: 'context/project_context.md', scope: 'project' },
|
|
590
|
+
projectBrief: { relativePath: 'context/project_brief.md', scope: 'project' },
|
|
591
|
+
projectRules: { relativePath: 'rules/project_rules.md', scope: 'project' },
|
|
592
|
+
projectQa: { relativePath: 'context/project_qa.md', scope: 'project' }
|
|
593
|
+
};
|
|
594
|
+
function isTeamContextKey(value) {
|
|
595
|
+
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(TEAM_CONTEXT_FILE_MAP, value);
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Resolve the read + write locations for one editable context file.
|
|
599
|
+
*
|
|
600
|
+
* - Org scope (org/manager/orgRules/managerRules): a repo-local copy under `fraim/…` wins for
|
|
601
|
+
* both read and write when present (so the edit updates the file that actually
|
|
602
|
+
* shadows user-level for this repo); otherwise read AND write target the
|
|
603
|
+
* user-level `~/.fraim/…` path (where org onboarding writes).
|
|
604
|
+
* - Project scope (projectContext/projectBrief/projectRules/projectQa): repo-local only.
|
|
605
|
+
*/
|
|
606
|
+
function resolveTeamContextFile(workspaceRoot, key) {
|
|
607
|
+
const { relativePath, scope } = TEAM_CONTEXT_FILE_MAP[key];
|
|
608
|
+
const repoPath = (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', relativePath);
|
|
609
|
+
const repoDisplay = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`personalized-employee/${relativePath}`);
|
|
610
|
+
if (scope === 'project') {
|
|
611
|
+
// Repo-local only — read and write are the same path.
|
|
612
|
+
return {
|
|
613
|
+
present: (0, fs_1.existsSync)(repoPath),
|
|
614
|
+
readPath: (0, fs_1.existsSync)(repoPath) ? repoPath : '',
|
|
615
|
+
writePath: repoPath,
|
|
616
|
+
displayPath: repoDisplay,
|
|
617
|
+
scope
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
// Org scope: repo-local override wins when present.
|
|
621
|
+
if ((0, fs_1.existsSync)(repoPath)) {
|
|
622
|
+
return {
|
|
623
|
+
present: true,
|
|
624
|
+
readPath: repoPath,
|
|
625
|
+
writePath: repoPath,
|
|
626
|
+
displayPath: repoDisplay,
|
|
627
|
+
scope
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
const userPath = (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee', relativePath);
|
|
631
|
+
const userDisplay = (0, project_fraim_paths_1.getUserFraimDisplayPath)(`personalized-employee/${relativePath}`);
|
|
632
|
+
return {
|
|
633
|
+
present: (0, fs_1.existsSync)(userPath),
|
|
634
|
+
readPath: (0, fs_1.existsSync)(userPath) ? userPath : '',
|
|
635
|
+
// No repo-local override exists → write to the portable user-level path,
|
|
636
|
+
// matching where org onboarding persists these files.
|
|
637
|
+
writePath: userPath,
|
|
638
|
+
displayPath: userDisplay,
|
|
639
|
+
scope
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Count the number of `## [P-...]`-style entries inside a preserved learning
|
|
644
|
+
* file. Returns 0 when the file is absent or unreadable. Counts ALL entries
|
|
645
|
+
* (not score-gated) — the Brain summary reports preserved learnings, not the
|
|
646
|
+
* subset above the auto-load threshold.
|
|
647
|
+
*/
|
|
648
|
+
function countLearningEntries(filePath) {
|
|
649
|
+
if (!(0, fs_1.existsSync)(filePath))
|
|
650
|
+
return 0;
|
|
651
|
+
let content;
|
|
652
|
+
try {
|
|
653
|
+
content = (0, fs_1.readFileSync)(filePath, 'utf8');
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
return 0;
|
|
657
|
+
}
|
|
658
|
+
let count = 0;
|
|
659
|
+
const headingRe = learningEntryHeadingRegex();
|
|
660
|
+
for (const line of content.split(/\r?\n/)) {
|
|
661
|
+
if (headingRe.test(line))
|
|
662
|
+
count++;
|
|
663
|
+
}
|
|
664
|
+
return count;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Count preserved learnings by scope for the Brain summary (R14). Organization
|
|
668
|
+
* = L2 org-* files; manager = the personal manager-coaching file (reverse
|
|
669
|
+
* mentoring); project = personal mistake/preferences/validated patterns. Raw =
|
|
670
|
+
* un-dismissed L0 signals still awaiting synthesis. Reuses the same root/file
|
|
671
|
+
* resolution as buildLearningContextSection so the counts line up with what the
|
|
672
|
+
* agent actually auto-loads.
|
|
673
|
+
*/
|
|
674
|
+
function countPreservedLearnings(workspaceRoot, userId) {
|
|
675
|
+
const roots = getLearningRoots(workspaceRoot);
|
|
676
|
+
const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
|
|
677
|
+
// L2 organization-scope preserved files (repo-local org-* files).
|
|
678
|
+
const organization = countLearningEntries((0, path_1.join)(roots.repoLearningsBase, 'org-mistake-patterns.md')) +
|
|
679
|
+
countLearningEntries((0, path_1.join)(roots.repoLearningsBase, 'org-preferences.md')) +
|
|
680
|
+
countLearningEntries((0, path_1.join)(roots.repoLearningsBase, 'org-manager-coaching.md')) +
|
|
681
|
+
countLearningEntries((0, path_1.join)(roots.repoLearningsBase, 'org-validated-patterns.md'));
|
|
682
|
+
const resolve = (fileName) => resolvePersonalLearningFile(roots.repoLearningsBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, fileName);
|
|
683
|
+
// L1 manager-facing reverse-mentoring file.
|
|
684
|
+
const manager = countLearningEntries(resolve(`${resolvedUserId}-manager-coaching.md`).path);
|
|
685
|
+
// L1 personal work patterns (project scope).
|
|
686
|
+
const project = countLearningEntries(resolve(`${resolvedUserId}-mistake-patterns.md`).path) +
|
|
687
|
+
countLearningEntries(resolve(`${resolvedUserId}-preferences.md`).path) +
|
|
688
|
+
countLearningEntries(resolve(`${resolvedUserId}-validated-patterns.md`).path);
|
|
689
|
+
// L0 raw signals still awaiting synthesis (not dismissed).
|
|
690
|
+
let rawSignals = 0;
|
|
691
|
+
const rawDir = (0, path_1.join)(roots.repoLearningsBase, 'raw');
|
|
692
|
+
if ((0, fs_1.existsSync)(rawDir)) {
|
|
693
|
+
try {
|
|
694
|
+
for (const fileName of (0, fs_1.readdirSync)(rawDir)) {
|
|
695
|
+
if (!fileName.endsWith('.md'))
|
|
696
|
+
continue;
|
|
697
|
+
const fm = readFrontmatter((0, fs_1.readFileSync)((0, path_1.join)(rawDir, fileName), 'utf8'));
|
|
698
|
+
if (isTruthyFlag(fm.dismissed))
|
|
699
|
+
continue;
|
|
700
|
+
rawSignals++;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
// ignore unreadable raw dir
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return { organization, manager, project, rawSignals };
|
|
708
|
+
}
|
|
709
|
+
exports.CATEGORY_TO_FILETYPE = {
|
|
710
|
+
avoid: 'mistake-patterns',
|
|
711
|
+
preference: 'preferences',
|
|
712
|
+
repeat: 'validated-patterns',
|
|
713
|
+
coaching: 'manager-coaching',
|
|
714
|
+
};
|
|
715
|
+
const FILETYPE_TO_CATEGORY = {
|
|
716
|
+
'mistake-patterns': 'avoid',
|
|
717
|
+
'preferences': 'preference',
|
|
718
|
+
'validated-patterns': 'repeat',
|
|
719
|
+
'manager-coaching': 'coaching',
|
|
720
|
+
};
|
|
721
|
+
// Parse the `## [P-…] Title` entries (and their body prose) out of one learning
|
|
722
|
+
// file. Score/Last seen/Recurrences metadata lines are dropped — the Hub shows
|
|
723
|
+
// the human-readable learning, not the bookkeeping.
|
|
724
|
+
function parseLearningEntries(filePath, displayPath, category, level) {
|
|
725
|
+
if (!(0, fs_1.existsSync)(filePath))
|
|
726
|
+
return [];
|
|
727
|
+
let content;
|
|
728
|
+
try {
|
|
729
|
+
content = (0, fs_1.readFileSync)(filePath, 'utf8');
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
return [];
|
|
733
|
+
}
|
|
734
|
+
const out = [];
|
|
735
|
+
let current = null;
|
|
736
|
+
let bodyLines = [];
|
|
737
|
+
const flush = () => {
|
|
738
|
+
if (current) {
|
|
739
|
+
current.body = bodyLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
740
|
+
out.push(current);
|
|
741
|
+
}
|
|
742
|
+
current = null;
|
|
743
|
+
bodyLines = [];
|
|
744
|
+
};
|
|
745
|
+
const headingRe = learningEntryHeadingRegex();
|
|
746
|
+
for (const line of content.split(/\r?\n/)) {
|
|
747
|
+
const header = line.match(headingRe);
|
|
748
|
+
if (header) {
|
|
749
|
+
flush();
|
|
750
|
+
current = { severity: header[1], title: header[2].trim(), body: '', source: displayPath, category, level };
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
if (!current)
|
|
754
|
+
continue;
|
|
755
|
+
if (line.trim() === '---') {
|
|
756
|
+
flush();
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
// Drop the bookkeeping lines — the Hub shows the human-readable learning,
|
|
760
|
+
// not the scoring metadata.
|
|
761
|
+
const t = line.trim();
|
|
762
|
+
if (/^\*\*(Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t))
|
|
763
|
+
continue;
|
|
764
|
+
if (/^(First|Last) synthesized:/i.test(t))
|
|
765
|
+
continue;
|
|
766
|
+
bodyLines.push(line);
|
|
767
|
+
}
|
|
768
|
+
flush();
|
|
769
|
+
return out;
|
|
770
|
+
}
|
|
771
|
+
function levelDir(roots, level) {
|
|
772
|
+
if (level === 'machine')
|
|
773
|
+
return { dir: roots.globalPersonalBase, displayBase: roots.globalPersonalDisplayBase.replace(/\/$/, '') };
|
|
774
|
+
return { dir: roots.repoLearningsBase, displayBase: REPO_LEARNINGS_REL };
|
|
775
|
+
}
|
|
776
|
+
function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine') {
|
|
777
|
+
const roots = getLearningRoots(workspaceRoot);
|
|
778
|
+
const out = [];
|
|
779
|
+
if (scope === 'org') {
|
|
780
|
+
for (const cat of ['avoid', 'preference', 'repeat', 'coaching']) {
|
|
781
|
+
const f = `org-${exports.CATEGORY_TO_FILETYPE[cat]}.md`;
|
|
782
|
+
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'org'));
|
|
783
|
+
}
|
|
784
|
+
return out;
|
|
785
|
+
}
|
|
786
|
+
const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
|
|
787
|
+
const { dir, displayBase } = levelDir(roots, level);
|
|
788
|
+
const cats = scope === 'reverse' ? ['coaching'] : ['avoid', 'preference', 'repeat'];
|
|
789
|
+
for (const cat of cats) {
|
|
790
|
+
const f = `${resolvedUserId}-${exports.CATEGORY_TO_FILETYPE[cat]}.md`;
|
|
791
|
+
out.push(...parseLearningEntries((0, path_1.join)(dir, f), `${displayBase}/${f}`, cat, level));
|
|
792
|
+
}
|
|
793
|
+
return out;
|
|
794
|
+
}
|
|
795
|
+
function resolveLearningFilePath(workspaceRoot, userId, ref) {
|
|
796
|
+
const roots = getLearningRoots(workspaceRoot);
|
|
797
|
+
const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
|
|
798
|
+
if (ref.scope === 'org')
|
|
799
|
+
return (0, path_1.join)(roots.repoLearningsBase, `org-${fileType}.md`);
|
|
800
|
+
const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
|
|
801
|
+
const base = ref.level === 'project' ? roots.repoLearningsBase : roots.globalPersonalBase;
|
|
802
|
+
return (0, path_1.join)(base, `${resolvedUserId}-${fileType}.md`);
|
|
803
|
+
}
|
|
804
|
+
function titleCaseFromFileType(fileType) {
|
|
805
|
+
return fileType.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
806
|
+
}
|
|
807
|
+
function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
|
|
808
|
+
const filePath = resolveLearningFilePath(workspaceRoot, userId, ref);
|
|
809
|
+
const existing = (0, fs_1.existsSync)(filePath) ? (0, fs_1.readFileSync)(filePath, 'utf8') : '';
|
|
810
|
+
const nl = existing.includes('\r\n') ? '\r\n' : '\n';
|
|
811
|
+
const lines = existing.length ? existing.split(/\r?\n/) : [];
|
|
812
|
+
const headingRe = learningEntryHeadingRegex();
|
|
813
|
+
const starts = [];
|
|
814
|
+
lines.forEach((l, i) => { if (headingRe.test(l))
|
|
815
|
+
starts.push(i); });
|
|
816
|
+
const write = (text) => {
|
|
817
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(filePath), { recursive: true });
|
|
818
|
+
(0, fs_1.writeFileSync)(filePath, text.replace(/\s*$/, '') + nl, 'utf8');
|
|
819
|
+
};
|
|
820
|
+
if (action === 'add') {
|
|
821
|
+
const sev = (payload.severity || 'P-MED');
|
|
822
|
+
const title = (payload.title || '').trim() || 'Untitled learning';
|
|
823
|
+
const body = String(payload.body || '').trim();
|
|
824
|
+
const block = [`#### [${sev}] ${title}`, '', body, '', '---'].join(nl);
|
|
825
|
+
const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
|
|
826
|
+
const preamble = existing.trim()
|
|
827
|
+
? existing.replace(/\s*$/, '')
|
|
828
|
+
: `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileType)}`;
|
|
829
|
+
write(preamble + nl + nl + block);
|
|
830
|
+
return { ok: true, path: filePath };
|
|
831
|
+
}
|
|
832
|
+
const targetIdx = starts.findIndex((s) => {
|
|
833
|
+
const m = lines[s].match(headingRe);
|
|
834
|
+
return m && m[2].trim() === (payload.originalTitle || '').trim();
|
|
835
|
+
});
|
|
836
|
+
if (targetIdx === -1)
|
|
837
|
+
throw new Error(`Learning entry not found: "${payload.originalTitle}"`);
|
|
838
|
+
const start = starts[targetIdx];
|
|
839
|
+
const end = targetIdx + 1 < starts.length ? starts[targetIdx + 1] : lines.length;
|
|
840
|
+
if (action === 'delete') {
|
|
841
|
+
const next = [...lines.slice(0, start), ...lines.slice(end)];
|
|
842
|
+
write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
|
|
843
|
+
return { ok: true, path: filePath };
|
|
844
|
+
}
|
|
845
|
+
// edit: keep the entry's metadata lines (Score/Last seen/Recurrences/...) intact,
|
|
846
|
+
// replace only the heading (priority + title) and the prose body.
|
|
847
|
+
const headerMatch = lines[start].match(headingRe);
|
|
848
|
+
const sev = (payload.severity || headerMatch[1]);
|
|
849
|
+
const title = (payload.title !== undefined ? payload.title : headerMatch[2]).trim();
|
|
850
|
+
const meta = [];
|
|
851
|
+
for (const l of lines.slice(start + 1, end)) {
|
|
852
|
+
const t = l.trim();
|
|
853
|
+
if (t === '---')
|
|
854
|
+
continue;
|
|
855
|
+
if (/^\*\*(Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t) || /^(First|Last) synthesized:/i.test(t))
|
|
856
|
+
meta.push(l);
|
|
857
|
+
}
|
|
858
|
+
const body = String(payload.body !== undefined ? payload.body : '').trim();
|
|
859
|
+
const rebuilt = [`#### [${sev}] ${title}`, '', ...meta, ...(meta.length ? [''] : []), body, '', '---'];
|
|
860
|
+
const next = [...lines.slice(0, start), ...rebuilt, ...lines.slice(end)];
|
|
861
|
+
write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
|
|
862
|
+
return { ok: true, path: filePath };
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Interpret a frontmatter flag value (string) as a boolean. Mirrors how the
|
|
866
|
+
* synthesis jobs treat `dismissed:`/`blessed:` — only an explicit truthy value
|
|
867
|
+
* counts as set.
|
|
868
|
+
*/
|
|
869
|
+
function isTruthyFlag(value) {
|
|
870
|
+
if (value === undefined)
|
|
871
|
+
return false;
|
|
872
|
+
const normalized = value.trim().toLowerCase();
|
|
873
|
+
if (!normalized)
|
|
874
|
+
return false;
|
|
875
|
+
return normalized === 'true' || normalized === 'yes' || normalized === '1';
|
|
876
|
+
}
|
|
441
877
|
function renderBacklogDetail(oldestAgeDays, agingRisk) {
|
|
442
878
|
if (oldestAgeDays <= 0 && agingRisk <= 0)
|
|
443
879
|
return '';
|
|
@@ -472,6 +472,33 @@ class FraimLocalMCPServer {
|
|
|
472
472
|
}
|
|
473
473
|
return '';
|
|
474
474
|
}
|
|
475
|
+
// #533: persist the authenticated user email into ~/.fraim/preferences.json so
|
|
476
|
+
// the AI Hub (a separate process) can resolve personal learnings + show the real
|
|
477
|
+
// profile. Merge-and-write; never throw out of the connect path.
|
|
478
|
+
persistUserEmail(userEmail) {
|
|
479
|
+
try {
|
|
480
|
+
const fs = require('fs');
|
|
481
|
+
const path = require('path');
|
|
482
|
+
const dir = (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
483
|
+
const prefsPath = path.join(dir, 'preferences.json');
|
|
484
|
+
let prefs = {};
|
|
485
|
+
try {
|
|
486
|
+
prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf8'));
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
prefs = {};
|
|
490
|
+
}
|
|
491
|
+
if (prefs.userEmail === userEmail)
|
|
492
|
+
return; // already current
|
|
493
|
+
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
494
|
+
prefs.userEmail = userEmail;
|
|
495
|
+
(0, fs_1.writeFileSync)(prefsPath, JSON.stringify(prefs, null, 2), 'utf8');
|
|
496
|
+
this.log(`Persisted userEmail to ${prefsPath}`);
|
|
497
|
+
}
|
|
498
|
+
catch (e) {
|
|
499
|
+
this.log(`Could not persist userEmail: ${e instanceof Error ? e.message : String(e)}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
475
502
|
log(message) {
|
|
476
503
|
// Log to stderr (stdout is reserved for MCP protocol)
|
|
477
504
|
const key = this.apiKey || 'MISSING_API_KEY';
|
|
@@ -1199,6 +1226,11 @@ class FraimLocalMCPServer {
|
|
|
1199
1226
|
const userEmail = emailMatch[1].trim();
|
|
1200
1227
|
this.ensureEngine().setUserEmail(userEmail);
|
|
1201
1228
|
this.log(`[req:${requestId}] Captured user email for template substitution: ${userEmail}`);
|
|
1229
|
+
// #533: persist the authenticated email locally so the AI Hub (a separate
|
|
1230
|
+
// server that never sees fraim_connect) can resolve personal/manager
|
|
1231
|
+
// learnings and show the real identity in its profile card. Merge into
|
|
1232
|
+
// the existing preferences.json rather than overwriting it.
|
|
1233
|
+
this.persistUserEmail(userEmail);
|
|
1202
1234
|
// Inject learning context from the local workspace (RFC 177: files live on disk, not server).
|
|
1203
1235
|
const workspaceRoot = this.findProjectRoot() || process.cwd();
|
|
1204
1236
|
const learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, false);
|
|
@@ -1206,6 +1238,11 @@ class FraimLocalMCPServer {
|
|
|
1206
1238
|
responseText += learningSection;
|
|
1207
1239
|
this.log(`[req:${requestId}] Injected learning context for ${userEmail} from ${workspaceRoot}`);
|
|
1208
1240
|
}
|
|
1241
|
+
const teamContextSection = (0, learning_context_builder_js_1.buildTeamContextSection)(workspaceRoot, false);
|
|
1242
|
+
if (teamContextSection) {
|
|
1243
|
+
responseText += teamContextSection;
|
|
1244
|
+
this.log(`[req:${requestId}] Injected team context from ${workspaceRoot}`);
|
|
1245
|
+
}
|
|
1209
1246
|
}
|
|
1210
1247
|
if (this.latestConnectSyncWarning) {
|
|
1211
1248
|
responseText += `\n\n## Local Catalog\n${this.latestConnectSyncWarning}`;
|
|
@@ -1220,9 +1257,10 @@ class FraimLocalMCPServer {
|
|
|
1220
1257
|
if (typeof text === 'string' && userEmail) {
|
|
1221
1258
|
const workspaceRoot = this.findProjectRoot() || process.cwd();
|
|
1222
1259
|
const learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, true);
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1260
|
+
const teamContextSection = (0, learning_context_builder_js_1.buildTeamContextSection)(workspaceRoot, true);
|
|
1261
|
+
if (learningSection || teamContextSection) {
|
|
1262
|
+
finalizedResponse.result.content[0].text = text + `\n\n---` + learningSection + teamContextSection;
|
|
1263
|
+
this.log(`[req:${requestId}] Injected job-focus learning/team context for ${userEmail}`);
|
|
1226
1264
|
}
|
|
1227
1265
|
}
|
|
1228
1266
|
}
|
|
@@ -1942,9 +1980,10 @@ class FraimLocalMCPServer {
|
|
|
1942
1980
|
if (userEmail) {
|
|
1943
1981
|
const workspaceRoot = this.findProjectRoot() || process.cwd();
|
|
1944
1982
|
const learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, true);
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1983
|
+
const teamContextSection = (0, learning_context_builder_js_1.buildTeamContextSection)(workspaceRoot, true);
|
|
1984
|
+
if (learningSection || teamContextSection) {
|
|
1985
|
+
responseText += `\n\n---` + learningSection + teamContextSection;
|
|
1986
|
+
this.log(`✅ Injected job-focus learning/team context for ${userEmail} (local override path)`);
|
|
1948
1987
|
}
|
|
1949
1988
|
}
|
|
1950
1989
|
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, responseText);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.163",
|
|
4
4
|
"description": "FRAIM CLI - Framework for Rigor-based AI Management (alias for fraim-framework)",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"dev": "tsx --watch src/fraim-mcp-server.ts > server.log 2>&1",
|
|
11
11
|
"dev:prod": "npm run build && node dist/src/fraim-mcp-server.js > server.log 2>&1",
|
|
12
|
-
"build": "tsx scripts/build-fraim-config-schema-template.ts && npm run typecheck:scripts && tsc && npm run build:stubs && npm run build:fraim-brain && node scripts/copy-registry.js && npm run validate:registry && npm run validate:fraim-pro-assets && npm run validate:employee-catalog && tsx scripts/validate-purity.ts",
|
|
12
|
+
"build": "tsx scripts/build-fraim-config-schema-template.ts && npm run typecheck:scripts && tsc && npm run build:stubs && npm run build:fraim-brain && node scripts/copy-registry.js && npm run validate:registry && npm run validate:fraim-pro-assets && npm run validate:employee-catalog && npm run validate:learning-format-contract && tsx scripts/validate-purity.ts",
|
|
13
|
+
"validate:learning-format-contract": "tsx scripts/validate-learning-format-contract.ts",
|
|
13
14
|
"build:stubs": "tsx scripts/build-stub-registry.ts",
|
|
14
15
|
"build:fraim-brain": "node scripts/generate-fraim-brain.js",
|
|
15
|
-
"test-all": "npm run test && npm run test:isolated && npm run test:ui",
|
|
16
|
+
"test-all": "npm run test && npm run test:isolated tests/isolated/test-*.ts && npm run test:ui",
|
|
16
17
|
"test": "node scripts/test-with-server.js",
|
|
17
|
-
"test:isolated": "
|
|
18
|
+
"test:isolated": "node scripts/test-isolated.js",
|
|
18
19
|
"test:smoke": "node scripts/test-with-server.js --tags=smoke",
|
|
19
20
|
"test:coverage": "node scripts/test-with-server.js --tags=smoke --coverage",
|
|
20
21
|
"test:stripe": "node scripts/test-with-server.js tests/test-stripe-payment-complete.ts",
|