chore: sync protocol outputs

This commit is contained in:
Peter Steinberger
2026-01-21 00:19:39 +00:00
parent 00bcb01bb4
commit e447233533
7 changed files with 209 additions and 161 deletions

View File

@@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String? public let replychannel: String?
public let accountid: String? public let accountid: String?
public let replyaccountid: String? public let replyaccountid: String?
public let threadid: String?
public let timeout: Int? public let timeout: Int?
public let lane: String? public let lane: String?
public let extrasystemprompt: String? public let extrasystemprompt: String?
@@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?, replychannel: String?,
accountid: String?, accountid: String?,
replyaccountid: String?, replyaccountid: String?,
threadid: String?,
timeout: Int?, timeout: Int?,
lane: String?, lane: String?,
extrasystemprompt: String?, extrasystemprompt: String?,
@@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel self.replychannel = replychannel
self.accountid = accountid self.accountid = accountid
self.replyaccountid = replyaccountid self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout self.timeout = timeout
self.lane = lane self.lane = lane
self.extrasystemprompt = extrasystemprompt self.extrasystemprompt = extrasystemprompt
@@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel" case replychannel = "replyChannel"
case accountid = "accountId" case accountid = "accountId"
case replyaccountid = "replyAccountId" case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout case timeout
case lane case lane
case extrasystemprompt = "extraSystemPrompt" case extrasystemprompt = "extraSystemPrompt"
@@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int? public let activeminutes: Int?
public let includeglobal: Bool? public let includeglobal: Bool?
public let includeunknown: Bool? public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String? public let label: String?
public let spawnedby: String? public let spawnedby: String?
public let agentid: String? public let agentid: String?
public let search: String?
public init( public init(
limit: Int?, limit: Int?,
activeminutes: Int?, activeminutes: Int?,
includeglobal: Bool?, includeglobal: Bool?,
includeunknown: Bool?, includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?, label: String?,
spawnedby: String?, spawnedby: String?,
agentid: String? agentid: String?,
search: String?
) { ) {
self.limit = limit self.limit = limit
self.activeminutes = activeminutes self.activeminutes = activeminutes
self.includeglobal = includeglobal self.includeglobal = includeglobal
self.includeunknown = includeunknown self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label self.label = label
self.spawnedby = spawnedby self.spawnedby = spawnedby
self.agentid = agentid self.agentid = agentid
self.search = search
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case limit case limit
case activeminutes = "activeMinutes" case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal" case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown" case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label case label
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
case agentid = "agentId" case agentid = "agentId"
case search
} }
} }

View File

