chore: import upstream snapshot with attribution
CI / integration tests (3.13) (push) Failing after 1s
Commit lint / pull request title (push) Has been skipped
Docs / links (push) Failing after 1s
CI / unit tests (3.13) (push) Failing after 1s
CI / lint (push) Failing after 1s
CI / integration tests (push) Failing after 1s
CI / package build (push) Failing after 1s
Commit lint / commit messages (push) Failing after 1s
CI / unit tests (push) Failing after 1s
CI / integration tests (3.13) (push) Failing after 1s
Commit lint / pull request title (push) Has been skipped
Docs / links (push) Failing after 1s
CI / unit tests (3.13) (push) Failing after 1s
CI / lint (push) Failing after 1s
CI / integration tests (push) Failing after 1s
CI / package build (push) Failing after 1s
Commit lint / commit messages (push) Failing after 1s
CI / unit tests (push) Failing after 1s
This commit is contained in:
@@ -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