@olares/lares-core 0.15.4 → 0.15.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ export const CONVERSATION_SETTINGS_NAMESPACE = "ui-conversation";
2
+ export const DEFAULT_BUSY_ENTER = "queue";
3
+
4
+ export function busyEnterBehavior(value) {
5
+ return value === "steer" ? "steer" : DEFAULT_BUSY_ENTER;
6
+ }
7
+
8
+ export function resolveSubmitMode(running, accelerated, steeringAvailable, preferred) {
9
+ if (!running || !steeringAvailable) return "queue";
10
+ const busyEnter = busyEnterBehavior(preferred);
11
+ if (!accelerated) return busyEnter;
12
+ return busyEnter === "queue" ? "steer" : "queue";
13
+ }
14
+
15
+ export function conversationSettings(described) {
16
+ const rows = Array.isArray(described?.namespaces) ? described.namespaces : [];
17
+ const section = rows.find((row) => row?.ns === CONVERSATION_SETTINGS_NAMESPACE);
18
+ if (!section) {
19
+ return { busyEnter: DEFAULT_BUSY_ENTER, revision: undefined, writable: false };
20
+ }
21
+ return {
22
+ busyEnter: busyEnterBehavior(section.value?.busyEnter),
23
+ revision: Number.isInteger(section.revision) ? section.revision : undefined,
24
+ writable: described?.writable !== false,
25
+ };
26
+ }
@@ -1,6 +1,7 @@
1
1
  import { producedPathsFromView } from "../files/deliverables.js";
2
2
  import { contextForm, contextProvenance, isHumanUserSource } from "./context-provenance.js";
3
3
  import { toolRowModel } from "./tool-row.js";
4
+ import { createTurnMetrics } from "./turn-metrics.js";
4
5
 
