chore: initialize EverOS 1.0.0
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.
This commit is contained in:
190
use-cases/claude-code-plugin/hooks/scripts/utils/groups-store.js
Normal file
190
use-cases/claude-code-plugin/hooks/scripts/utils/groups-store.js
Normal file
@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user