md-first memory extraction framework for AI agents. Markdown is the single source of truth; SQLite holds state and LanceDB provides the rebuildable vector + BM25 + scalar index. The codebase follows a single-direction DDD layering (entrypoints -> service -> memory -> infra, with component / core / config cross-cutting) enforced by import-linter. Engineering surface: - Coding conventions in .claude/rules/ (path-scoped) and workflows in .claude/skills/ (/commit, /new-branch, /pr). - GitHub Actions CI runs make lint + test + integration; pre-commit mirrors the gates locally (ruff, hygiene hooks, gitlint commit-msg). - Commit messages follow Conventional Commits, enforced by gitlint. - make lint also enforces datetime two-zone discipline and OpenAPI drift.
191 lines
5.6 KiB
JavaScript
191 lines
5.6 KiB
JavaScript
/**
|
|
* Groups Store - Local persistence for memory groups (JSONL format)
|
|
*
|
|
* Each groupId+keyId combination is stored only once (no duplicates).
|
|
* Format: {"keyId":"...","groupId":"...","name":"...","path":"...","timestamp":"..."}
|
|
*
|
|
* keyId: SHA-256 hash (first 12 chars) of the API key - identifies which account owns this group
|
|
*/
|
|
|
|
import { readFileSync, appendFileSync, existsSync } from 'fs';
|
|
import { resolve, dirname, basename } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { getKeyId } from './config.js';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const GROUPS_FILE = resolve(__dirname, '../../../data/groups.jsonl');
|
|
|
|
/**
|
|
* Check if the groupId+keyId combination already exists in the file
|
|
* @param {string} groupId - The group ID to check
|
|
* @param {string} keyId - The key ID (hashed API key) to check
|
|
* @returns {boolean} True if already exists (should skip)
|
|
*/
|
|
function alreadyExists(groupId, keyId) {
|
|
try {
|
|
if (!existsSync(GROUPS_FILE)) {
|
|
return false;
|
|
}
|
|
|
|
const content = readFileSync(GROUPS_FILE, 'utf8');
|
|
const lines = content.trim().split('\n').filter(Boolean);
|
|
|
|
for (const line of lines) {
|
|
try {
|
|
const entry = JSON.parse(line);
|
|
// Match both groupId AND keyId (same project + same API key)
|
|
if (entry.groupId === groupId && entry.keyId === keyId) {
|
|
return true;
|
|
}
|
|
} catch {}
|
|
}
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append a group entry to the JSONL file
|
|
* Only records if the groupId+keyId combination doesn't already exist
|
|
* @param {string} groupId - The group ID
|
|
* @param {string} cwd - The working directory path
|
|
* @returns {Object|null} The entry if saved, null if skipped or error
|
|
*/
|
|
export function saveGroup(groupId, cwd) {
|
|
try {
|
|
const keyId = getKeyId();
|
|
|
|
// Skip if this groupId+keyId already exists
|
|
if (alreadyExists(groupId, keyId)) {
|
|
return null;
|
|
}
|
|
|
|
const entry = {
|
|
keyId, // Hashed API key identifier (null if not configured)
|
|
groupId,
|
|
name: basename(cwd),
|
|
path: cwd,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
appendFileSync(GROUPS_FILE, JSON.stringify(entry) + '\n', 'utf8');
|
|
return entry;
|
|
} catch (e) {
|
|
// Silent on errors
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load and aggregate groups from the JSONL file
|
|
* @param {string} [filterKeyId] - Optional keyId to filter by (only show groups for this API key)
|
|
* @returns {Array} Aggregated list of groups
|
|
*/
|
|
export function getGroups(filterKeyId = null) {
|
|
try {
|
|
if (!existsSync(GROUPS_FILE)) {
|
|
return [];
|
|
}
|
|
|
|
const content = readFileSync(GROUPS_FILE, 'utf8');
|
|
const lines = content.trim().split('\n').filter(Boolean);
|
|
|
|
// Aggregate by groupId+keyId (composite key)
|
|
const groupMap = new Map();
|
|
|
|
for (const line of lines) {
|
|
try {
|
|
const entry = JSON.parse(line);
|
|
|
|
// Skip if filtering by keyId and this entry doesn't match
|
|
if (filterKeyId && entry.keyId !== filterKeyId) {
|
|
continue;
|
|
}
|
|
|
|
// Use composite key: keyId:groupId (to separate same project under different accounts)
|
|
const compositeKey = `${entry.keyId || 'none'}:${entry.groupId}`;
|
|
const existing = groupMap.get(compositeKey);
|
|
|
|
if (existing) {
|
|
existing.sessionCount += 1;
|
|
// Update lastSeen if this timestamp is newer
|
|
if (entry.timestamp > existing.lastSeen) {
|
|
existing.lastSeen = entry.timestamp;
|
|
}
|
|
// Update firstSeen if this timestamp is older
|
|
if (entry.timestamp < existing.firstSeen) {
|
|
existing.firstSeen = entry.timestamp;
|
|
}
|
|
} else {
|
|
groupMap.set(compositeKey, {
|
|
id: entry.groupId,
|
|
keyId: entry.keyId || null,
|
|
name: entry.name,
|
|
path: entry.path,
|
|
firstSeen: entry.timestamp,
|
|
lastSeen: entry.timestamp,
|
|
sessionCount: 1
|
|
});
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
// Convert to array and sort by lastSeen (most recent first)
|
|
return Array.from(groupMap.values()).sort((a, b) =>
|
|
new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime()
|
|
);
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get groups for the current API key only
|
|
* @returns {Array} Aggregated list of groups for current keyId
|
|
*/
|
|
export function getMyGroups() {
|
|
const keyId = getKeyId();
|
|
return getGroups(keyId);
|
|
}
|
|
|
|
/**
|
|
* Get a specific group by ID (optionally filtered by current keyId)
|
|
* @param {string} groupId - The group ID
|
|
* @param {boolean} [filterByKey=true] - Whether to filter by current API key
|
|
* @returns {Object|null} The group or null if not found
|
|
*/
|
|
export function getGroup(groupId, filterByKey = true) {
|
|
const keyId = filterByKey ? getKeyId() : null;
|
|
const groups = getGroups(keyId);
|
|
return groups.find(g => g.id === groupId) || null;
|
|
}
|
|
|
|
/**
|
|
* Load raw groups data (for backward compatibility)
|
|
* @returns {Object} Groups data in old format
|
|
*/
|
|
export function loadGroups() {
|
|
return { groups: getGroups() };
|
|
}
|
|
|
|
/**
|
|
* Format relative time (e.g., "2h ago", "1d ago")
|
|
* @param {string} isoTime - ISO timestamp
|
|
* @returns {string} Relative time string
|
|
*/
|
|
export function formatRelativeTime(isoTime) {
|
|
const now = Date.now();
|
|
const then = new Date(isoTime).getTime();
|
|
const diffMs = now - then;
|
|
|
|
const minutes = Math.floor(diffMs / 60000);
|
|
const hours = Math.floor(diffMs / 3600000);
|
|
const days = Math.floor(diffMs / 86400000);
|
|
|
|
if (minutes < 1) return 'just now';
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
if (hours < 24) return `${hours}h ago`;
|
|
if (days < 30) return `${days}d ago`;
|
|
return `${Math.floor(days / 30)}mo ago`;
|
|
}
|