@namzu/cli 26.3.0 → 27.0.0
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.
- package/CHANGELOG.md +214 -0
- package/README.md +49 -34
- package/dist/bin.js +7 -0
- package/dist/bin.js.map +1 -1
- package/dist/checkpoints/store.d.ts +17 -4
- package/dist/checkpoints/store.d.ts.map +1 -1
- package/dist/checkpoints/store.js +20 -5
- package/dist/checkpoints/store.js.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/acp.d.ts +23 -1
- package/dist/commands/acp.d.ts.map +1 -1
- package/dist/commands/acp.js +41 -2
- package/dist/commands/acp.js.map +1 -1
- package/dist/commands/drain.d.ts +69 -2
- package/dist/commands/drain.d.ts.map +1 -1
- package/dist/commands/drain.js +214 -134
- package/dist/commands/drain.js.map +1 -1
- package/dist/commands/eval.js +1 -1
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/provider-wait.d.ts +5 -5
- package/dist/commands/provider-wait.d.ts.map +1 -1
- package/dist/commands/provider-wait.js +2 -2
- package/dist/commands/resident.d.ts.map +1 -1
- package/dist/commands/resident.js +2 -1
- package/dist/commands/resident.js.map +1 -1
- package/dist/commands/run-flags.d.ts +7 -7
- package/dist/commands/run-flags.js +4 -4
- package/dist/commands/run-stream.d.ts +16 -12
- package/dist/commands/run-stream.d.ts.map +1 -1
- package/dist/commands/run-stream.js +142 -98
- package/dist/commands/run-stream.js.map +1 -1
- package/dist/commands/run.d.ts +6 -4
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +129 -45
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/serve.js +2 -2
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/state.js +8 -8
- package/dist/commands/state.js.map +1 -1
- package/dist/config/load.d.ts +1 -1
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +17 -5
- package/dist/config/load.js.map +1 -1
- package/dist/config/run-limits.d.ts +20 -8
- package/dist/config/run-limits.d.ts.map +1 -1
- package/dist/config/run-limits.js +28 -46
- package/dist/config/run-limits.js.map +1 -1
- package/dist/config/schema.d.ts +44 -19
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/config/tool-result-screens.js +1 -1
- package/dist/context/directories.d.ts +37 -0
- package/dist/context/directories.d.ts.map +1 -0
- package/dist/context/directories.js +69 -0
- package/dist/context/directories.js.map +1 -0
- package/dist/context/environment.d.ts +55 -0
- package/dist/context/environment.d.ts.map +1 -1
- package/dist/context/environment.js +96 -0
- package/dist/context/environment.js.map +1 -1
- package/dist/context/project-tracker.d.ts +3 -3
- package/dist/context/project-tracker.d.ts.map +1 -1
- package/dist/context/project-tracker.js +3 -3
- package/dist/context/project-tracker.js.map +1 -1
- package/dist/context/project.d.ts +1 -1
- package/dist/context/project.js +1 -1
- package/dist/context/sandbox.d.ts +22 -3
- package/dist/context/sandbox.d.ts.map +1 -1
- package/dist/context/sandbox.js +42 -4
- package/dist/context/sandbox.js.map +1 -1
- package/dist/doctor/checks/chain.d.ts +1 -1
- package/dist/doctor/checks/chain.js +5 -5
- package/dist/doctor/checks/chain.js.map +1 -1
- package/dist/doctor/checks/invariants.d.ts +2 -2
- package/dist/doctor/checks/invariants.js +5 -5
- package/dist/doctor/checks/invariants.js.map +1 -1
- package/dist/doctor/checks/logging.js +1 -1
- package/dist/doctor/checks/logging.js.map +1 -1
- package/dist/doctor/checks/sandbox.js +1 -1
- package/dist/doctor/checks/sandbox.js.map +1 -1
- package/dist/doctor/checks/session-export.d.ts +1 -1
- package/dist/doctor/checks/session-export.js +2 -2
- package/dist/doctor/checks/session-export.js.map +1 -1
- package/dist/doctor/checks/telemetry.d.ts +1 -1
- package/dist/doctor/checks/telemetry.js +1 -1
- package/dist/doctor/registry.js +2 -2
- package/dist/doctor/registry.js.map +1 -1
- package/dist/exit-codes.d.ts +2 -2
- package/dist/exit-codes.js +2 -2
- package/dist/integrations/mcp/servers.d.ts +1 -1
- package/dist/integrations/providers/chain-capabilities.d.ts +6 -6
- package/dist/integrations/providers/chain-capabilities.js +8 -8
- package/dist/integrations/providers/chain-capabilities.js.map +1 -1
- package/dist/integrations/providers/register.d.ts +1 -1
- package/dist/integrations/providers/register.js +1 -1
- package/dist/integrations/providers/registry.d.ts +1 -1
- package/dist/integrations/resident/inspection.d.ts +4 -1
- package/dist/integrations/resident/inspection.d.ts.map +1 -1
- package/dist/integrations/resident/inspection.js +53 -34
- package/dist/integrations/resident/inspection.js.map +1 -1
- package/dist/integrations/resident/learning-storage.d.ts +7 -2
- package/dist/integrations/resident/learning-storage.d.ts.map +1 -1
- package/dist/integrations/resident/learning-storage.js +12 -8
- package/dist/integrations/resident/learning-storage.js.map +1 -1
- package/dist/integrations/resident/runner-store.d.ts +1 -1
- package/dist/integrations/resident/runner-store.d.ts.map +1 -1
- package/dist/integrations/resident/runner-store.js +5 -2
- package/dist/integrations/resident/runner-store.js.map +1 -1
- package/dist/integrations/resident/runner-worker.js +1 -0
- package/dist/integrations/resident/runner-worker.js.map +1 -1
- package/dist/integrations/resident/session-log-reads.d.ts +37 -0
- package/dist/integrations/resident/session-log-reads.d.ts.map +1 -0
- package/dist/integrations/resident/session-log-reads.js +125 -0
- package/dist/integrations/resident/session-log-reads.js.map +1 -0
- package/dist/integrations/resident/session-step.d.ts +2 -0
- package/dist/integrations/resident/session-step.d.ts.map +1 -1
- package/dist/integrations/resident/session-step.js +15 -13
- package/dist/integrations/resident/session-step.js.map +1 -1
- package/dist/integrations/resident/storage.d.ts +23 -1
- package/dist/integrations/resident/storage.d.ts.map +1 -1
- package/dist/integrations/resident/storage.js +60 -17
- package/dist/integrations/resident/storage.js.map +1 -1
- package/dist/integrations/resident/tool-evidence.d.ts +4 -2
- package/dist/integrations/resident/tool-evidence.d.ts.map +1 -1
- package/dist/integrations/resident/tool-evidence.js +36 -30
- package/dist/integrations/resident/tool-evidence.js.map +1 -1
- package/dist/integrations/resident/verification.d.ts +2 -2
- package/dist/integrations/resident/verification.d.ts.map +1 -1
- package/dist/integrations/resident/verification.js +4 -4
- package/dist/integrations/resident/verification.js.map +1 -1
- package/dist/integrations/sessions/checkpoint-view.d.ts +13 -0
- package/dist/integrations/sessions/checkpoint-view.d.ts.map +1 -0
- package/dist/integrations/sessions/checkpoint-view.js +48 -0
- package/dist/integrations/sessions/checkpoint-view.js.map +1 -0
- package/dist/integrations/sessions/context-inventory.js +1 -1
- package/dist/integrations/sessions/context-inventory.js.map +1 -1
- package/dist/integrations/sessions/conversation-search.d.ts +16 -20
- package/dist/integrations/sessions/conversation-search.d.ts.map +1 -1
- package/dist/integrations/sessions/conversation-search.js +305 -731
- package/dist/integrations/sessions/conversation-search.js.map +1 -1
- package/dist/integrations/sessions/evidence-page-validation.d.ts +4 -4
- package/dist/integrations/sessions/evidence-page-validation.d.ts.map +1 -1
- package/dist/integrations/sessions/evidence-page-validation.js +1 -1
- package/dist/integrations/sessions/evidence-page-validation.js.map +1 -1
- package/dist/integrations/sessions/evidence-recall.d.ts +40 -2
- package/dist/integrations/sessions/evidence-recall.d.ts.map +1 -1
- package/dist/integrations/sessions/evidence-recall.js +41 -97
- package/dist/integrations/sessions/evidence-recall.js.map +1 -1
- package/dist/integrations/sessions/store.d.ts +153 -85
- package/dist/integrations/sessions/store.d.ts.map +1 -1
- package/dist/integrations/sessions/store.js +414 -484
- package/dist/integrations/sessions/store.js.map +1 -1
- package/dist/integrations/sessions/task-context.d.ts +15 -2
- package/dist/integrations/sessions/task-context.d.ts.map +1 -1
- package/dist/integrations/sessions/task-context.js +34 -11
- package/dist/integrations/sessions/task-context.js.map +1 -1
- package/dist/integrations/sessions/transcript-export.d.ts +13 -6
- package/dist/integrations/sessions/transcript-export.d.ts.map +1 -1
- package/dist/integrations/sessions/transcript-export.js +148 -263
- package/dist/integrations/sessions/transcript-export.js.map +1 -1
- package/dist/integrations/state/home.d.ts +4 -16
- package/dist/integrations/state/home.d.ts.map +1 -1
- package/dist/integrations/state/home.js +6 -42
- package/dist/integrations/state/home.js.map +1 -1
- package/dist/integrations/state/project.d.ts +6 -7
- package/dist/integrations/state/project.d.ts.map +1 -1
- package/dist/integrations/state/project.js +7 -9
- package/dist/integrations/state/project.js.map +1 -1
- package/dist/integrations/state/report.d.ts +27 -41
- package/dist/integrations/state/report.d.ts.map +1 -1
- package/dist/integrations/state/report.js +172 -464
- package/dist/integrations/state/report.js.map +1 -1
- package/dist/integrations/state/retention.d.ts +18 -0
- package/dist/integrations/state/retention.d.ts.map +1 -0
- package/dist/integrations/state/retention.js +18 -0
- package/dist/integrations/state/retention.js.map +1 -0
- package/dist/integrations/subagents/activity.d.ts +36 -31
- package/dist/integrations/subagents/activity.d.ts.map +1 -1
- package/dist/integrations/subagents/activity.js +27 -23
- package/dist/integrations/subagents/activity.js.map +1 -1
- package/dist/integrations/subagents/batches.d.ts +91 -0
- package/dist/integrations/subagents/batches.d.ts.map +1 -0
- package/dist/integrations/subagents/batches.js +172 -0
- package/dist/integrations/subagents/batches.js.map +1 -0
- package/dist/integrations/subagents/parent.d.ts +26 -11
- package/dist/integrations/subagents/parent.d.ts.map +1 -1
- package/dist/integrations/subagents/parent.js +13 -28
- package/dist/integrations/subagents/parent.js.map +1 -1
- package/dist/integrations/subagents/policy.d.ts +2 -2
- package/dist/integrations/subagents/policy.d.ts.map +1 -1
- package/dist/integrations/subagents/policy.js +2 -2
- package/dist/integrations/subagents/policy.js.map +1 -1
- package/dist/integrations/subagents/replay.d.ts +91 -26
- package/dist/integrations/subagents/replay.d.ts.map +1 -1
- package/dist/integrations/subagents/replay.js +216 -114
- package/dist/integrations/subagents/replay.js.map +1 -1
- package/dist/integrations/subagents/runtime.d.ts +50 -26
- package/dist/integrations/subagents/runtime.d.ts.map +1 -1
- package/dist/integrations/subagents/runtime.js +132 -126
- package/dist/integrations/subagents/runtime.js.map +1 -1
- package/dist/integrations/subagents/saved-agents.d.ts +57 -0
- package/dist/integrations/subagents/saved-agents.d.ts.map +1 -0
- package/dist/integrations/subagents/saved-agents.js +102 -0
- package/dist/integrations/subagents/saved-agents.js.map +1 -0
- package/dist/integrations/subagents/slash.d.ts +55 -0
- package/dist/integrations/subagents/slash.d.ts.map +1 -0
- package/dist/integrations/subagents/slash.js +45 -0
- package/dist/integrations/subagents/slash.js.map +1 -0
- package/dist/integrations/telemetry/session-export.d.ts +6 -6
- package/dist/integrations/telemetry/session-export.d.ts.map +1 -1
- package/dist/integrations/telemetry/session-export.js +2 -2
- package/dist/memory/presentation.d.ts +11 -0
- package/dist/memory/presentation.d.ts.map +1 -1
- package/dist/memory/presentation.js +25 -0
- package/dist/memory/presentation.js.map +1 -1
- package/dist/memory/store.d.ts +13 -7
- package/dist/memory/store.d.ts.map +1 -1
- package/dist/memory/store.js +37 -11
- package/dist/memory/store.js.map +1 -1
- package/dist/memory/typed.d.ts +116 -0
- package/dist/memory/typed.d.ts.map +1 -0
- package/dist/memory/typed.js +336 -0
- package/dist/memory/typed.js.map +1 -0
- package/dist/permissions/checks.d.ts +1 -1
- package/dist/permissions/checks.js +1 -1
- package/dist/permissions/headless-trust.d.ts +2 -2
- package/dist/permissions/headless-trust.d.ts.map +1 -1
- package/dist/permissions/headless-trust.js +3 -3
- package/dist/permissions/headless-trust.js.map +1 -1
- package/dist/permissions/mode.d.ts +4 -4
- package/dist/permissions/mode.js +1 -1
- package/dist/permissions/rules.d.ts +1 -1
- package/dist/permissions/rules.js +1 -1
- package/dist/termination.d.ts +75 -0
- package/dist/termination.d.ts.map +1 -0
- package/dist/termination.js +143 -0
- package/dist/termination.js.map +1 -0
- package/dist/tui/AgentExplorer.d.ts +2 -2
- package/dist/tui/AgentExplorer.js +4 -4
- package/dist/tui/AgentExplorer.js.map +1 -1
- package/dist/tui/App.d.ts +9 -1
- package/dist/tui/App.d.ts.map +1 -1
- package/dist/tui/App.js +452 -303
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/LiveActivity.d.ts +1 -1
- package/dist/tui/LiveActivity.d.ts.map +1 -1
- package/dist/tui/PermissionOverlay.d.ts +8 -0
- package/dist/tui/PermissionOverlay.d.ts.map +1 -1
- package/dist/tui/PermissionOverlay.js +25 -2
- package/dist/tui/PermissionOverlay.js.map +1 -1
- package/dist/tui/Picker.js +1 -1
- package/dist/tui/agent.d.ts +208 -132
- package/dist/tui/agent.d.ts.map +1 -1
- package/dist/tui/agent.js +523 -313
- package/dist/tui/agent.js.map +1 -1
- package/dist/tui/conversation-evidence-view.js +3 -3
- package/dist/tui/conversation-evidence-view.js.map +1 -1
- package/dist/tui/conversation-history.d.ts +4 -21
- package/dist/tui/conversation-history.d.ts.map +1 -1
- package/dist/tui/conversation-history.js +4 -28
- package/dist/tui/conversation-history.js.map +1 -1
- package/dist/tui/index.d.ts.map +1 -1
- package/dist/tui/index.js +27 -1
- package/dist/tui/index.js.map +1 -1
- package/dist/tui/log-pane.js +1 -1
- package/dist/tui/markdownParser.d.ts +1 -1
- package/dist/tui/markdownParser.js +1 -1
- package/dist/tui/permission-review.d.ts +16 -0
- package/dist/tui/permission-review.d.ts.map +1 -1
- package/dist/tui/permission-review.js +25 -2
- package/dist/tui/permission-review.js.map +1 -1
- package/dist/tui/slashCommands.d.ts +29 -40
- package/dist/tui/slashCommands.d.ts.map +1 -1
- package/dist/tui/slashCommands.js +70 -61
- package/dist/tui/slashCommands.js.map +1 -1
- package/dist/tui/{run-interruption.d.ts → turn-interruption.d.ts} +4 -4
- package/dist/tui/turn-interruption.d.ts.map +1 -0
- package/dist/tui/{run-interruption.js → turn-interruption.js} +20 -20
- package/dist/tui/turn-interruption.js.map +1 -0
- package/dist/tui/turn-limits-settings.d.ts +39 -0
- package/dist/tui/turn-limits-settings.d.ts.map +1 -0
- package/dist/tui/{run-limits-settings.js → turn-limits-settings.js} +18 -18
- package/dist/tui/turn-limits-settings.js.map +1 -0
- package/dist/tui/types.d.ts +3 -3
- package/dist/tui/types.d.ts.map +1 -1
- package/dist/user-commands/store.d.ts +1 -1
- package/dist/user-commands/store.js +1 -1
- package/package.json +7 -7
- package/dist/integrations/sessions/compaction-evidence.d.ts +0 -5
- package/dist/integrations/sessions/compaction-evidence.d.ts.map +0 -1
- package/dist/integrations/sessions/compaction-evidence.js +0 -59
- package/dist/integrations/sessions/compaction-evidence.js.map +0 -1
- package/dist/integrations/sessions/database.d.ts +0 -5
- package/dist/integrations/sessions/database.d.ts.map +0 -1
- package/dist/integrations/sessions/database.js +0 -34
- package/dist/integrations/sessions/database.js.map +0 -1
- package/dist/integrations/sessions/paths.d.ts +0 -7
- package/dist/integrations/sessions/paths.d.ts.map +0 -1
- package/dist/integrations/sessions/paths.js +0 -14
- package/dist/integrations/sessions/paths.js.map +0 -1
- package/dist/integrations/sessions/run-discovery.d.ts +0 -24
- package/dist/integrations/sessions/run-discovery.d.ts.map +0 -1
- package/dist/integrations/sessions/run-discovery.js +0 -142
- package/dist/integrations/sessions/run-discovery.js.map +0 -1
- package/dist/integrations/sessions/turn-evidence.d.ts +0 -129
- package/dist/integrations/sessions/turn-evidence.d.ts.map +0 -1
- package/dist/integrations/sessions/turn-evidence.js +0 -517
- package/dist/integrations/sessions/turn-evidence.js.map +0 -1
- package/dist/integrations/subagents/history.d.ts +0 -26
- package/dist/integrations/subagents/history.d.ts.map +0 -1
- package/dist/integrations/subagents/history.js +0 -76
- package/dist/integrations/subagents/history.js.map +0 -1
- package/dist/integrations/subagents/runs.d.ts +0 -72
- package/dist/integrations/subagents/runs.d.ts.map +0 -1
- package/dist/integrations/subagents/runs.js +0 -155
- package/dist/integrations/subagents/runs.js.map +0 -1
- package/dist/tui/run-interruption.d.ts.map +0 -1
- package/dist/tui/run-interruption.js.map +0 -1
- package/dist/tui/run-limits-settings.d.ts +0 -39
- package/dist/tui/run-limits-settings.d.ts.map +0 -1
- package/dist/tui/run-limits-settings.js.map +0 -1
|
@@ -1,32 +1,46 @@
|
|
|
1
|
-
import { sessionStore } from './database.js';
|
|
2
1
|
/**
|
|
3
|
-
* Conversation persistence for the
|
|
4
|
-
* indexed SQLite session store. Each checkout is one Project (an immutable
|
|
5
|
-
* root binding keeps its id stable across launches),
|
|
6
|
-
* every conversation is a Session under a fixed CLI Topic, and the
|
|
7
|
-
* conversation's messages are appended to the Session as turns complete.
|
|
2
|
+
* Conversation persistence for the CLI, built on the SDK's session log.
|
|
8
3
|
*
|
|
9
|
-
*
|
|
10
|
-
* session
|
|
11
|
-
*
|
|
12
|
-
*
|
|
4
|
+
* Every conversation is one session: an append-only, hash-chained JSONL file
|
|
5
|
+
* at `$NAMZU_HOME/projects/<slug>/<session-id>.jsonl`. The log is the source
|
|
6
|
+
* of truth. The rebuildable index at `$NAMZU_HOME/index.sqlite` answers the
|
|
7
|
+
* questions a list needs (which sessions this project has, which one a
|
|
8
|
+
* desktop key names) and is refreshed from the log after every write here.
|
|
9
|
+
*
|
|
10
|
+
* The CLI writes only the records that happen outside a turn: the
|
|
11
|
+
* `session_started` that creates a conversation, `session_updated` for its
|
|
12
|
+
* title, archive flag and caller-side names, and the `compaction` record that
|
|
13
|
+
* seeds a fork with the history it copied. Messages and turns are appended by
|
|
14
|
+
* the kernel's turn recorder while `query()` runs, under the session lease.
|
|
15
|
+
*
|
|
16
|
+
* This is what powers `/resume`: list recent sessions, fold a chosen
|
|
17
|
+
* session's messages, and keep chatting in it. The project is the canonical
|
|
18
|
+
* checkout root, so every directory of one checkout shares its history.
|
|
13
19
|
*/
|
|
14
|
-
import { createHash,
|
|
15
|
-
import {
|
|
20
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
21
|
+
import { mkdirSync } from 'node:fs';
|
|
16
22
|
import { realpath } from 'node:fs/promises';
|
|
17
|
-
import { basename,
|
|
23
|
+
import { basename, resolve } from 'node:path';
|
|
24
|
+
import { join } from 'node:path';
|
|
18
25
|
import { isDeepStrictEqual } from 'node:util';
|
|
19
|
-
import { DiskSessionGoalStore, asSessionId, asTopicId, isEntityId,
|
|
20
|
-
import { restrictToOwner } from '../providers/credential-store.js';
|
|
26
|
+
import { DiskSessionGoalStore, DiskSessionLog, SessionPaths, asSessionId, asTopicId, ensureProject, foldSessionMessages, generateSessionId, isEntityId, openSessionIndex, readSessionLog, } from '@namzu/sdk';
|
|
21
27
|
import { resolveNamzuHome } from '../state/home.js';
|
|
22
28
|
import { loadIdentity } from '../state/identity.js';
|
|
23
29
|
import { ensurePrivateStateDirectory } from '../state/private-directory.js';
|
|
24
|
-
import { cliProjectRoot
|
|
25
|
-
|
|
30
|
+
import { cliProjectRoot } from '../state/project.js';
|
|
31
|
+
/** Who created a conversation, as its `session_started.agent` records it. */
|
|
32
|
+
const CLI_AGENT = { id: 'namzu-cli', name: 'Namzu' };
|
|
33
|
+
/** How long a write outside a turn waits for a lease another writer holds. */
|
|
34
|
+
const LEASE_WAIT_MS = 5_000;
|
|
35
|
+
const LEASE_POLL_MS = 25;
|
|
36
|
+
const LEASE_TTL_MS = 30_000;
|
|
26
37
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
38
|
+
* Open (or initialize) the working directory's project: `projects/<slug>/`
|
|
39
|
+
* with its `project.json`, the installation's tenant, and the index.
|
|
40
|
+
*
|
|
41
|
+
* The project is the nearest checkout root (a `.git` file or directory), so
|
|
42
|
+
* worktrees and nested repositories keep distinct history while every
|
|
43
|
+
* directory of one checkout shares it. Tool cwd is unchanged.
|
|
30
44
|
*/
|
|
31
45
|
export async function openSessions(cwd, options = {}) {
|
|
32
46
|
const workingDirectory = await realpath(resolve(cwd));
|
|
@@ -35,436 +49,426 @@ export async function openSessions(cwd, options = {}) {
|
|
|
35
49
|
...(options.home !== undefined ? { home: options.home } : {}),
|
|
36
50
|
...(options.env !== undefined ? { env: options.env } : {}),
|
|
37
51
|
}));
|
|
52
|
+
mkdirSync(root, { recursive: true });
|
|
38
53
|
// The installation owns the tenant; the canonical checkout owns the Project.
|
|
39
54
|
const tenantId = loadIdentity(root).tenantId;
|
|
40
|
-
ensurePrivateStateDirectory(root, '
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
const projectsDir = ensurePrivateStateDirectory(root, 'projects');
|
|
56
|
+
const project = await ensureProject({ home: root, cwd: cliProjectRoot(workingDirectory) });
|
|
57
|
+
ensurePrivateStateDirectory(projectsDir, project.slug);
|
|
58
|
+
const paths = new SessionPaths({ home: root, slug: project.slug });
|
|
59
|
+
const index = await openSessionIndex({
|
|
60
|
+
home: root,
|
|
61
|
+
...(options.indexBackend ? { backend: options.indexBackend } : {}),
|
|
62
|
+
});
|
|
63
|
+
const partial = {
|
|
64
|
+
root,
|
|
65
|
+
paths,
|
|
66
|
+
slug: project.slug,
|
|
67
|
+
projectRoot: project.cwd,
|
|
68
|
+
projectId: project.projectId,
|
|
69
|
+
topicId: topicIdFor(project.projectId),
|
|
70
|
+
tenantId,
|
|
71
|
+
index,
|
|
72
|
+
};
|
|
73
|
+
const store = {
|
|
74
|
+
getSession: (sessionId, tenant) => readSessionEntity(partial, sessionId, tenant),
|
|
75
|
+
};
|
|
58
76
|
return {
|
|
77
|
+
...partial,
|
|
59
78
|
store,
|
|
60
|
-
goals: new DiskSessionGoalStore({ rootDir:
|
|
61
|
-
projectId,
|
|
62
|
-
topicId: topicIdFor(projectId),
|
|
63
|
-
tenantId,
|
|
64
|
-
root,
|
|
65
|
-
projectStateRoot,
|
|
66
|
-
controlRoot,
|
|
67
|
-
turnEvidence: new DiskConversationEvidence({ root, projectId }),
|
|
79
|
+
goals: new DiskSessionGoalStore({ rootDir: paths.projectDir(), sessions: store }),
|
|
68
80
|
};
|
|
69
81
|
}
|
|
82
|
+
/** Release the index's database handle. Idempotent. */
|
|
83
|
+
export function closeSessions(s) {
|
|
84
|
+
try {
|
|
85
|
+
s.index.close();
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Already closed.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
70
91
|
/** One stable CLI topic per logical workspace, without an extra binding file. */
|
|
71
92
|
function topicIdFor(projectId) {
|
|
72
93
|
const digest = createHash('sha256').update(`namzu:cli-topic:${projectId}`).digest('hex');
|
|
73
94
|
return asTopicId(`${digest.slice(0, 8)}-${digest.slice(8, 12)}-8${digest.slice(13, 16)}-a${digest.slice(17, 20)}-${digest.slice(20, 32)}`);
|
|
74
95
|
}
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
// ─── the log ──────────────────────────────────────────────────────────────
|
|
97
|
+
/** The file a root conversation's log lives in. */
|
|
98
|
+
export function conversationLogPath(s, sessionId) {
|
|
99
|
+
return s.paths.sessionLog({ sessionId });
|
|
100
|
+
}
|
|
101
|
+
/** A writer handle on one conversation's log. */
|
|
102
|
+
export function openConversationLog(s, sessionId) {
|
|
103
|
+
return DiskSessionLog.at(s.paths, { sessionId });
|
|
104
|
+
}
|
|
105
|
+
function holderName() {
|
|
106
|
+
return `namzu-cli:${process.pid}:${randomUUID()}`;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Take a conversation's writer lease, waiting briefly for another writer.
|
|
110
|
+
*
|
|
111
|
+
* A turn holds the lease while it runs, so a write between turns only ever
|
|
112
|
+
* waits for a turn that is settling. One that is still running when the wait
|
|
113
|
+
* ends is refused by name rather than waited on indefinitely.
|
|
114
|
+
*/
|
|
115
|
+
async function claimLease(log, op) {
|
|
116
|
+
const deadline = Date.now() + LEASE_WAIT_MS;
|
|
117
|
+
const holder = holderName();
|
|
118
|
+
for (;;) {
|
|
119
|
+
const lease = await log.claim({ holder, ttlMs: LEASE_TTL_MS });
|
|
120
|
+
if (lease)
|
|
121
|
+
return lease;
|
|
122
|
+
if (Date.now() >= deadline) {
|
|
123
|
+
throw new Error(`Conversation ${log.sessionId} is busy: another writer holds it — ${op} rejected. Wait for its turn to finish and try again.`);
|
|
124
|
+
}
|
|
125
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, LEASE_POLL_MS));
|
|
92
126
|
}
|
|
93
|
-
|
|
94
|
-
|
|
127
|
+
}
|
|
128
|
+
/** Append records outside any turn, under a lease taken for just these records. */
|
|
129
|
+
async function appendOutsideTurn(s, sessionId, drafts, op) {
|
|
130
|
+
const log = openConversationLog(s, sessionId);
|
|
131
|
+
const lease = await claimLease(log, op);
|
|
132
|
+
try {
|
|
133
|
+
for (const draft of drafts)
|
|
134
|
+
await log.append(lease, draft);
|
|
95
135
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (!isEntityId(value, 'session')) {
|
|
99
|
-
throw new Error(`Cannot read ${path}; desktop session ${JSON.stringify(key)} does not name a Session id. Refusing to replace the existing map.`);
|
|
100
|
-
}
|
|
101
|
-
map[key] = value;
|
|
136
|
+
finally {
|
|
137
|
+
await log.release(lease);
|
|
102
138
|
}
|
|
103
|
-
|
|
139
|
+
await refreshIndex(s, sessionId);
|
|
104
140
|
}
|
|
105
|
-
|
|
106
|
-
|
|
141
|
+
/** Bring the index's row for one conversation up to date with its log. */
|
|
142
|
+
export async function refreshIndex(s, sessionId) {
|
|
143
|
+
await s.index.refresh({ slug: s.slug, logPath: conversationLogPath(s, sessionId), sessionId });
|
|
107
144
|
}
|
|
108
145
|
/**
|
|
109
|
-
*
|
|
146
|
+
* Read one conversation's log, or `null` when there is none.
|
|
110
147
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* owner deliberately leaves a lock behind: guessing that it is stale and
|
|
114
|
-
* deleting it automatically would reintroduce the race this lease prevents.
|
|
148
|
+
* Strict by default: a log whose chain is broken is not something to resume
|
|
149
|
+
* or fork. A list passes `tolerant` and reads what precedes the break.
|
|
115
150
|
*/
|
|
116
|
-
async function
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return () => {
|
|
133
|
-
try {
|
|
134
|
-
if (readFileSync(path, 'utf8').trim() === token)
|
|
135
|
-
rmSync(path, { force: true });
|
|
151
|
+
export async function readConversationFacts(s, sessionId, mode = 'strict') {
|
|
152
|
+
const read = await readSessionLog(conversationLogPath(s, sessionId), { mode, sessionId });
|
|
153
|
+
const records = read.entries.map((entry) => entry.record);
|
|
154
|
+
const first = records[0];
|
|
155
|
+
if (first === undefined || first.type !== 'session_started')
|
|
156
|
+
return null;
|
|
157
|
+
let title;
|
|
158
|
+
let named = false;
|
|
159
|
+
let archived = false;
|
|
160
|
+
let active;
|
|
161
|
+
for (const record of records) {
|
|
162
|
+
switch (record.type) {
|
|
163
|
+
case 'session_updated':
|
|
164
|
+
if (record.title !== undefined) {
|
|
165
|
+
title = record.title;
|
|
166
|
+
named = record.titleSource === 'named' && record.title.length > 0;
|
|
136
167
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
throw error;
|
|
158
|
-
}
|
|
159
|
-
if (Date.now() >= deadline) {
|
|
160
|
-
throw new Error(`Timed out waiting for ${path}. Another Namzu process may be updating desktop session bindings. If no Namzu process is running, inspect and remove this stale lock manually.`);
|
|
161
|
-
}
|
|
162
|
-
await wait(DESKTOP_MAP_LOCK_POLL_MS);
|
|
168
|
+
if (record.archived !== undefined)
|
|
169
|
+
archived = record.archived;
|
|
170
|
+
break;
|
|
171
|
+
case 'turn_started':
|
|
172
|
+
active = { turnId: record.turnId, paused: false };
|
|
173
|
+
break;
|
|
174
|
+
case 'turn_paused':
|
|
175
|
+
if (active?.turnId === record.turnId)
|
|
176
|
+
active = { ...active, paused: true };
|
|
177
|
+
break;
|
|
178
|
+
case 'turn_resuming':
|
|
179
|
+
if (active?.turnId === record.turnId)
|
|
180
|
+
active = { ...active, paused: false };
|
|
181
|
+
break;
|
|
182
|
+
case 'turn_completed':
|
|
183
|
+
case 'turn_failed':
|
|
184
|
+
if (active?.turnId === record.turnId)
|
|
185
|
+
active = undefined;
|
|
186
|
+
break;
|
|
163
187
|
}
|
|
164
188
|
}
|
|
189
|
+
return {
|
|
190
|
+
sessionId,
|
|
191
|
+
started: first,
|
|
192
|
+
...(title !== undefined ? { title } : {}),
|
|
193
|
+
named,
|
|
194
|
+
archived,
|
|
195
|
+
createdAt: first.ts,
|
|
196
|
+
updatedAt: records.at(-1)?.ts ?? first.ts,
|
|
197
|
+
...(active ? { activeTurn: active } : {}),
|
|
198
|
+
records,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/** The folded conversation a log's records describe, spills read back. */
|
|
202
|
+
async function foldConversation(s, sessionId, records) {
|
|
203
|
+
const log = openConversationLog(s, sessionId);
|
|
204
|
+
return await foldSessionMessages(records, { readSpill: (ref) => log.readSpill(ref) });
|
|
205
|
+
}
|
|
206
|
+
async function readSessionEntity(s, sessionId, tenantId) {
|
|
207
|
+
if (!isEntityId(sessionId, 'session'))
|
|
208
|
+
return null;
|
|
209
|
+
const facts = await readConversationFacts(s, sessionId, 'tolerant');
|
|
210
|
+
if (!facts)
|
|
211
|
+
return null;
|
|
212
|
+
const recordedTenant = facts.started.tenantId;
|
|
213
|
+
if (recordedTenant !== undefined && recordedTenant !== tenantId)
|
|
214
|
+
return null;
|
|
215
|
+
return {
|
|
216
|
+
id: sessionId,
|
|
217
|
+
topicId: facts.started.topicId ?? topicIdFor(facts.started.projectId),
|
|
218
|
+
projectId: facts.started.projectId,
|
|
219
|
+
tenantId,
|
|
220
|
+
status: facts.archived ? 'archived' : 'idle',
|
|
221
|
+
currentActor: null,
|
|
222
|
+
previousActors: [],
|
|
223
|
+
workspaceId: null,
|
|
224
|
+
ownerVersion: 0,
|
|
225
|
+
createdAt: new Date(facts.createdAt),
|
|
226
|
+
updatedAt: new Date(facts.updatedAt),
|
|
227
|
+
};
|
|
165
228
|
}
|
|
166
229
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* the
|
|
230
|
+
* Start a fresh conversation; returns its session id.
|
|
231
|
+
*
|
|
232
|
+
* Writes the log's first record, `session_started`, naming the project, the
|
|
233
|
+
* tenant and the CLI topic. An id that already has a log is refused: two
|
|
234
|
+
* conversations never share one.
|
|
171
235
|
*/
|
|
172
|
-
export async function
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
const release = await acquireDesktopMapLock(s.controlRoot);
|
|
236
|
+
export async function startConversation(s, idOrOptions) {
|
|
237
|
+
const options = typeof idOrOptions === 'string' ? { id: idOrOptions } : (idOrOptions ?? {});
|
|
238
|
+
const id = options.id ?? generateSessionId();
|
|
239
|
+
const log = openConversationLog(s, id);
|
|
240
|
+
const lease = await claimLease(log, 'start conversation');
|
|
179
241
|
try {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const winner = await resolveExistingConversation(s, key, map);
|
|
183
|
-
if (winner) {
|
|
184
|
-
await requireWritableConversation(s, winner, 'continue keyed conversation');
|
|
185
|
-
return winner;
|
|
242
|
+
if ((await log.head()) !== null) {
|
|
243
|
+
throw new Error(`Conversation ${id} already exists — start conversation rejected.`);
|
|
186
244
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
245
|
+
await log.append(lease, {
|
|
246
|
+
type: 'session_started',
|
|
247
|
+
projectId: s.projectId,
|
|
248
|
+
tenantId: s.tenantId,
|
|
249
|
+
topicId: s.topicId,
|
|
250
|
+
cwd: s.projectRoot,
|
|
251
|
+
agent: { ...CLI_AGENT },
|
|
252
|
+
origin: options.origin ?? { protocol: 'cli' },
|
|
253
|
+
});
|
|
191
254
|
}
|
|
192
255
|
finally {
|
|
193
|
-
release();
|
|
256
|
+
await log.release(lease);
|
|
194
257
|
}
|
|
258
|
+
await refreshIndex(s, id);
|
|
259
|
+
return id;
|
|
195
260
|
}
|
|
196
|
-
/**
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (
|
|
208
|
-
return
|
|
261
|
+
/**
|
|
262
|
+
* Give a session log its `session_started` if it has none yet, so a turn can
|
|
263
|
+
* begin in it. A log that already starts is left alone. Used where a session
|
|
264
|
+
* is brought into existence by its first turn rather than by
|
|
265
|
+
* {@link startConversation}: a headless scope, an in-memory log.
|
|
266
|
+
*/
|
|
267
|
+
export async function ensureSessionStarted(log, session) {
|
|
268
|
+
if ((await log.head()) !== null)
|
|
269
|
+
return;
|
|
270
|
+
const lease = await claimLease(log, 'start session');
|
|
271
|
+
try {
|
|
272
|
+
if ((await log.head()) !== null)
|
|
273
|
+
return;
|
|
274
|
+
await log.append(lease, {
|
|
275
|
+
type: 'session_started',
|
|
276
|
+
projectId: session.projectId,
|
|
277
|
+
tenantId: session.tenantId,
|
|
278
|
+
topicId: session.topicId,
|
|
279
|
+
cwd: session.cwd,
|
|
280
|
+
agent: { ...session.agent },
|
|
281
|
+
origin: session.origin ?? { protocol: 'cli' },
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
await log.release(lease);
|
|
209
286
|
}
|
|
210
|
-
|
|
287
|
+
}
|
|
288
|
+
/** The external id a desktop key is filed under: scoped to the project, like the map it replaces. */
|
|
289
|
+
function desktopExternalId(projectId, key) {
|
|
290
|
+
return JSON.stringify([projectId, key]);
|
|
211
291
|
}
|
|
212
292
|
/**
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* workspace status — the SDK's own note says a direct store caller bypasses
|
|
218
|
-
* the invariant, and this was such a caller.
|
|
219
|
-
*
|
|
220
|
-
* `openSessions` reuses the central root-path binding across launches. Its
|
|
221
|
-
* Project may have been closed since it was created, so session creation
|
|
222
|
-
* checks that Project's current status again.
|
|
293
|
+
* Resolve (creating if needed) the conversation bound to an embedder's
|
|
294
|
+
* session key. The binding is the conversation's own `session_started.origin`,
|
|
295
|
+
* so a later turn or history load with the same key reuses it, and an index
|
|
296
|
+
* rebuild finds it again.
|
|
223
297
|
*/
|
|
224
|
-
export async function
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
298
|
+
export async function resolveConversation(s, key) {
|
|
299
|
+
const existing = await findMappedConversation(s, key);
|
|
300
|
+
if (existing) {
|
|
301
|
+
await requireWritableConversation(s, existing, 'continue keyed conversation');
|
|
302
|
+
return existing;
|
|
303
|
+
}
|
|
304
|
+
const id = await startConversation(s, {
|
|
305
|
+
origin: { protocol: 'desktop', externalSessionId: desktopExternalId(s.projectId, key) },
|
|
306
|
+
});
|
|
307
|
+
// Another process may have claimed the key while this one was creating. The
|
|
308
|
+
// earliest claim wins the name; a loser archives its empty conversation so it
|
|
309
|
+
// never shows up in `/resume` as a second, indistinguishable row.
|
|
310
|
+
const winner = await findMappedConversation(s, key);
|
|
311
|
+
if (winner && winner !== id) {
|
|
312
|
+
await archiveConversation(s, id).catch(() => undefined);
|
|
313
|
+
await requireWritableConversation(s, winner, 'continue keyed conversation');
|
|
314
|
+
return winner;
|
|
315
|
+
}
|
|
316
|
+
return id;
|
|
228
317
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
318
|
+
/** Read an external-session binding without creating or widening its scope. */
|
|
319
|
+
export async function findMappedConversation(s, key) {
|
|
320
|
+
const target = await s.index.resolveExternal('desktop', 'session', desktopExternalId(s.projectId, key));
|
|
321
|
+
if (!target)
|
|
322
|
+
return null;
|
|
323
|
+
const facts = await readConversationFacts(s, target.sessionId, 'tolerant');
|
|
324
|
+
return facts?.started.projectId === s.projectId ? target.sessionId : null;
|
|
233
325
|
}
|
|
234
326
|
/**
|
|
235
|
-
* Resolve a conversation through
|
|
327
|
+
* Resolve a conversation through this project.
|
|
236
328
|
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* load-bearing: two Projects in one root can legitimately carry the same
|
|
241
|
-
* topic id.
|
|
329
|
+
* A session id names a file under this project's directory, so a log that
|
|
330
|
+
* exists proves the conversation is this project's; the project id it
|
|
331
|
+
* recorded must also agree, and so must the tenant.
|
|
242
332
|
*/
|
|
243
333
|
async function requireConversationInScope(s, sessionId, op) {
|
|
244
|
-
const
|
|
245
|
-
if (!
|
|
334
|
+
const facts = await readConversationFacts(s, sessionId);
|
|
335
|
+
if (!facts) {
|
|
246
336
|
throw new Error(`Conversation ${sessionId} was not found — ${op} rejected`);
|
|
247
337
|
}
|
|
248
|
-
if (
|
|
338
|
+
if (facts.started.projectId !== s.projectId ||
|
|
339
|
+
(facts.started.tenantId !== undefined && facts.started.tenantId !== s.tenantId)) {
|
|
249
340
|
throw new Error(`Conversation ${sessionId} does not belong to this workspace — ${op} rejected`);
|
|
250
341
|
}
|
|
251
|
-
return
|
|
342
|
+
return facts;
|
|
252
343
|
}
|
|
253
344
|
/**
|
|
254
|
-
* Sequential admission gate for a turn or conversation mutation
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
* deliberately not described as a cross-process transaction: Project archive
|
|
258
|
-
* and Session mutation live in separate store records, so serializing an
|
|
259
|
-
* archive racing a live turn requires a durable lease shared by both paths.
|
|
260
|
-
* What this gate does guarantee is that a target already closed, archived or
|
|
261
|
-
* outside the current Project never silently reaches the requested operation.
|
|
345
|
+
* Sequential admission gate for a turn or conversation mutation: the target
|
|
346
|
+
* exists in this project and is not archived. It is not a cross-process
|
|
347
|
+
* transaction; the session lease is what serialises writers.
|
|
262
348
|
*/
|
|
263
349
|
export async function requireWritableConversation(s, sessionId, op = 'continue conversation') {
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
if (session.status === 'archived') {
|
|
350
|
+
const facts = await requireConversationInScope(s, sessionId, op);
|
|
351
|
+
if (facts.archived) {
|
|
267
352
|
throw new Error(`Conversation ${sessionId} is archived and read-only — ${op} rejected. Its history remains available for inspection.`);
|
|
268
353
|
}
|
|
269
354
|
}
|
|
270
355
|
/**
|
|
271
|
-
* Turn the
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
* versioned publication. History remains readable, while `/resume`, later
|
|
275
|
-
* appends and forks reject through {@link requireWritableConversation}.
|
|
356
|
+
* Turn the conversation into a read-only tombstone with `session_updated
|
|
357
|
+
* {archived: true}`. History remains readable, while `/resume`, later turns
|
|
358
|
+
* and forks reject through {@link requireWritableConversation}.
|
|
276
359
|
*/
|
|
277
360
|
export async function archiveConversation(s, sessionId) {
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
if (session.status === 'archived') {
|
|
361
|
+
const facts = await requireConversationInScope(s, sessionId, 'archive conversation');
|
|
362
|
+
if (facts.archived) {
|
|
281
363
|
throw new Error(`Conversation ${sessionId} is already archived.`);
|
|
282
364
|
}
|
|
283
|
-
await s
|
|
284
|
-
}
|
|
285
|
-
/** Append messages (in order) to a conversation. */
|
|
286
|
-
export async function appendMessages(s, sessionId, messages) {
|
|
287
|
-
await requireWritableConversation(s, sessionId, 'append conversation messages');
|
|
288
|
-
for (const m of messages) {
|
|
289
|
-
await s.store.appendMessage(sessionId, m, s.tenantId);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
/**
|
|
293
|
-
* Replace the durable conversation view with a compacted history.
|
|
294
|
-
*
|
|
295
|
-
* The store writes this as one replacement record inside its append-only log,
|
|
296
|
-
* so a crash cannot expose the first half of a compacted conversation. Before
|
|
297
|
-
* replacing, pin the derived title without calling it a chosen name: the
|
|
298
|
-
* opening user message may be among the turns compacted away, and `/resume`
|
|
299
|
-
* must neither rename nor quote the conversation as a side effect of making
|
|
300
|
-
* it smaller.
|
|
301
|
-
*/
|
|
302
|
-
export async function replaceConversation(s, sessionId, messages) {
|
|
303
|
-
await requireWritableConversation(s, sessionId, 'replace conversation history');
|
|
304
|
-
const existing = await loadConversation(s, sessionId);
|
|
305
|
-
const titles = readTitles(s.controlRoot);
|
|
306
|
-
if ((titles[sessionId] === undefined ||
|
|
307
|
-
(!titles[sessionId]?.named &&
|
|
308
|
-
titles[sessionId]?.title === 'Conversation')) &&
|
|
309
|
-
conversationTitle(existing) !== 'Conversation') {
|
|
310
|
-
titles[sessionId] = {
|
|
311
|
-
title: conversationTitle(existing),
|
|
312
|
-
named: false,
|
|
313
|
-
};
|
|
314
|
-
writeTitles(s.controlRoot, titles);
|
|
315
|
-
}
|
|
316
|
-
await s.store.replaceMessages(sessionId, messages, s.tenantId);
|
|
365
|
+
await appendOutsideTurn(s, sessionId, [{ type: 'session_updated', archived: true }], 'archive conversation');
|
|
317
366
|
}
|
|
318
|
-
/** Load a conversation's
|
|
367
|
+
/** Load a conversation's folded message history. */
|
|
319
368
|
export async function loadConversation(s, sessionId) {
|
|
320
|
-
await requireConversationInScope(s, sessionId, 'load conversation history');
|
|
321
|
-
return
|
|
369
|
+
const facts = await requireConversationInScope(s, sessionId, 'load conversation history');
|
|
370
|
+
return await foldConversation(s, sessionId, facts.records);
|
|
322
371
|
}
|
|
323
372
|
/**
|
|
324
373
|
* Load one conversation for a new model turn.
|
|
325
374
|
*
|
|
326
375
|
* Reading an archived conversation remains legitimate — `history` and export
|
|
327
376
|
* are inspection surfaces — but resuming it would turn a tombstone back into a
|
|
328
|
-
* live writer without a restore operation.
|
|
329
|
-
* the same reason: closing a workspace must not make its history disappear,
|
|
330
|
-
* while it must stop a later turn from starting there.
|
|
377
|
+
* live writer without a restore operation.
|
|
331
378
|
*/
|
|
332
379
|
export async function loadResumableConversation(s, sessionId) {
|
|
333
380
|
const checked = asSessionId(sessionId);
|
|
334
381
|
await requireWritableConversation(s, checked, 'resume conversation');
|
|
335
|
-
return
|
|
382
|
+
return await loadConversation(s, checked);
|
|
383
|
+
}
|
|
384
|
+
/** The open turn of a conversation, if it has one: `paused` when it is parked. */
|
|
385
|
+
export async function activeConversationTurn(s, sessionId) {
|
|
386
|
+
return (await requireConversationInScope(s, sessionId, 'inspect conversation')).activeTurn;
|
|
336
387
|
}
|
|
337
388
|
/** Recent non-empty conversations, newest first — for the `/resume` list. */
|
|
338
389
|
export async function listRecent(s, limit = 20) {
|
|
339
|
-
await
|
|
340
|
-
const sessions = s.store.listSessionsByProject
|
|
341
|
-
? await s.store.listSessionsByProject(s.projectId, s.tenantId)
|
|
342
|
-
: await s.store.listSessionsByTopic(s.topicId, s.tenantId);
|
|
343
|
-
const titles = readTitles(s.controlRoot);
|
|
390
|
+
const rows = await s.index.listSessions({ slug: s.slug, rootsOnly: true, includeArchived: false });
|
|
344
391
|
const out = [];
|
|
345
|
-
for (const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
392
|
+
for (const row of rows) {
|
|
393
|
+
if (row.projectId !== s.projectId || row.archived)
|
|
394
|
+
continue;
|
|
395
|
+
let facts;
|
|
396
|
+
let messages;
|
|
397
|
+
try {
|
|
398
|
+
facts = await readConversationFacts(s, row.id, 'tolerant');
|
|
399
|
+
if (!facts || facts.archived || facts.started.projectId !== s.projectId)
|
|
400
|
+
continue;
|
|
401
|
+
messages = await foldConversation(s, row.id, facts.records);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
// A log this process cannot read is not a row a person can resume.
|
|
351
405
|
continue;
|
|
352
|
-
|
|
406
|
+
}
|
|
353
407
|
if (messages.length === 0)
|
|
354
408
|
continue;
|
|
355
|
-
const
|
|
409
|
+
const everything = recordedMessages(facts.records);
|
|
356
410
|
out.push({
|
|
357
|
-
id:
|
|
358
|
-
title:
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
411
|
+
id: row.id,
|
|
412
|
+
title: facts.title !== undefined &&
|
|
413
|
+
facts.title.length > 0 &&
|
|
414
|
+
(facts.named || facts.title !== 'Conversation')
|
|
415
|
+
? facts.title
|
|
416
|
+
: conversationTitle(everything),
|
|
417
|
+
preview: [...everything]
|
|
362
418
|
.reverse()
|
|
363
419
|
.find((message) => message.role === 'user' && message.source === undefined)
|
|
364
420
|
?.content.replace(/\s+/g, ' ')
|
|
365
421
|
.trim()
|
|
366
422
|
.slice(0, 240),
|
|
367
|
-
named:
|
|
368
|
-
updatedAt:
|
|
423
|
+
named: facts.named,
|
|
424
|
+
updatedAt: facts.updatedAt,
|
|
369
425
|
count: messages.length,
|
|
370
426
|
});
|
|
371
427
|
}
|
|
372
428
|
return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit);
|
|
373
429
|
}
|
|
374
|
-
/**
|
|
375
|
-
* Conversation titles that must survive their opening message, in a file
|
|
376
|
-
* beside the sessions. `named` distinguishes a person's choice from a derived
|
|
377
|
-
* title pinned before compaction removes the message it came from.
|
|
378
|
-
*
|
|
379
|
-
* A sidecar rather than a field on the SDK's `Session`. Naming a conversation
|
|
380
|
-
* is an operator-application concern: the kernel has no view that lists them
|
|
381
|
-
* and nothing in it would read the name. Putting it in the entity would widen
|
|
382
|
-
* a store interface every host implements, to carry a string only this package
|
|
383
|
-
* writes and only this package displays.
|
|
384
|
-
*
|
|
385
|
-
* The cost is that the two can disagree — a session deleted outside this
|
|
386
|
-
* process leaves its name behind. That is why nothing here treats the file as
|
|
387
|
-
* a list of sessions: it is consulted BY id, from a list the store produced,
|
|
388
|
-
* so a stale entry is never reachable and never has to be reconciled.
|
|
389
|
-
*/
|
|
390
|
-
const TITLES_FILE = 'titles.json';
|
|
391
|
-
function titlesPath(root) {
|
|
392
|
-
return join(root, TITLES_FILE);
|
|
393
|
-
}
|
|
394
|
-
function readTitles(root) {
|
|
395
|
-
try {
|
|
396
|
-
const parsed = JSON.parse(readFileSync(titlesPath(root), 'utf-8'));
|
|
397
|
-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
398
|
-
return {};
|
|
399
|
-
// Filtered rather than trusted. This file is on disk where a person can
|
|
400
|
-
// edit it, and a malformed value reaching the renderer as a title is a
|
|
401
|
-
// crash in a list nobody could then get out of. Strings are the v1 shape:
|
|
402
|
-
// every one was written by `/title`, so each remains a chosen name.
|
|
403
|
-
const titles = {};
|
|
404
|
-
for (const [id, value] of Object.entries(parsed)) {
|
|
405
|
-
if (typeof value === 'string') {
|
|
406
|
-
titles[id] = { title: value, named: true };
|
|
407
|
-
continue;
|
|
408
|
-
}
|
|
409
|
-
if (typeof value !== 'object' || value === null)
|
|
410
|
-
continue;
|
|
411
|
-
const candidate = value;
|
|
412
|
-
if (typeof candidate.title === 'string' && typeof candidate.named === 'boolean') {
|
|
413
|
-
titles[id] = { title: candidate.title, named: candidate.named };
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
return titles;
|
|
417
|
-
}
|
|
418
|
-
catch {
|
|
419
|
-
// Absent, unreadable, or not JSON. A conversation with no chosen name
|
|
420
|
-
// still has a derived one, so the honest fallback is "nobody named
|
|
421
|
-
// anything" rather than a failure the operator cannot act on.
|
|
422
|
-
return {};
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
function writeTitles(root, titles) {
|
|
426
|
-
writePrivateJson(root, TITLES_FILE, titles);
|
|
427
|
-
}
|
|
428
|
-
/** Crash-safe publication for the small CLI sidecars scoped to one Project. */
|
|
429
|
-
function writePrivateJson(root, filename, value) {
|
|
430
|
-
mkdirSync(root, { recursive: true });
|
|
431
|
-
const path = join(root, filename);
|
|
432
|
-
const temporary = `${path}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
|
|
433
|
-
try {
|
|
434
|
-
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, {
|
|
435
|
-
encoding: 'utf-8',
|
|
436
|
-
flag: 'wx',
|
|
437
|
-
mode: 0o600,
|
|
438
|
-
});
|
|
439
|
-
if (process.platform !== 'win32')
|
|
440
|
-
chmodSync(temporary, 0o600);
|
|
441
|
-
restrictToOwner(temporary);
|
|
442
|
-
renameSync(temporary, path);
|
|
443
|
-
}
|
|
444
|
-
finally {
|
|
445
|
-
rmSync(temporary, { force: true });
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
430
|
/** The name a person gave this conversation, or `undefined`. */
|
|
449
|
-
export function titleOf(s, sessionId) {
|
|
450
|
-
const
|
|
451
|
-
return
|
|
431
|
+
export async function titleOf(s, sessionId) {
|
|
432
|
+
const facts = await readConversationFacts(s, sessionId, 'tolerant');
|
|
433
|
+
return facts?.named ? facts.title : undefined;
|
|
452
434
|
}
|
|
453
435
|
/**
|
|
454
436
|
* Name a conversation, or with an empty name, take the name away.
|
|
455
437
|
*
|
|
456
|
-
*
|
|
457
|
-
* an empty
|
|
458
|
-
*
|
|
438
|
+
* Recorded as `session_updated{title, titleSource: 'named'}`; clearing records
|
|
439
|
+
* an empty derived title, so the list derives one from the first message
|
|
440
|
+
* again rather than showing a blank row.
|
|
459
441
|
*/
|
|
460
|
-
export function setTitle(s, sessionId, title) {
|
|
461
|
-
|
|
442
|
+
export async function setTitle(s, sessionId, title) {
|
|
443
|
+
await requireConversationInScope(s, sessionId, 'name conversation');
|
|
462
444
|
const trimmed = title.trim();
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
445
|
+
await appendOutsideTurn(s, sessionId, [
|
|
446
|
+
trimmed === ''
|
|
447
|
+
? { type: 'session_updated', title: '', titleSource: 'derived' }
|
|
448
|
+
: { type: 'session_updated', title: trimmed, titleSource: 'named' },
|
|
449
|
+
], 'name conversation');
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Seed a conversation that has no turns yet with a history, as one
|
|
453
|
+
* `compaction` record outside any turn: the fold starts from its summary, so
|
|
454
|
+
* the new conversation's context is exactly `messages`. Used by forks.
|
|
455
|
+
*/
|
|
456
|
+
export async function seedConversationHistory(s, sessionId, messages, strategy = 'fork') {
|
|
457
|
+
if (messages.length === 0)
|
|
458
|
+
return;
|
|
459
|
+
await appendOutsideTurn(s, sessionId, [
|
|
460
|
+
{
|
|
461
|
+
type: 'compaction',
|
|
462
|
+
compactionId: randomUUID(),
|
|
463
|
+
strategy,
|
|
464
|
+
trigger: 'manual',
|
|
465
|
+
replacesSeqRange: [1, 1],
|
|
466
|
+
summary: [...messages],
|
|
467
|
+
keptMessageIds: [],
|
|
468
|
+
tokensBefore: 0,
|
|
469
|
+
tokensAfter: 0,
|
|
470
|
+
},
|
|
471
|
+
], 'seed conversation history');
|
|
468
472
|
}
|
|
469
473
|
/**
|
|
470
474
|
* Continue in a copy, leaving the original where it is.
|
|
@@ -475,11 +479,9 @@ export function setTitle(s, sessionId, title) {
|
|
|
475
479
|
*
|
|
476
480
|
* **The fork is always named, and that is the load-bearing part.** Both
|
|
477
481
|
* conversations start with the same first message, so both DERIVE the same
|
|
478
|
-
* title — and `/resume` would show two rows a person cannot tell apart
|
|
479
|
-
* is
|
|
480
|
-
*
|
|
481
|
-
* numbered against the names already in use so a second fork does not collide
|
|
482
|
-
* with the first.
|
|
482
|
+
* title — and `/resume` would show two rows a person cannot tell apart. The
|
|
483
|
+
* name is taken from the source's own, so a fork of a fork stays readable, and
|
|
484
|
+
* it is numbered against the names already in use.
|
|
483
485
|
*/
|
|
484
486
|
export async function forkConversation(s, sourceId) {
|
|
485
487
|
await requireWritableConversation(s, sourceId, 'fork conversation');
|
|
@@ -489,9 +491,7 @@ export async function forkConversation(s, sourceId) {
|
|
|
489
491
|
// that shows up in `/resume` forever and answers no question.
|
|
490
492
|
throw new Error('There is nothing to fork yet — this conversation has no messages.');
|
|
491
493
|
}
|
|
492
|
-
const { id, title } = await writeFork(s, sourceId, messages, messages
|
|
493
|
-
kind: 'all',
|
|
494
|
-
});
|
|
494
|
+
const { id, title } = await writeFork(s, sourceId, messages, messages);
|
|
495
495
|
return { id, title, copied: messages.length };
|
|
496
496
|
}
|
|
497
497
|
/**
|
|
@@ -504,8 +504,7 @@ export async function forkConversation(s, sourceId) {
|
|
|
504
504
|
* a boundary the operator did not select.
|
|
505
505
|
*
|
|
506
506
|
* An empty prefix is valid. Editing the first prompt creates an empty branch
|
|
507
|
-
* and restores that prompt to the composer
|
|
508
|
-
* common first-turn correction the one prompt this feature cannot edit.
|
|
507
|
+
* and restores that prompt to the composer.
|
|
509
508
|
*/
|
|
510
509
|
export async function forkConversationBeforeUser(s, sourceId, userOrdinal, expected) {
|
|
511
510
|
if (!Number.isInteger(userOrdinal) || userOrdinal < 0) {
|
|
@@ -528,129 +527,53 @@ export async function forkConversationBeforeUser(s, sourceId, userOrdinal, expec
|
|
|
528
527
|
throw new Error('The conversation changed after the prompt was selected. Nothing was forked; open the editor again from the current history.');
|
|
529
528
|
}
|
|
530
529
|
const prefix = messages.slice(0, messageIndex);
|
|
531
|
-
const { id, title } = await writeFork(s, sourceId, messages, prefix
|
|
532
|
-
kind: 'before-user',
|
|
533
|
-
userOrdinal,
|
|
534
|
-
});
|
|
530
|
+
const { id, title } = await writeFork(s, sourceId, messages, prefix);
|
|
535
531
|
return { id, title, messages: prefix, selected };
|
|
536
532
|
}
|
|
537
|
-
/** Create and name one fork after every boundary decision has been validated. */
|
|
538
|
-
async function writeFork(s, sourceId, sourceMessages, copiedMessages
|
|
539
|
-
const
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
await s.store.replaceMessages(id, copiedMessages, s.tenantId);
|
|
533
|
+
/** Create, seed and name one fork after every boundary decision has been validated. */
|
|
534
|
+
async function writeFork(s, sourceId, sourceMessages, copiedMessages) {
|
|
535
|
+
const sourceFacts = await readConversationFacts(s, sourceId, 'tolerant');
|
|
536
|
+
const source = sourceFacts?.title !== undefined && sourceFacts.title.length > 0
|
|
537
|
+
? sourceFacts.title
|
|
538
|
+
: conversationTitle(sourceFacts ? recordedMessages(sourceFacts.records) : sourceMessages);
|
|
539
|
+
const id = await startConversation(s);
|
|
540
|
+
await seedConversationHistory(s, id, copiedMessages);
|
|
546
541
|
const copiedBack = await loadConversation(s, id);
|
|
547
|
-
if (!isDeepStrictEqual(copiedBack, copiedMessages)) {
|
|
548
|
-
throw new Error(`The forked conversation did not preserve its exact copied history
|
|
542
|
+
if (!isDeepStrictEqual(copiedBack, [...copiedMessages])) {
|
|
543
|
+
throw new Error(`The forked conversation did not preserve its exact copied history (${id}).`);
|
|
549
544
|
}
|
|
550
|
-
await s
|
|
551
|
-
|
|
552
|
-
setTitle(s, id, title);
|
|
545
|
+
const title = nextForkName(await takenTitles(s), source);
|
|
546
|
+
await setTitle(s, id, title);
|
|
553
547
|
return { id, title };
|
|
554
548
|
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
if (!s.turnEvidence)
|
|
562
|
-
return unresolved;
|
|
563
|
-
try {
|
|
564
|
-
const lineage = await s.turnEvidence.resolveLineage(sourceId);
|
|
565
|
-
if (lineage.kind !== 'available')
|
|
566
|
-
return unresolved;
|
|
567
|
-
const durableTurns = durableTurnProjections(sourceMessages);
|
|
568
|
-
if (!durableTurns || durableTurns.length === 0)
|
|
569
|
-
return unresolved;
|
|
570
|
-
const selected = boundary.kind === 'all'
|
|
571
|
-
? uniqueLineageIndex(durableTurns, lineage.turns, durableTurns.length - 1)
|
|
572
|
-
: uniqueLineageIndex(durableTurns, lineage.turns, boundary.userOrdinal);
|
|
573
|
-
if (selected === undefined)
|
|
574
|
-
return unresolved;
|
|
575
|
-
const copiedTurnCount = boundary.kind === 'all' ? selected + 1 : selected;
|
|
576
|
-
return {
|
|
577
|
-
kind: 'fork',
|
|
578
|
-
sourceSessionId: sourceId,
|
|
579
|
-
copiedMessages,
|
|
580
|
-
turns: lineage.turns
|
|
581
|
-
.slice(0, copiedTurnCount)
|
|
582
|
-
.map((turn) => ({ ...turn.reference })),
|
|
583
|
-
};
|
|
584
|
-
}
|
|
585
|
-
catch {
|
|
586
|
-
// Forking model history remains useful when old/foreign evidence cannot
|
|
587
|
-
// prove lineage. The origin says so explicitly, and complete export refuses.
|
|
588
|
-
return unresolved;
|
|
549
|
+
/** Every title in use among this project's conversations, keyed by session id. */
|
|
550
|
+
async function takenTitles(s) {
|
|
551
|
+
const taken = {};
|
|
552
|
+
for (const row of await s.index.listSessions({ slug: s.slug, rootsOnly: true })) {
|
|
553
|
+
if (row.title !== undefined && row.title.length > 0)
|
|
554
|
+
taken[row.id] = row.title;
|
|
589
555
|
}
|
|
556
|
+
return taken;
|
|
590
557
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
}
|
|
608
|
-
/** Unique source-turn boundary for one user selected from a possibly compacted suffix. */
|
|
609
|
-
function uniqueLineageIndex(durable, lineage, selectedUser) {
|
|
610
|
-
if (selectedUser < 0 || selectedUser >= durable.length)
|
|
611
|
-
return undefined;
|
|
612
|
-
const prefix = alignmentTable(durable, lineage);
|
|
613
|
-
const reversedDurable = [...durable].reverse();
|
|
614
|
-
const reversedLineage = [...lineage].reverse();
|
|
615
|
-
const suffix = alignmentTable(reversedDurable, reversedLineage);
|
|
616
|
-
const candidates = [];
|
|
617
|
-
for (let index = 0; index < lineage.length; index += 1) {
|
|
618
|
-
if (!projectionMatches(durable[selectedUser], lineage[index]))
|
|
619
|
-
continue;
|
|
620
|
-
const beforeFits = prefix[selectedUser]?.[index] === true;
|
|
621
|
-
const durableAfter = durable.length - selectedUser - 1;
|
|
622
|
-
const lineageAfter = lineage.length - index - 1;
|
|
623
|
-
const afterFits = suffix[durableAfter]?.[lineageAfter] === true;
|
|
624
|
-
if (beforeFits && afterFits)
|
|
625
|
-
candidates.push(index);
|
|
626
|
-
}
|
|
627
|
-
return candidates.length === 1 ? candidates[0] : undefined;
|
|
628
|
-
}
|
|
629
|
-
/** DP table: whether the first i needles embed, in order, within the first j haystack values. */
|
|
630
|
-
function alignmentTable(needles, haystack) {
|
|
631
|
-
const table = Array.from({ length: needles.length + 1 }, () => Array.from({ length: haystack.length + 1 }, () => false));
|
|
632
|
-
for (let column = 0; column <= haystack.length; column += 1)
|
|
633
|
-
table[0][column] = true;
|
|
634
|
-
for (let row = 1; row <= needles.length; row += 1) {
|
|
635
|
-
for (let column = 1; column <= haystack.length; column += 1) {
|
|
636
|
-
table[row][column] =
|
|
637
|
-
table[row]?.[column - 1] === true ||
|
|
638
|
-
(table[row - 1]?.[column - 1] === true &&
|
|
639
|
-
projectionMatches(needles[row - 1], haystack[column - 1]));
|
|
558
|
+
/**
|
|
559
|
+
* Every message the log ever recorded, in order: a fork's seeded history and
|
|
560
|
+
* each `message` record, before any compaction or replacement folds them.
|
|
561
|
+
*
|
|
562
|
+
* A derived title and the list preview are read from this rather than from
|
|
563
|
+
* the fold, so compacting a conversation away from its opening question does
|
|
564
|
+
* not rename it: the opening message is still in the log.
|
|
565
|
+
*/
|
|
566
|
+
function recordedMessages(records) {
|
|
567
|
+
const out = [];
|
|
568
|
+
for (const record of records) {
|
|
569
|
+
if (record.type === 'message')
|
|
570
|
+
out.push(record.content);
|
|
571
|
+
else if (record.type === 'compaction' && Array.isArray(record.summary)) {
|
|
572
|
+
if (record.strategy === 'fork')
|
|
573
|
+
out.push(...record.summary);
|
|
640
574
|
}
|
|
641
575
|
}
|
|
642
|
-
return
|
|
643
|
-
}
|
|
644
|
-
function projectionMatches(durable, lineage) {
|
|
645
|
-
if (!durable || !lineage || !isDeepStrictEqual(durable.user, lineage.evidence.started.user)) {
|
|
646
|
-
return false;
|
|
647
|
-
}
|
|
648
|
-
const settled = lineage.evidence.settled;
|
|
649
|
-
if (!settled)
|
|
650
|
-
return false;
|
|
651
|
-
return durable.assistantText === undefined
|
|
652
|
-
? settled.assistantText.trim().length === 0
|
|
653
|
-
: settled.assistantText === durable.assistantText;
|
|
576
|
+
return out;
|
|
654
577
|
}
|
|
655
578
|
/**
|
|
656
579
|
* `X (fork)`, then `X (fork 2)`, `X (fork 3)`.
|
|
@@ -673,7 +596,7 @@ export function nextForkName(taken, source) {
|
|
|
673
596
|
// answer, and a name that repeats is better than a refusal here.
|
|
674
597
|
return `${source} (fork)`;
|
|
675
598
|
}
|
|
676
|
-
function conversationTitle(messages) {
|
|
599
|
+
export function conversationTitle(messages) {
|
|
677
600
|
const firstHuman = messages.find((message) => message.role === 'user' && message.source === undefined);
|
|
678
601
|
const firstGoal = messages.find((message) => message.role === 'user' && message.source?.type === 'goal-round');
|
|
679
602
|
const raw = firstHuman?.role === 'user'
|
|
@@ -684,9 +607,16 @@ function conversationTitle(messages) {
|
|
|
684
607
|
const text = raw.replace(/\s+/g, ' ').trim();
|
|
685
608
|
return text.length > 60 ? `${text.slice(0, 59)}…` : text || 'Conversation';
|
|
686
609
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
610
|
+
/** The project directory name, for messages that point a person at it. */
|
|
611
|
+
export function projectLabel(s) {
|
|
612
|
+
return basename(s.projectRoot) || s.projectRoot;
|
|
613
|
+
}
|
|
614
|
+
/** Where a conversation's per-session files live (`<session-id>/`). */
|
|
615
|
+
export function conversationDir(s, sessionId) {
|
|
616
|
+
return s.paths.sessionDir({ sessionId });
|
|
617
|
+
}
|
|
618
|
+
/** The project's directory under `NAMZU_HOME` (`projects/<slug>/`). */
|
|
619
|
+
export function projectDir(s) {
|
|
620
|
+
return join(s.root, 'projects', s.slug);
|
|
691
621
|
}
|
|
692
622
|
//# sourceMappingURL=store.js.map
|