update owliver skill

This commit is contained in:
2026-08-14 17:33:16 +05:30
parent cccada9bd2
commit 4fdfb90326
58 changed files with 8015 additions and 1339 deletions

View File

@@ -0,0 +1,152 @@
/**
* Owliver's conversation history.
*
* The panel already persisted the *current* thread per page, in sessionStorage,
* so returning to Candidates found the conversation you left there. What it had
* no notion of was a conversation you had *finished*: starting a new one
* overwrote the old one, and there was no way back to anything.
*
* This is that archive, and it is deliberately the same artefact — a list of
* threads in the shape the panel already stores. Two differences, both
* intentional:
*
* - **localStorage, not session.** A history that empties when the tab closes
* is not a history. Threads outlive the session, which is what makes
* "Yesterday" and "Older" mean anything.
* - **Written continuously, not on reset.** The archive is updated as the
* conversation grows rather than when it ends, so a thread is recoverable
* even if the tab is closed mid-answer.
*
* Nothing here is a second conversation store: the live thread is still the one
* the panel renders from. This holds copies, keyed by conversation, for the one
* question the panel could not previously answer — "what did I ask before?"
*/
const KEY = 'krow_assistant:history';
/** Threads older than this stop being useful and start being clutter. */
const MAX_AGE_DAYS = 60;
/** A hard cap, so a heavy user cannot fill the origin's storage quota. */
const MAX_RECORDS = 100;
const DAY = 24 * 60 * 60 * 1000;
/** Every archived conversation, newest first. Never throws. */
export function readHistory() {
try {
const raw = localStorage.getItem(KEY);
const list = raw ? JSON.parse(raw) : [];
if (!Array.isArray(list)) return [];
const cutoff = Date.now() - MAX_AGE_DAYS * DAY;
return list
.filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length)
.filter((r) => new Date(r.updatedAt || 0).getTime() >= cutoff)
.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0));
} catch {
/* Corrupt or unavailable storage is an empty history, not an error the
reader has to deal with. */
return [];
}
}
/** The first thing the person asked, which is what the thread was about. */
function titleFor(messages) {
const first = messages.find((m) => m.role === 'user' && m.text);
const text = String(first?.text || '').trim();
if (!text) return 'Conversation';
return text.length > 60 ? `${text.slice(0, 57)}…` : text;
}
/**
* Records this conversation, replacing its own earlier entry.
*
* Matched on `id`, so a thread being added to updates in place rather than
* appearing once per turn.
*/
export function saveConversation({ id, contextId, page, messages }) {
if (!id || !Array.isArray(messages) || !messages.length) return readHistory();
const record = {
id,
contextId,
page: page || '',
title: titleFor(messages),
turns: messages.filter((m) => m.role === 'user').length,
updatedAt: new Date().toISOString(),
messages,
};
const next = [record, ...readHistory().filter((r) => r.id !== id)].slice(0, MAX_RECORDS);
try {
localStorage.setItem(KEY, JSON.stringify(next));
} catch {
/* Quota or private mode: the live thread is unaffected, only the archive
is. Dropping the oldest half is a better outcome than losing every
future write. */
try {
localStorage.setItem(KEY, JSON.stringify(next.slice(0, Math.floor(MAX_RECORDS / 4))));
} catch {
/* Storage is unusable. History is in-memory for this session only. */
}
}
return next;
}
/** Forgets one conversation. */
export function removeConversation(id) {
const next = readHistory().filter((r) => r.id !== id);
try {
localStorage.setItem(KEY, JSON.stringify(next));
} catch {
/* Ignore — the caller re-reads either way. */
}
return next;
}
/** Forgets everything. */
export function clearHistory() {
try {
localStorage.removeItem(KEY);
} catch {
/* Ignore. */
}
return [];
}
/**
* Conversations under a heading, in the order a person thinks about time.
*
* Empty groups are dropped rather than rendered as a heading with nothing
* under it.
*/
export function groupByRecency(records, now = new Date()) {
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const startOfYesterday = new Date(startOfToday.getTime() - DAY);
const startOfWeek = new Date(startOfToday.getTime() - 7 * DAY);
const groups = [
{ id: 'today', label: 'Today', items: [] },
{ id: 'yesterday', label: 'Yesterday', items: [] },
{ id: 'week', label: 'Earlier this week', items: [] },
{ id: 'older', label: 'Older', items: [] },
];
for (const record of records) {
const at = new Date(record.updatedAt || 0).getTime();
if (at >= startOfToday.getTime()) groups[0].items.push(record);
else if (at >= startOfYesterday.getTime()) groups[1].items.push(record);
else if (at >= startOfWeek.getTime()) groups[2].items.push(record);
else groups[3].items.push(record);
}
return groups.filter((g) => g.items.length);
}
/** A new conversation id. Time-ordered, so ids sort the way threads do. */
export function newConversationId() {
return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}