@alaarab/cortex 1.15.4 → 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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alaarab/cortex",
3
- "version": "1.15.4",
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": {