@@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String? public let replychannel: String?
public let accountid: String? public let accountid: String?
public let replyaccountid: String? public let replyaccountid: String?
public let threadid: String?
public let timeout: Int? public let timeout: Int?
public let lane: String? public let lane: String?
public let extrasystemprompt: String? public let extrasystemprompt: String?
@@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?, replychannel: String?,
accountid: String?, accountid: String?,
replyaccountid: String?, replyaccountid: String?,
threadid: String?,
timeout: Int?, timeout: Int?,
lane: String?, lane: String?,
extrasystemprompt: String?, extrasystemprompt: String?,
@@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel self.replychannel = replychannel
self.accountid = accountid self.accountid = accountid
self.replyaccountid = replyaccountid self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout self.timeout = timeout
self.lane = lane self.lane = lane
self.extrasystemprompt = extrasystemprompt self.extrasystemprompt = extrasystemprompt
@@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel" case replychannel = "replyChannel"
case accountid = "accountId" case accountid = "accountId"
case replyaccountid = "replyAccountId" case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout case timeout
case lane case lane
case extrasystemprompt = "extraSystemPrompt" case extrasystemprompt = "extraSystemPrompt"
@@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int? public let activeminutes: Int?
public let includeglobal: Bool? public let includeglobal: Bool?
public let includeunknown: Bool? public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String? public let label: String?
public let spawnedby: String? public let spawnedby: String?
public let agentid: String? public let agentid: String?
public let search: String?
public init( public init(
limit: Int?, limit: Int?,
activeminutes: Int?, activeminutes: Int?,
includeglobal: Bool?, includeglobal: Bool?,
includeunknown: Bool?, includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?, label: String?,
spawnedby: String?, spawnedby: String?,
agentid: String? agentid: String?,
search: String?
) { ) {
self.limit = limit self.limit = limit
self.activeminutes = activeminutes self.activeminutes = activeminutes
self.includeglobal = includeglobal self.includeglobal = includeglobal
self.includeunknown = includeunknown self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label self.label = label
self.spawnedby = spawnedby self.spawnedby = spawnedby
self.agentid = agentid self.agentid = agentid
self.search = search
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case limit case limit
case activeminutes = "activeMinutes" case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal" case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown" case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label case label
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
case agentid = "agentId" case agentid = "agentId"
case search
} }
} }
@@ -1324,6 +1340,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
public let ts: Int public let ts: Int
public let channelorder: [String] public let channelorder: [String]
public let channellabels: [String: AnyCodable] public let channellabels: [String: AnyCodable]
public let channeldetaillabels: [String: AnyCodable]?
public let channelsystemimages: [String: AnyCodable]?
public let channelmeta: [[String: AnyCodable]]?
public let channels: [String: AnyCodable] public let channels: [String: AnyCodable]
public let channelaccounts: [String: AnyCodable] public let channelaccounts: [String: AnyCodable]
public let channeldefaultaccountid: [String: AnyCodable] public let channeldefaultaccountid: [String: AnyCodable]
@@ -1332,6 +1351,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
ts: Int, ts: Int,
channelorder: [String], channelorder: [String],
channellabels: [String: AnyCodable], channellabels: [String: AnyCodable],
channeldetaillabels: [String: AnyCodable]?,
channelsystemimages: [String: AnyCodable]?,
channelmeta: [[String: AnyCodable]]?,
channels: [String: AnyCodable], channels: [String: AnyCodable],
channelaccounts: [String: AnyCodable], channelaccounts: [String: AnyCodable],
channeldefaultaccountid: [String: AnyCodable] channeldefaultaccountid: [String: AnyCodable]
@@ -1339,6 +1361,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
self.ts = ts self.ts = ts
self.channelorder = channelorder self.channelorder = channelorder
self.channellabels = channellabels self.channellabels = channellabels
self.channeldetaillabels = channeldetaillabels
self.channelsystemimages = channelsystemimages
self.channelmeta = channelmeta
self.channels = channels self.channels = channels
self.channelaccounts = channelaccounts self.channelaccounts = channelaccounts
self.channeldefaultaccountid = channeldefaultaccountid self.channeldefaultaccountid = channeldefaultaccountid
@@ -1347,6 +1372,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
case ts case ts
case channelorder = "channelOrder" case channelorder = "channelOrder"
case channellabels = "channelLabels" case channellabels = "channelLabels"
case channeldetaillabels = "channelDetailLabels"
case channelsystemimages = "channelSystemImages"
case channelmeta = "channelMeta"
case channels case channels
case channelaccounts = "channelAccounts" case channelaccounts = "channelAccounts"
case channeldefaultaccountid = "channelDefaultAccountId" case channeldefaultaccountid = "channelDefaultAccountId"

View File

