@alaarab/cortex 1.15.3 → 1.15.5

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.
@@ -47,7 +47,18 @@ export function buildHookOutput(selected, usedTokens, intent, gitCtx, detectedPr
47
47
  }
48
48
  }
49
49
  else {
50
- for (const injected of selected) {
50
+ // Position-aware injection: place most relevant at START and END (LaRA benchmark).
51
+ // Input `selected` is already ranked by relevance (best first).
52
+ // Reorder so: [0] stays first, [1] goes last, middle positions get [2..N-1].
53
+ let ordered = selected;
54
+ if (selected.length >= 3) {
55
+ ordered = [
56
+ selected[0], // most relevant → start
57
+ ...selected.slice(2), // remaining → middle
58
+ selected[1], // second most → end
59
+ ];
60
+ }
61
+ for (const injected of ordered) {
51
62
  const { doc, snippet, key } = injected;
52
63
  recordInjection(cortexPathLocal, key, sessionId);
53
64
  try {
@@ -1,4 +1,4 @@
1
- import { debugLog, appendAuditLog, runtimeFile, EXEC_TIMEOUT_MS, ensureCortexPath, } from "./shared.js";
1
+ import { debugLog, appendAuditLog, runtimeFile, sessionMarker, EXEC_TIMEOUT_MS, ensureCortexPath, } from "./shared.js";
2
2
  import { appendReviewQueue, recordFeedback, getQualityMultiplier, updateRuntimeHealth, withFileLock, } from "./shared-governance.js";
3
3
  import { detectProject, } from "./shared-index.js";
4
4
  import { addFindingToFile, } from "./shared-content.js";
@@ -526,7 +526,37 @@ export async function handleHookTool() {
526
526
  // best effort
527
527
  }
528
528
  const cwd = (data.cwd ?? input.cwd ?? undefined);
529
- const activeProject = cwd ? detectProject(getCortexPath(), cwd, profile) : null;
529
+ let activeProject = cwd ? detectProject(getCortexPath(), cwd, profile) : null;
530
+ // Check cooldown
531
+ const cooldownMs = Number.parseInt(process.env.CORTEX_HOOK_TOOL_COOLDOWN_MS || "30000", 10) || 30000;
532
+ const cooldownFile = runtimeFile(getCortexPath(), "hook-tool-cooldown");
533
+ try {
534
+ if (fs.existsSync(cooldownFile)) {
535
+ const age = Date.now() - fs.statSync(cooldownFile).mtimeMs;
536
+ if (age < cooldownMs) {
537
+ debugLog(`hook-tool: cooldown active (${Math.round(age / 1000)}s < ${Math.round(cooldownMs / 1000)}s), skipping extraction`);
538
+ // Skip to the log-only path
539
+ activeProject = null; // prevent extraction below
540
+ }
541
+ }
542
+ }
543
+ catch { /* best effort */ }
544
+ // Check session cap
545
+ const sessionCap = Number.parseInt(process.env.CORTEX_HOOK_TOOL_SESSION_CAP || "10", 10) || 10;
546
+ if (activeProject && sessionId) {
547
+ try {
548
+ const capFile = sessionMarker(getCortexPath(), `tool-findings-${sessionId}`);
549
+ let count = 0;
550
+ if (fs.existsSync(capFile)) {
551
+ count = Number.parseInt(fs.readFileSync(capFile, "utf8").trim(), 10) || 0;
552
+ }
553
+ if (count >= sessionCap) {
554
+ debugLog(`hook-tool: session cap reached (${count}/${sessionCap}), skipping extraction`);
555
+ activeProject = null; // prevent extraction below
556
+ }
557
+ }
558
+ catch { /* best effort */ }
559
+ }
530
560
  if (activeProject) {
531
561
  try {
532
562
  const candidates = extractToolFindings(toolName, input, responseStr);
@@ -540,6 +570,26 @@ export async function handleHookTool() {
540
570
  debugLog(`hook-tool: queued candidate (conf=${confidence}): ${text.slice(0, 60)}`);
541
571
  }
542
572
  }
573
+ // Update cooldown and session counter
574
+ if (candidates.length > 0) {
575
+ try {
576
+ fs.writeFileSync(cooldownFile, Date.now().toString());
577
+ }
578
+ catch { /* best effort */ }
579
+ if (sessionId) {
580
+ try {
581
+ const capFile = sessionMarker(getCortexPath(), `tool-findings-${sessionId}`);
582
+ let count = 0;
583
+ try {
584
+ count = Number.parseInt(fs.readFileSync(capFile, "utf8").trim(), 10) || 0;
585
+ }
586
+ catch { /* new file */ }
587
+ count += candidates.filter(c => c.confidence >= 0.85).length; // only count directly added ones
588
+ fs.writeFileSync(capFile, count.toString());
589
+ }
590
+ catch { /* best effort */ }
591
+ }
592
+ }
543
593
  }
544
594
  catch (err) {
545
595
  debugLog(`hook-tool: learning extraction failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -805,25 +805,30 @@ export function removeProjectFromProfile(cortexPath, profile, project) {
805
805
  return cortexOk(`Removed ${project} from profile ${profile}.`);
806
806
  });
807
807
  }
808
+ function buildProjectCard(dir) {
809
+ const name = path.basename(dir);
810
+ const summaryFile = path.join(dir, "summary.md");
811
+ const claudeFile = path.join(dir, "CLAUDE.md");
812
+ const summarySource = fs.existsSync(summaryFile)
813
+ ? fs.readFileSync(summaryFile, "utf8")
814
+ : fs.existsSync(claudeFile)
815
+ ? fs.readFileSync(claudeFile, "utf8")
816
+ : "";
817
+ const summary = summarySource
818
+ .split("\n")
819
+ .map((line) => line.trim())
820
+ .find((line) => line && !line.startsWith("#")) || "";
821
+ const docs = ["CLAUDE.md", "FINDINGS.md", "LEARNINGS.md", "summary.md", "backlog.md", "MEMORY_QUEUE.md"]
822
+ .filter((file) => fs.existsSync(path.join(dir, file)));
823
+ return { name, summary, docs };
824
+ }
808
825
  export function listProjectCards(cortexPath, profile) {
809
826
  const dirs = getProjectDirs(cortexPath, profile).sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
810
- const cards = [];
811
- for (const dir of dirs) {
812
- const name = path.basename(dir);
813
- const summaryFile = path.join(dir, "summary.md");
814
- const claudeFile = path.join(dir, "CLAUDE.md");
815
- const summarySource = fs.existsSync(summaryFile)
816
- ? fs.readFileSync(summaryFile, "utf8")
817
- : fs.existsSync(claudeFile)
818
- ? fs.readFileSync(claudeFile, "utf8")
819
- : "";
820
- const summary = summarySource
821
- .split("\n")
822
- .map((line) => line.trim())
823
- .find((line) => line && !line.startsWith("#")) || "";
824
- const docs = ["CLAUDE.md", "FINDINGS.md", "LEARNINGS.md", "summary.md", "backlog.md", "MEMORY_QUEUE.md"]
825
- .filter((file) => fs.existsSync(path.join(dir, file)));
826
- cards.push({ name, summary, docs });
827
+ const cards = dirs.map(buildProjectCard);
828
+ // Prepend global as a pinned entry so it's always accessible from the shell
829
+ const globalDir = path.join(cortexPath, "global");
830
+ if (fs.existsSync(globalDir)) {
831
+ cards.unshift(buildProjectCard(globalDir));
827
832
  }
828
833
  return cards;
829
834
  }
@@ -5,7 +5,8 @@ import * as path from "path";
5
5
  import { isValidProjectName } from "./utils.js";
6
6
  import { addBacklogItem as addBacklogItemStore, addBacklogItems as addBacklogItemsBatch, backlogMarkdown, completeBacklogItem as completeBacklogItemStore, completeBacklogItems as completeBacklogItemsBatch, readBacklog, readBacklogs, updateBacklogItem as updateBacklogItemStore, } from "./data-access.js";
7
7
  const BACKLOG_SECTION_ORDER = ["Active", "Queue", "Done"];
8
- function buildBacklogView(doc, status) {
8
+ const DEFAULT_BACKLOG_LIMIT = 20;
9
+ function buildBacklogView(doc, status, limit) {
9
10
  let includedSections;
10
11
  if (status === "all") {
11
12
  includedSections = BACKLOG_SECTION_ORDER;
@@ -22,11 +23,23 @@ function buildBacklogView(doc, status) {
22
23
  else {
23
24
  includedSections = ["Active", "Queue"];
24
25
  }
26
+ const effectiveLimit = limit ?? DEFAULT_BACKLOG_LIMIT;
27
+ let truncated = false;
25
28
  const items = {
26
- Active: includedSections.includes("Active") ? doc.items.Active : [],
27
- Queue: includedSections.includes("Queue") ? doc.items.Queue : [],
28
- Done: includedSections.includes("Done") ? doc.items.Done : [],
29
+ Active: [],
30
+ Queue: [],
31
+ Done: [],
29
32
  };
33
+ for (const section of includedSections) {
34
+ const sectionItems = doc.items[section];
35
+ if (sectionItems.length > effectiveLimit) {
36
+ items[section] = sectionItems.slice(0, effectiveLimit);
37
+ truncated = true;
38
+ }
39
+ else {
40
+ items[section] = sectionItems;
41
+ }
42
+ }
30
43
  const totalItems = BACKLOG_SECTION_ORDER.reduce((sum, section) => sum + items[section].length, 0);
31
44
  return {
32
45
  doc: {
@@ -35,8 +48,26 @@ function buildBacklogView(doc, status) {
35
48
  },
36
49
  includedSections,
37
50
  totalItems,
51
+ truncated,
38
52
  };
39
53
  }
54
+ function buildBacklogSummary(doc, includedSections) {
55
+ const lines = [`## ${doc.project}`];
56
+ for (const section of includedSections) {
57
+ const items = doc.items[section];
58
+ const highCount = items.filter(i => i.priority === "high").length;
59
+ const medCount = items.filter(i => i.priority === "medium").length;
60
+ lines.push(`**${section}**: ${items.length} items${highCount ? ` (${highCount} high` + (medCount ? `, ${medCount} medium` : "") + ")" : ""}`);
61
+ // Show first 3 items as preview
62
+ for (const item of items.slice(0, 3)) {
63
+ const prio = item.priority ? ` [${item.priority}]` : "";
64
+ lines.push(` - ${item.line.slice(0, 80)}${item.line.length > 80 ? "\u2026" : ""}${prio}`);
65
+ }
66
+ if (items.length > 3)
67
+ lines.push(` ... and ${items.length - 3} more`);
68
+ }
69
+ return lines.join("\n");
70
+ }
40
71
  export function register(server, ctx) {
41
72
  const { cortexPath, profile, withWriteQueue, updateFileInIndex } = ctx;
42
73
  server.registerTool("get_backlog", {
@@ -47,8 +78,10 @@ export function register(server, ctx) {
47
78
  id: z.string().optional().describe("Backlog item ID like A1, Q3, D2. Requires project."),
48
79
  item: z.string().optional().describe("Exact backlog item text. Requires project."),
49
80
  status: z.enum(["all", "active", "queue", "done", "active+queue"]).optional().describe("Which backlog sections to include. Defaults to 'active+queue'."),
81
+ limit: z.number().optional().describe("Max items per section to return. Default 20."),
82
+ summary: z.boolean().optional().describe("If true, return counts and titles only (no full content). Reduces token usage."),
50
83
  }),
51
- }, async ({ project, id, item, status }) => {
84
+ }, async ({ project, id, item, status, limit, summary }) => {
52
85
  // Single item lookup
53
86
  if (id || item) {
54
87
  if (!project)
@@ -78,7 +111,7 @@ export function register(server, ctx) {
78
111
  if (!result.ok)
79
112
  return mcpResponse({ ok: false, error: result.error });
80
113
  const doc = result.data;
81
- const view = buildBacklogView(doc, status);
114
+ const view = buildBacklogView(doc, status, limit);
82
115
  if (!fs.existsSync(doc.path)) {
83
116
  return mcpResponse({
84
117
  ok: true,
@@ -86,26 +119,43 @@ export function register(server, ctx) {
86
119
  data: { project, items: view.doc.items, includedSections: view.includedSections, totalItems: view.totalItems },
87
120
  });
88
121
  }
122
+ if (summary) {
123
+ return mcpResponse({
124
+ ok: true,
125
+ message: buildBacklogSummary(doc, view.includedSections),
126
+ data: { project, includedSections: view.includedSections, totalItems: view.totalItems, summary: true },
127
+ });
128
+ }
129
+ const truncationNote = view.truncated ? `\n\n_Showing first ${limit ?? DEFAULT_BACKLOG_LIMIT} items per section. Pass a higher limit to see more._` : "";
89
130
  return mcpResponse({
90
131
  ok: true,
91
- message: `## ${project}\n${backlogMarkdown(view.doc)}`,
92
- data: { project, items: view.doc.items, issues: doc.issues, includedSections: view.includedSections, totalItems: view.totalItems },
132
+ message: `## ${project}\n${backlogMarkdown(view.doc)}${truncationNote}`,
133
+ data: { project, items: view.doc.items, issues: doc.issues, includedSections: view.includedSections, totalItems: view.totalItems, truncated: view.truncated },
93
134
  });
94
135
  }
95
136
  // All projects
96
137
  const docs = readBacklogs(cortexPath, profile);
97
138
  if (!docs.length)
98
139
  return mcpResponse({ ok: true, message: "No backlogs found.", data: { projects: [] } });
99
- const views = docs.map((doc) => ({ project: doc.project, view: buildBacklogView(doc, status), issues: doc.issues }));
100
- const parts = views.map(({ project, view }) => `## ${project}\n${backlogMarkdown(view.doc)}`);
140
+ const views = docs.map((doc) => ({ project: doc.project, doc, view: buildBacklogView(doc, status, limit), issues: doc.issues }));
141
+ const anyTruncated = views.some(({ view }) => view.truncated);
142
+ let parts;
143
+ if (summary) {
144
+ parts = views.map(({ doc, view }) => buildBacklogSummary(doc, view.includedSections));
145
+ }
146
+ else {
147
+ parts = views.map(({ project, view }) => `## ${project}\n${backlogMarkdown(view.doc)}`);
148
+ }
149
+ const truncationNote = anyTruncated && !summary ? `\n\n_Showing first ${limit ?? DEFAULT_BACKLOG_LIMIT} items per section. Pass a higher limit to see more._` : "";
101
150
  const projectData = views.map(({ project, view, issues }) => ({
102
151
  project,
103
152
  items: view.doc.items,
104
153
  issues,
105
154
  includedSections: view.includedSections,
106
155
  totalItems: view.totalItems,
156
+ truncated: view.truncated,
107
157
  }));
108
- return mcpResponse({ ok: true, message: parts.join("\n\n"), data: { projects: projectData } });
158
+ return mcpResponse({ ok: true, message: parts.join("\n\n") + truncationNote, data: { projects: projectData, summary: summary || false } });
109
159
  });
110
160
  server.registerTool("add_backlog_item", {
111
161
  title: "◆ cortex · add task",
@@ -835,6 +835,8 @@ export function getQualityMultiplier(cortexPath, key) {
835
835
  let helpful = entry ? entry.helpful : 0;
836
836
  let repromptPenalty = entry ? entry.repromptPenalty : 0;
837
837
  let regressionPenalty = entry ? entry.regressionPenalty : 0;
838
+ let impressions = entry ? entry.impressions : 0;
839
+ let lastUsedAt = entry ? entry.lastUsedAt : "";
838
840
  // Include unflushed journal deltas
839
841
  const journalEntries = readScoreJournal(cortexPath).filter(e => e.key === key);
840
842
  for (const je of journalEntries) {
@@ -844,11 +846,39 @@ export function getQualityMultiplier(cortexPath, key) {
844
846
  repromptPenalty += je.delta.repromptPenalty;
845
847
  if (je.delta.regressionPenalty)
846
848
  regressionPenalty += je.delta.regressionPenalty;
849
+ if (je.delta.impressions)
850
+ impressions += je.delta.impressions;
851
+ // Track most recent journal timestamp as lastUsedAt
852
+ if (je.at && (!lastUsedAt || je.at > lastUsedAt))
853
+ lastUsedAt = je.at;
847
854
  }
848
855
  if (!entry && journalEntries.length === 0)
849
856
  return 1;
857
+ // ACT-R activation scoring components:
858
+ // 1. Temporal decay: recency of last retrieval
859
+ let recencyBoost = 0;
860
+ if (lastUsedAt) {
861
+ const lastUsedMs = new Date(lastUsedAt).getTime();
862
+ if (!Number.isNaN(lastUsedMs)) {
863
+ const daysSinceUse = Math.max(0, (Date.now() - lastUsedMs) / 86_400_000);
864
+ if (daysSinceUse <= 7) {
865
+ recencyBoost = 0.15; // recently used: boost
866
+ }
867
+ else if (daysSinceUse <= 30) {
868
+ recencyBoost = 0; // neutral
869
+ }
870
+ else {
871
+ recencyBoost = -0.1 * Math.min(3, (daysSinceUse - 30) / 30); // decay up to -0.3
872
+ }
873
+ }
874
+ }
875
+ // 2. Usage frequency: log-scaled impressions (diminishing returns)
876
+ const frequencyBoost = impressions > 0 ? Math.min(0.2, Math.log2(impressions + 1) * 0.05) : 0;
877
+ // 3. Feedback signals (existing)
850
878
  const penalties = repromptPenalty + regressionPenalty * 2;
851
- const raw = 1 + helpful * 0.15 - penalties * 0.2;
879
+ const feedbackScore = helpful * 0.15 - penalties * 0.2;
880
+ // Combine all components
881
+ const raw = 1 + feedbackScore + recencyBoost + frequencyBoost;
852
882
  return Math.max(0.2, Math.min(1.5, raw));
853
883
  }
854
884
  export function recordRetrieval(cortexPath, file, section) {
@@ -607,7 +607,7 @@ export function getListItems(cortexPath, profile, state, healthLineCount) {
607
607
  return getProjectSkills(cortexPath, state.project).map((s) => ({ name: s.name, text: s.path }));
608
608
  }
609
609
  case "Hooks": {
610
- return getHookEntries(cortexPath).map((e) => ({ name: e.tool, text: e.enabled ? "enabled" : "disabled" }));
610
+ return getHookEntries(cortexPath).map((e) => ({ name: e.event, text: e.enabled ? "active" : "inactive" }));
611
611
  }
612
612
  case "Health":
613
613
  return Array.from({ length: Math.max(1, healthLineCount) }, (_, i) => ({ id: String(i) }));
@@ -663,7 +663,7 @@ export async function activateSelected(host) {
663
663
  break;
664
664
  case "Hooks":
665
665
  if (item.name) {
666
- host.setMessage(` ${item.text === "enabled" ? style.boldGreen("enabled") : style.dim("disabled")} ${style.bold(item.name)}`);
666
+ host.setMessage(` ${item.text === "active" ? style.boldGreen("active") : style.dim("inactive")} ${style.bold(item.name)}`);
667
667
  }
668
668
  break;
669
669
  }
@@ -766,11 +766,10 @@ export async function doViewAction(host, key) {
766
766
  }
767
767
  break;
768
768
  case "Hooks":
769
- if ((key === "a" || key === "d") && item?.name) {
769
+ if (key === "a" || key === "d") {
770
770
  const enable = key === "a";
771
- const prefs = { hookTools: { ...((getHookEntries(host.cortexPath).reduce((acc, e) => ({ ...acc, [e.tool]: e.enabled }), {}))), [item.name]: enable } };
772
- writeInstallPreferences(host.cortexPath, prefs);
773
- host.setMessage(` ${enable ? style.boldGreen("Enabled") : style.dim("Disabled")} hooks for ${item.name}`);
771
+ writeInstallPreferences(host.cortexPath, { hooksEnabled: enable });
772
+ host.setMessage(` Hooks ${enable ? style.boldGreen("enabled") : style.dim("disabled")} — takes effect next session`);
774
773
  }
775
774
  break;
776
775
  }
@@ -434,20 +434,20 @@ export function renderSkillsView(ctx, cursor, height) {
434
434
  }
435
435
  return vp.lines;
436
436
  }
437
- // ── Hooks view ─────────────────────────────────────────────────────────────
438
- const HOOK_TOOLS = ["claude", "copilot", "cursor", "codex"];
437
+ const LIFECYCLE_HOOKS = [
438
+ { event: "UserPromptSubmit", description: "inject context before each prompt" },
439
+ { event: "Stop", description: "auto-save findings after each response" },
440
+ { event: "SessionStart", description: "git pull at session start" },
441
+ ];
439
442
  export function getHookEntries(cortexPath) {
440
443
  const prefs = readInstallPreferences(cortexPath);
441
444
  const hooksEnabled = prefs.hooksEnabled !== false;
442
- const toolPrefs = (prefs.hookTools && typeof prefs.hookTools === "object") ? prefs.hookTools : {};
443
- return HOOK_TOOLS.map((tool) => ({
444
- tool,
445
- enabled: hooksEnabled && toolPrefs[tool] !== false,
446
- }));
445
+ return LIFECYCLE_HOOKS.map((h) => ({ ...h, enabled: hooksEnabled }));
447
446
  }
448
447
  export function renderHooksView(ctx, cursor, height) {
449
448
  const cols = process.stdout.columns || 80;
450
449
  const entries = getHookEntries(ctx.cortexPath);
450
+ const allEnabled = entries.every((e) => e.enabled);
451
451
  const allLines = [];
452
452
  let cursorFirstLine = 0;
453
453
  let cursorLastLine = 0;
@@ -456,16 +456,24 @@ export function renderHooksView(ctx, cursor, height) {
456
456
  const isSelected = i === cursor;
457
457
  if (isSelected)
458
458
  cursorFirstLine = allLines.length;
459
- const statusBadge = e.enabled ? style.boldGreen("enabled ") : style.dim("disabled");
460
- let row = ` ${style.dim((i + 1).toString().padEnd(3))} ${statusBadge} ${style.bold(e.tool)}`;
461
- if (isSelected)
462
- row = `\x1b[7m${padToWidth(row, cols)}${RESET}`;
463
- else
464
- row = truncateLine(row, cols);
465
- allLines.push(row);
459
+ const statusBadge = e.enabled ? style.boldGreen("active ") : style.dim("inactive");
460
+ let nameRow = ` ${style.dim((i + 1).toString().padEnd(3))} ${statusBadge} ${style.bold(e.event)}`;
461
+ let descRow = ` ${style.dim(e.description)}`;
462
+ if (isSelected) {
463
+ nameRow = `\x1b[7m${padToWidth(nameRow, cols)}${RESET}`;
464
+ descRow = `\x1b[7m${padToWidth(descRow, cols)}${RESET}`;
465
+ }
466
+ else {
467
+ nameRow = truncateLine(nameRow, cols);
468
+ descRow = truncateLine(descRow, cols);
469
+ }
470
+ allLines.push(nameRow);
471
+ allLines.push(descRow);
466
472
  if (isSelected)
467
473
  cursorLastLine = allLines.length - 1;
468
474
  }
475
+ allLines.push("");
476
+ allLines.push(style.dim(` hooks: ${allEnabled ? style.boldGreen("ON") : style.boldRed("OFF")} · ${style.dim("a = enable all · d = disable all")}`));
469
477
  const usableHeight = Math.max(1, height - (allLines.length > height ? 1 : 0));
470
478
  const vp = lineViewport(allLines, cursorFirstLine, cursorLastLine, usableHeight, ctx.currentScroll());
471
479
  ctx.setScroll(vp.scrollStart);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alaarab/cortex",
3
- "version": "1.15.3",
3
+ "version": "1.15.5",
4
4
  "description": "Long-term memory for AI agents. Stored as markdown in a git repo you own.",
5
5
  "type": "module",
6
6
  "bin": {