5
6
  export function textFromBlocks(blocks) {
6
7
  if (!Array.isArray(blocks)) return "";
@@ -98,6 +99,8 @@ export function foldTranscript(entries) {
98
99
  const items = [];
99
100
  const blocks = new Map();
100
101
  const tools = new Map();
102
+ const metrics = createTurnMetrics();
103
+ const lastReply = new Map();
101
104
  const retries = new Map();
102
105
  const fileSeen = new Set();
103
106
  let files = [];
@@ -132,7 +135,10 @@ export function foldTranscript(entries) {
132
135
  };
133
136
 
134
137
  const putBlock = (turn, step, index, row) => {
135
- upsert(items, blocks, blockKey(turn, step, index), row);
138
+ const stored = upsert(items, blocks, blockKey(turn, step, index), row);
139
+ // The turn's readings belong under its closing reply, the row whose action
140
+ // bar ends the turn on screen.
141
+ if (stored.type === "assistant") lastReply.set(Number(turn) || 0, stored);
136
142
  };
137
143
 
138
144
  const chunkIndex = (data, chunk) => {
@@ -219,6 +225,7 @@ export function foldTranscript(entries) {
219
225
  if (!event) continue;
220
226
  const data = event.data ?? {};
221
227
  const view = entryView(entry, event);
228
+ metrics.observe(event);
222
229
 
223
230
  if (event.type === "turn/start") {
224
231
  closeTurn();
@@ -322,6 +329,10 @@ export function foldTranscript(entries) {
322
329
  }
323
330
 
324
331
  settleOpen(items, running);
332
+ for (const [turn, row] of lastReply) {
333
+ const value = metrics.value(turn);
334
+ if (value) row.metrics = value;
335
+ }
325
336
  if (running) syncFiles();
326
337
  return {
327
338
  items,
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Turn timings read off the session log the way the harness client reads them:
3
+ * the turn's own wall time, its first step's time to first token, and decode
4
+ * throughput over the steps that report both a first token and provider usage.
5
+ * Every event carries `time` (epoch ms), so nothing here needs a clock.
6
+ */
7
+
8
+ function isTokenDelta(chunk) {
9
+ return chunk?.type === "text-delta" || chunk?.type === "reasoning-delta";
10
+ }
11
+
12
+ function pad2(value) {
13
+ return String(value).padStart(2, "0");
14
+ }
15
+
16
+ export function createTurnMetrics() {
17
+ const turns = new Map();
18
+
19
+ const turnRow = (turn) => {
20
+ const key = Number(turn) || 0;
21
+ let row = turns.get(key);
22
+ if (!row) {
23
+ row = {
24
+ startTime: 0,
25
+ endTime: 0,
26
+ messageTime: 0,
27
+ ttftMs: null,
28
+ ttftStep: Infinity,
29
+ decodeMs: 0,
30
+ decodeTokens: 0,
31
+ steps: new Map(),
32
+ };
33
+ turns.set(key, row);
34
+ }
35
+ return row;
36
+ };
37
+
38
+ const stepRow = (row, step) => {
39
+ const key = Number(step) || 0;
40
+ let cell = row.steps.get(key);
41
+ if (!cell) {
42
+ cell = { startTime: 0, firstTokenTime: 0 };
43
+ row.steps.set(key, cell);
44
+ }
45
+ return cell;
46
+ };
47
+
48
+ return {
49
+ observe(event) {
50
+ const time = Number(event?.time) || 0;
51
+ if (!time) return;
52
+ const data = event.data ?? {};
53
+ const turn = data.turn ?? data.message?.turn;
54
+ const step = data.step ?? data.message?.step;
55
+ if (event.type === "turn/start") {
56
+ turnRow(turn).startTime = time;
57
+ return;
58
+ }
59
+ if (event.type === "turn/end") {
60
+ turnRow(turn).endTime = time;
61
+ return;
62
+ }
63
+ if (event.type === "step/start") {
64
+ stepRow(turnRow(turn), step).startTime = time;
65
+ return;
66
+ }
67
+ if (event.type === "assistant/chunk") {
68
+ if (!isTokenDelta(data.chunk)) return;
69
+ const cell = stepRow(turnRow(turn), step);
70
+ if (!cell.firstTokenTime) cell.firstTokenTime = time;
71
+ return;
72
+ }
73
+ if (event.type !== "assistant/message") return;
74
+ const row = turnRow(turn);
75
+ const index = Number(step) || 0;
76
+ const cell = stepRow(row, index);
77
+ row.messageTime = time;
78
+ // The turn shows its earliest step's latency; later steps answer a tool
79
+ // result rather than the person, so their wait is a different question.
80
+ if (cell.startTime && cell.firstTokenTime && index < row.ttftStep) {
81
+ row.ttftStep = index;
82
+ row.ttftMs = Math.max(0, cell.firstTokenTime - cell.startTime);
83
+ }
84
+ const outputTokens = Number(data.usage?.outputTokens);
85
+ if (cell.firstTokenTime && Number.isFinite(outputTokens) && outputTokens > 0) {
86
+ row.decodeMs += Math.max(0, time - cell.firstTokenTime);
87
+ row.decodeTokens += outputTokens;
88
+ }
89
+ },
90
+ /** Readings for a turn the log has closed; null while it is still open. */
91
+ value(turn) {
92
+ const row = turns.get(Number(turn) || 0);
93
+ if (!row?.startTime || !row.endTime) return null;
94
+ const metrics = {
95
+ time: row.messageTime || row.endTime,
96
+ runMs: Math.max(0, row.endTime - row.startTime),
97
+ };
98
+ if (row.ttftMs !== null) metrics.ttftMs = row.ttftMs;
99
+ if (row.decodeMs > 0 && row.decodeTokens > 0) {
100
+ metrics.tokensPerSecond = row.decodeTokens / (row.decodeMs / 1000);
101
+ }
102
+ return metrics;
103
+ },
104
+ };
105
+ }
106
+
107
+ /** Wall clock of the reply, plus the calendar scope a reader needs to place it. */
108
+ export function clockParts(time, now = Date.now()) {
109
+ const at = new Date(Number(time) || 0);
110
+ const today = new Date(Number(now) || 0);
111
+ const sameYear = at.getFullYear() === today.getFullYear();
112
+ const sameDay = sameYear
113
+ && at.getMonth() === today.getMonth()
114
+ && at.getDate() === today.getDate();
115
+ return {
116
+ clock: `${pad2(at.getHours())}:${pad2(at.getMinutes())}`,
117
+ scope: sameDay ? "day" : sameYear ? "md" : "ymd",
118
+ y: at.getFullYear(),
119
+ m: at.getMonth() + 1,
120
+ d: at.getDate(),
121
+ };
122
+ }
123
+
124
+ /** Run time as whole seconds, carrying into minutes with padded seconds. */
125
+ export function durationParts(ms) {
126
+ const total = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
127
+ const minutes = Math.floor(total / 60);
128
+ const seconds = total % 60;
129
+ return { minutes, seconds: minutes > 0 ? pad2(seconds) : String(seconds) };
130
+ }
131
+
132
+ /** Sub-turn latency: one decimal under ten seconds, whole seconds beyond. */
133
+ export function latencySeconds(ms) {
134
+ const seconds = Math.max(0, Number(ms) || 0) / 1000;
135
+ return seconds < 10 ? String(Math.round(seconds * 10) / 10) : String(Math.round(seconds));
136
+ }
137
+
138
+ /** Decode throughput: whole tokens from ten up, one decimal below. */
139
+ export function throughputTokens(tokensPerSecond) {
140
+ const value = Math.max(0, Number(tokensPerSecond) || 0);
141
+ return value >= 10 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
142
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Which sessions moved on without being watched.
3
+ *
4
+ * The host has no read state to hand down: `session.list` carries `updatedAt`,
5
+ * `running` and `blank` and nothing else. The mux, however, streams events for
6
+ * every session at once, so a client knows about work landing in a session it
7
+ * is not showing. "Seen" is therefore decided here, per device, against the
8
+ * last moment each session was the open one.
9
+ */
10
+
11
+ export const SEEN_STORAGE_KEY = "lares.session.seen";
12
+
13
+ /**
14
+ * @param {unknown} value - whatever the store held, including nothing.
15
+ * @param {number} [now]
16
+ * @returns {{ since: number, sessions: Record<string, number> }}
17
+ */
18
+ export function readSeen(value, now = Date.now()) {
19
+ const rows = value && typeof value === "object" ? value.sessions : null;
20
+ const sessions = {};
21
+ if (rows && typeof rows === "object") {
22
+ for (const [sessionId, at] of Object.entries(rows)) {
23
+ const stamp = Number(at);
24
+ if (sessionId && Number.isFinite(stamp) && stamp > 0) sessions[sessionId] = stamp;
25
+ }
26
+ }
27
+ const since = Number(value && typeof value === "object" ? value.since : 0);
28
+ return { since: Number.isFinite(since) && since > 0 ? since : now, sessions };
29
+ }
30
+
31
+ export function markSeen(seen, sessionId, at = Date.now()) {
32
+ if (!sessionId) return seen;
33
+ const previous = seen.sessions[sessionId] ?? 0;
34
+ const stamp = Math.max(previous, Number(at) || 0);
35
+ if (stamp === previous) return seen;
36
+ return { since: seen.since, sessions: { ...seen.sessions, [sessionId]: stamp } };
37
+ }
38
+
39
+ /** Forget rows the host no longer lists, so the store cannot grow forever. */
40
+ export function pruneSeen(seen, sessions) {
41
+ const live = new Set((Array.isArray(sessions) ? sessions : []).map((row) => row?.sessionId).filter(Boolean));
42
+ const kept = Object.entries(seen.sessions).filter(([sessionId]) => live.has(sessionId));
43
+ if (kept.length === Object.keys(seen.sessions).length) return seen;
44
+ return { since: seen.since, sessions: Object.fromEntries(kept) };
45
+ }
46
+
47
+ /**
48
+ * A session never opened on this device is only news when it moved after
49
+ * tracking began — otherwise every history row would light up on first run.
50
+ * The open session is never marked: it is being watched right now.
51
+ *
52
+ * @returns {Record<string, true>} keyed by session id, for template lookups.
53
+ */
54
+ export function unseenSessions(sessions, seen, openSessionId = "") {
55
+ const marks = {};
56
+ for (const row of Array.isArray(sessions) ? sessions : []) {
57
+ const sessionId = row?.sessionId;
58
+ if (!sessionId || sessionId === openSessionId || row.blank) continue;
59
+ const updatedAt = Number(row.updatedAt) || 0;
60
+ if (!updatedAt) continue;
61
+ if (updatedAt > (seen.sessions[sessionId] ?? seen.since)) marks[sessionId] = true;
62
+ }
63
+ return marks;
64
+ }
@@ -0,0 +1,161 @@
1
+ import { visibleHistorySessions } from "./chat.js";
2
+
3
+ export const UNGROUPED_WORKSPACE = "";
4
+
5
+ /** Rows a workspace group shows before the "show more" cut (Lares app parity). */
6
+ export const COLLAPSED_SESSION_LIMIT = 5;
7
+
8
+ export function collapsedSessions(sessions, expanded) {
9
+ const rows = Array.isArray(sessions) ? sessions : [];
10
+ return expanded ? rows : rows.slice(0, COLLAPSED_SESSION_LIMIT);
11
+ }
12
+
13
+ export function hiddenSessionCount(sessions) {
14
+ return Math.max(0, (Array.isArray(sessions) ? sessions.length : 0) - COLLAPSED_SESSION_LIMIT);
15
+ }
16
+
17
+ export const FLAT_SESSION_ORDER_KEY = "__flat_session_order__";
18
+
19
+ export function reconciledSessionOrder(sessionIds, stored) {
20
+ if (!Array.isArray(stored)) return [...sessionIds];
21
+ const available = new Set(sessionIds);
22
+ const included = new Set();
23
+ const ordered = [];
24
+ for (const id of stored) {
25
+ if (!available.has(id) || included.has(id)) continue;
26
+ ordered.push(id);
27
+ included.add(id);
28
+ }
29
+ for (const id of sessionIds) {
30
+ if (!included.has(id)) ordered.push(id);
31
+ }
32
+ return ordered;
33
+ }
34
+
35
+ function compareSessionRecency(leftId, rightId, byId) {
36
+ const left = Number(byId.get(leftId)?.updatedAt) || 0;
37
+ const right = Number(byId.get(rightId)?.updatedAt) || 0;
38
+ if (left !== right) return right - left;
39
+ return leftId < rightId ? -1 : 1;
40
+ }
41
+
42
+ function sessionIdsByRecency(rows) {
43
+ const byId = new Map(rows.map((row) => [row.sessionId, row]));
44
+ return rows
45
+ .map((row) => row.sessionId)
46
+ .sort((left, right) => compareSessionRecency(left, right, byId));
47
+ }
48
+
49
+ export function nextSessionOrderAccount({
50
+ sessionIds,
51
+ previousOrder,
52
+ previousUpdatedAt = {},
53
+ sessions,
54
+ orderBy,
55
+ sortByRecency = false,
56
+ }) {
57
+ const byId = new Map((Array.isArray(sessions) ? sessions : []).map((row) => [row.sessionId, row]));
58
+ let order = reconciledSessionOrder(sessionIds, previousOrder);
59
+ if (sortByRecency) {
60
+ order.sort((left, right) => compareSessionRecency(left, right, byId));
61
+ } else if (orderBy === "updated") {
62
+ const promoted = sessionIds
63
+ .filter((id) => {
64
+ const row = byId.get(id);
65
+ return row && (previousUpdatedAt[id] === undefined || Number(row.updatedAt) > previousUpdatedAt[id]);
66
+ })
67
+ .sort((left, right) => compareSessionRecency(left, right, byId));
68
+ if (promoted.length) {
69
+ const ids = new Set(promoted);
70
+ order = [...promoted, ...order.filter((id) => !ids.has(id))];
71
+ }
72
+ }
73
+ return {
74
+ order,
75
+ updatedAt: Object.fromEntries(sessionIds.flatMap((id) => {
76
+ const row = byId.get(id);
77
+ return row ? [[id, Number(row.updatedAt) || 0]] : [];
78
+ })),
79
+ };
80
+ }
81
+
82
+ export function workspaceForSession(workspaces, sessionId) {
83
+ if (!sessionId) return undefined;
84
+ return (Array.isArray(workspaces) ? workspaces : []).find(
85
+ (workspace) => Array.isArray(workspace?.sessionIds) && workspace.sessionIds.includes(sessionId),
86
+ );
87
+ }
88
+
89
+ export function normalizeWorkspaceList(value) {
90
+ return {
91
+ items: Array.isArray(value?.items) ? value.items.filter((row) => row?.workspaceId) : [],
92
+ archivedSessionIds: Array.isArray(value?.archivedSessionIds) ? value.archivedSessionIds : [],
93
+ };
94
+ }
95
+
96
+ export function groupWorkspaceSessions(
97
+ sessions,
98
+ workspaces,
99
+ archivedSessionIds = [],
100
+ { query = "", flat = false, currentSessionId = "", sessionOrderByAccount = {} } = {},
101
+ ) {
102
+ const archived = new Set(archivedSessionIds);
103
+ const normalizedQuery = String(query).trim().toLocaleLowerCase();
104
+ // Same visibility cut as the Lares app: archived rows never show, and a blank
105
+ // session is only ever the selected provisional one.
106
+ const visible = visibleHistorySessions(sessions, currentSessionId).filter((row) => {
107
+ if (archived.has(row.sessionId)) return false;
108
+ if (!normalizedQuery) return true;
109
+ return String(row.title || "").toLocaleLowerCase().includes(normalizedQuery);
110
+ });
111
+ const byId = new Map(visible.map((row) => [row.sessionId, row]));
112
+
113
+ if (flat) {
114
+ const baseIds = sessionIdsByRecency(visible);
115
+ const ids = reconciledSessionOrder(
116
+ baseIds,
117
+ sessionOrderByAccount[FLAT_SESSION_ORDER_KEY],
118
+ );
119
+ return [{
120
+ key: "__all__",
121
+ workspaceId: undefined,
122
+ title: "",
123
+ sessions: ids.map((id) => byId.get(id)).filter(Boolean),
124
+ }];
125
+ }
126
+
127
+ const accounted = new Set();
128
+ const groups = (Array.isArray(workspaces) ? workspaces : []).map((workspace) => {
129
+ const rows = [];
130
+ const ids = reconciledSessionOrder(
131
+ workspace.sessionIds ?? [],
132
+ sessionOrderByAccount[workspace.workspaceId],
133
+ );
134
+ for (const sessionId of ids) {
135
+ accounted.add(sessionId);
136
+ const row = byId.get(sessionId);
137
+ if (row) rows.push(row);
138
+ }
139
+ return {
140
+ key: workspace.workspaceId,
141
+ workspaceId: workspace.workspaceId,
142
+ title: workspace.title,
143
+ path: workspace.path,
144
+ sessions: rows,
145
+ };
146
+ });
147
+ const ungroupedRows = visible.filter((row) => !accounted.has(row.sessionId));
148
+ const ungrouped = reconciledSessionOrder(
149
+ sessionIdsByRecency(ungroupedRows),
150
+ sessionOrderByAccount[UNGROUPED_WORKSPACE],
151
+ ).map((id) => byId.get(id)).filter(Boolean);
152
+ if (ungrouped.length) {
153
+ groups.push({
154
+ key: UNGROUPED_WORKSPACE,
155
+ workspaceId: undefined,
156
+ title: "",
157
+ sessions: ungrouped,
158
+ });
159
+ }
160
+ return groups.filter((group) => !normalizedQuery || group.sessions.length > 0);
161
+ }
@@ -34,4 +34,13 @@ export function loopbackAuthority(port) {
34
34
  export function applyLoopbackHeaders(headers, authority) {
35
35
  headers.host = authority;
36
36
  if (headers.origin !== undefined) headers.origin = `http://${authority}`;
37
+ // Fetch Metadata outlives the origin rewrite. The browser decides this one and
38
+ // we cannot argue with it: a packaged page calling the entrance is `cross-site`
39
+ // however the host and origin now read, and dsh refuses that outright — the
40
+ // rewrite lands, the request is still 403. Restate it to match the loopback
41
+ // pair the two lines above just produced.
42
+ //
43
+ // Only secure contexts get `Sec-` headers at all, which is why the LAN
44
+ // entrance (plain http) never ran into this and the https one always did.
45
+ if (headers["sec-fetch-site"] !== undefined) headers["sec-fetch-site"] = "same-origin";
37
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-core",
3
- "version": "0.15.4",
3
+ "version": "0.15.6",
4
4
  "description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
5
5
  "type": "module",
6
6
  "repository": {
@@ -61,6 +61,7 @@
61
61
  "./workspace/env": "./workspace/env.js",
62
62
  "./workspace/home": "./workspace/home.js",
63
63
  "./workspace/path": "./workspace/path.js",
64
+ "./workspace/constants": "./workspace/constants.js",
64
65
  "./workspace/seed": "./workspace/seed.js",
65
66
  "./workspace/session": "./workspace/session.js",
66
67
  "./files/filename": "./files/filename.js",
@@ -70,12 +71,14 @@
70
71
  "./files/upload-client": "./files/upload-client.js",
71
72
  "./files/preview": "./files/preview.js",
72
73
  "./files/preview-workspace": "./files/preview-workspace.js",
74
+ "./files/preview-tabs": "./files/preview-tabs.js",
73
75
  "./files/preview-groups": "./files/preview-groups.js",
74
76
  "./files/preview-cache": "./files/preview-cache.js",
75
77
  "./files/markdown": "./files/markdown.js",
76
78
  "./files/deliverables": "./files/deliverables.js",
77
79
  "./files/disposition": "./files/disposition.js",
78
80
  "./files/intake": "./files/intake.js",
81
+ "./files/draft-images": "./files/draft-images.js",
79
82
  "./files/mention": "./files/mention.js",
80
83
  "./drive/files-path": "./drive/files-path.js",
81
84
  "./drive/paths": "./drive/paths.js",
@@ -110,6 +113,15 @@
110
113
  "./larepass/session-cache": "./larepass/session-cache.js",
111
114
  "./larepass/runtime": "./larepass/runtime.js",
112
115
  "./larepass/questions": "./larepass/questions.js",
113
- "./larepass/settings": "./larepass/settings.js"
116
+ "./larepass/settings": "./larepass/settings.js",
117
+ "./larepass/workspace": "./larepass/workspace.js",
118
+ "./larepass/unread": "./larepass/unread.js",
119
+ "./larepass/permissions": "./larepass/permissions.js",
120
+ "./larepass/commands": "./larepass/commands.js",
121
+ "./larepass/queue": "./larepass/queue.js",
122
+ "./larepass/references": "./larepass/references.js",
123
+ "./larepass/submission-settings": "./larepass/submission-settings.js",
124
+ "./larepass/turn-metrics": "./larepass/turn-metrics.js",
125
+ "./larepass/context-occupancy": "./larepass/context-occupancy.js"
114
126
  }
115
127
  }
@@ -127,7 +127,7 @@ export function groupModelsByProvider(models) {
127
127
  for (const model of models) {
128
128
  let group = index.get(model.provider);
129
129
  if (group === undefined) {
130
- group = { provider: model.provider, models: [] };
130
+ group = { provider: model.provider, name: model.providerName || model.provider, models: [] };
131
131
  index.set(model.provider, group);
132
132
  groups.push(group);
133
133
  }
@@ -0,0 +1 @@
1
+ export const DEFAULT_WORKSPACE_TITLE = "Default";
package/workspace/seed.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync } from "node:fs";
2
+ import { DEFAULT_WORKSPACE_TITLE } from "./constants.js";
2
3
 
3
- export const DEFAULT_WORKSPACE_TITLE = "Default";
4
+ export { DEFAULT_WORKSPACE_TITLE };
4
5
 
5
6
  /**
6
7
  * @param {{ create: (path: string, title?: string) => Promise<{ id: string, path: string, title: string }> }} registry