@@ -3,92 +3,92 @@ import { Type } from "@sinclair/typebox";
import { NonEmptyString, SessionLabelString } from "./primitives.js"; import { NonEmptyString, SessionLabelString } from "./primitives.js";
export const SessionsListParamsSchema = Type.Object( export const SessionsListParamsSchema = Type.Object(
{ {
limit: Type.Optional(Type.Integer({ minimum: 1 })), limit: Type.Optional(Type.Integer({ minimum: 1 })),
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
includeGlobal: Type.Optional(Type.Boolean()), includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()),
/** /**
* Read first 8KB of each session transcript to derive title from first user message. * Read first 8KB of each session transcript to derive title from first user message.
* Performs a file read per session - use `limit` to bound result set on large stores. * Performs a file read per session - use `limit` to bound result set on large stores.
*/ */
includeDerivedTitles: Type.Optional(Type.Boolean()), includeDerivedTitles: Type.Optional(Type.Boolean()),
/** /**
* Read last 16KB of each session transcript to extract most recent message preview. * Read last 16KB of each session transcript to extract most recent message preview.
* Performs a file read per session - use `limit` to bound result set on large stores. * Performs a file read per session - use `limit` to bound result set on large stores.
*/ */
includeLastMessage: Type.Optional(Type.Boolean()), includeLastMessage: Type.Optional(Type.Boolean()),
label: Type.Optional(SessionLabelString), label: Type.Optional(SessionLabelString),
spawnedBy: Type.Optional(NonEmptyString), spawnedBy: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString), agentId: Type.Optional(NonEmptyString),
search: Type.Optional(Type.String()), search: Type.Optional(Type.String()),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsResolveParamsSchema = Type.Object( export const SessionsResolveParamsSchema = Type.Object(
{ {
key: Type.Optional(NonEmptyString), key: Type.Optional(NonEmptyString),
label: Type.Optional(SessionLabelString), label: Type.Optional(SessionLabelString),
agentId: Type.Optional(NonEmptyString), agentId: Type.Optional(NonEmptyString),
spawnedBy: Type.Optional(NonEmptyString), spawnedBy: Type.Optional(NonEmptyString),
includeGlobal: Type.Optional(Type.Boolean()), includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsPatchParamsSchema = Type.Object( export const SessionsPatchParamsSchema = Type.Object(
{ {
key: NonEmptyString, key: NonEmptyString,
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])), label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
responseUsage: Type.Optional( responseUsage: Type.Optional(
Type.Union([ Type.Union([
Type.Literal("off"), Type.Literal("off"),
Type.Literal("tokens"), Type.Literal("tokens"),
Type.Literal("full"), Type.Literal("full"),
// Backward compat with older clients/stores. // Backward compat with older clients/stores.
Type.Literal("on"), Type.Literal("on"),
Type.Null(), Type.Null(),
]), ]),
), ),
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
sendPolicy: Type.Optional( sendPolicy: Type.Optional(
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
), ),
groupActivation: Type.Optional( groupActivation: Type.Optional(
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]), Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
), ),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsResetParamsSchema = Type.Object( export const SessionsResetParamsSchema = Type.Object(
{ key: NonEmptyString }, { key: NonEmptyString },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsDeleteParamsSchema = Type.Object( export const SessionsDeleteParamsSchema = Type.Object(
{ {
key: NonEmptyString, key: NonEmptyString,
deleteTranscript: Type.Optional(Type.Boolean()), deleteTranscript: Type.Optional(Type.Boolean()),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const SessionsCompactParamsSchema = Type.Object( export const SessionsCompactParamsSchema = Type.Object(
{ {
key: NonEmptyString, key: NonEmptyString,
maxLines: Type.Optional(Type.Integer({ minimum: 1 })), maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );

View File

@@ -1,4 +1,11 @@
import { Editor, type EditorOptions, type EditorTheme, type TUI, Key, matchesKey } from "@mariozechner/pi-tui"; import {
Editor,
type EditorOptions,
type EditorTheme,
type TUI,
Key,
matchesKey,
} from "@mariozechner/pi-tui";
export class CustomEditor extends Editor { export class CustomEditor extends Editor {
onEscape?: () => void; onEscape?: () => void;

View File

@@ -11,7 +11,7 @@ const WORD_BOUNDARY_CHARS = /[\s\-_./:#@]/;
* Check if position is at a word boundary. * Check if position is at a word boundary.
*/ */
export function isWordBoundary(text: string, index: number): boolean { export function isWordBoundary(text: string, index: number): boolean {
return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? ""); return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? "");
} }
/** /**
@@ -19,17 +19,17 @@ export function isWordBoundary(text: string, index: number): boolean {
* Returns null if no match. * Returns null if no match.
*/ */
export function findWordBoundaryIndex(text: string, query: string): number | null { export function findWordBoundaryIndex(text: string, query: string): number | null {
if (!query) return null; if (!query) return null;
const textLower = text.toLowerCase(); const textLower = text.toLowerCase();
const queryLower = query.toLowerCase(); const queryLower = query.toLowerCase();
const maxIndex = textLower.length - queryLower.length; const maxIndex = textLower.length - queryLower.length;
if (maxIndex < 0) return null; if (maxIndex < 0) return null;
for (let i = 0; i <= maxIndex; i++) { for (let i = 0; i <= maxIndex; i++) {
if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) { if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) {
return i; return i;
} }
} }
return null; return null;
} }
/** /**
@@ -37,31 +37,31 @@ export function findWordBoundaryIndex(text: string, query: string): number | nul
* Returns score (lower = better) or null if no match. * Returns score (lower = better) or null if no match.
*/ */
export function fuzzyMatchLower(queryLower: string, textLower: string): number | null { export function fuzzyMatchLower(queryLower: string, textLower: string): number | null {
if (queryLower.length === 0) return 0; if (queryLower.length === 0) return 0;
if (queryLower.length > textLower.length) return null; if (queryLower.length > textLower.length) return null;
let queryIndex = 0; let queryIndex = 0;
let score = 0; let score = 0;
let lastMatchIndex = -1; let lastMatchIndex = -1;
let consecutiveMatches = 0; let consecutiveMatches = 0;
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) { for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
if (textLower[i] === queryLower[queryIndex]) { if (textLower[i] === queryLower[queryIndex]) {
const isAtWordBoundary = isWordBoundary(textLower, i); const isAtWordBoundary = isWordBoundary(textLower, i);
if (lastMatchIndex === i - 1) { if (lastMatchIndex === i - 1) {
consecutiveMatches++; consecutiveMatches++;
score -= consecutiveMatches * 5; // Reward consecutive matches score -= consecutiveMatches * 5; // Reward consecutive matches
} else { } else {
consecutiveMatches = 0; consecutiveMatches = 0;
if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps
} }
if (isAtWordBoundary) score -= 10; // Reward word boundary matches if (isAtWordBoundary) score -= 10; // Reward word boundary matches
score += i * 0.1; // Slight penalty for later matches score += i * 0.1; // Slight penalty for later matches
lastMatchIndex = i; lastMatchIndex = i;
queryIndex++; queryIndex++;
} }
} }
return queryIndex < queryLower.length ? null : score; return queryIndex < queryLower.length ? null : score;
} }
/** /**
@@ -69,46 +69,46 @@ export function fuzzyMatchLower(queryLower: string, textLower: string): number |
* Supports space-separated tokens (all must match). * Supports space-separated tokens (all must match).
*/ */
export function fuzzyFilterLower<T extends { searchTextLower?: string }>( export function fuzzyFilterLower<T extends { searchTextLower?: string }>(
items: T[], items: T[],
queryLower: string, queryLower: string,
): T[] { ): T[] {
const trimmed = queryLower.trim(); const trimmed = queryLower.trim();
if (!trimmed) return items; if (!trimmed) return items;
const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0); const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0);
if (tokens.length === 0) return items; if (tokens.length === 0) return items;
const results: { item: T; score: number }[] = []; const results: { item: T; score: number }[] = [];
for (const item of items) { for (const item of items) {
const text = item.searchTextLower ?? ""; const text = item.searchTextLower ?? "";
let totalScore = 0; let totalScore = 0;
let allMatch = true; let allMatch = true;
for (const token of tokens) { for (const token of tokens) {
const score = fuzzyMatchLower(token, text); const score = fuzzyMatchLower(token, text);
if (score !== null) { if (score !== null) {
totalScore += score; totalScore += score;
} else { } else {
allMatch = false; allMatch = false;
break; break;
} }
} }
if (allMatch) results.push({ item, score: totalScore }); if (allMatch) results.push({ item, score: totalScore });
} }
results.sort((a, b) => a.score - b.score); results.sort((a, b) => a.score - b.score);
return results.map((r) => r.item); return results.map((r) => r.item);
} }
/** /**
* Prepare items for fuzzy filtering by pre-computing lowercase search text. * Prepare items for fuzzy filtering by pre-computing lowercase search text.
*/ */
export function prepareSearchItems<T extends { label?: string; description?: string; searchText?: string }>( export function prepareSearchItems<
items: T[], T extends { label?: string; description?: string; searchText?: string },
): (T & { searchTextLower: string })[] { >(items: T[]): (T & { searchTextLower: string })[] {
return items.map((item) => { return items.map((item) => {
const parts: string[] = []; const parts: string[] = [];
if (item.label) parts.push(item.label); if (item.label) parts.push(item.label);
if (item.description) parts.push(item.description); if (item.description) parts.push(item.description);
if (item.searchText) parts.push(item.searchText); if (item.searchText) parts.push(item.searchText);
return { ...item, searchTextLower: parts.join(" ").toLowerCase() }; return { ...item, searchTextLower: parts.join(" ").toLowerCase() };
}); });
} }

View File

@@ -5,10 +5,7 @@ import {
selectListTheme, selectListTheme,
settingsListTheme, settingsListTheme,
} from "../theme/theme.js"; } from "../theme/theme.js";
import { import { FilterableSelectList, type FilterableSelectItem } from "./filterable-select-list.js";
FilterableSelectList,
type FilterableSelectItem,
} from "./filterable-select-list.js";
import { SearchableSelectList } from "./searchable-select-list.js"; import { SearchableSelectList } from "./searchable-select-list.js";
export function createSelectList(items: SelectItem[], maxVisible = 7) { export function createSelectList(items: SelectItem[], maxVisible = 7) {

View File

@@ -1,15 +1,15 @@
export function formatRelativeTime(timestamp: number): string { export function formatRelativeTime(timestamp: number): string {
const now = Date.now(); const now = Date.now();
const diff = now - timestamp; const diff = now - timestamp;
const seconds = Math.floor(diff / 1000); const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
if (seconds < 60) return "just now"; if (seconds < 60) return "just now";
if (minutes < 60) return `${minutes}m ago`; if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`; if (hours < 24) return `${hours}h ago`;
if (days === 1) return "Yesterday"; if (days === 1) return "Yesterday";
if (days < 7) return `${days}d ago`; if (days < 7) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" }); return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
} }