projectkeeper 0.0.0-stage → 1.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 +56 -0
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/README.zh-CN.md +285 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/bin/pk.mjs +6 -0
- package/bin/postinstall.mjs +5 -0
- package/bin/strip-types.mjs +28 -0
- package/package.json +68 -4
- package/patches/README.md +111 -0
- package/patches/pi-ai-0.85.0.ts +122 -0
- package/patches/pi-ai-0.87.1.ts +122 -0
- package/scripts/apply-pi-streaming-patch.ts +86 -0
- package/skills/clerk/coverage/SKILL.md +35 -0
- package/skills/clerk/cross-check/SKILL.md +118 -0
- package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
- package/skills/clerk/lane-follow-up/SKILL.md +94 -0
- package/skills/clerk/lane-plan/SKILL.md +107 -0
- package/skills/clerk/lane-slot/SKILL.md +113 -0
- package/skills/clerk/lane-topic/SKILL.md +116 -0
- package/skills/clerk/orientation/SKILL.md +160 -0
- package/skills/clerk/reconcile/SKILL.md +77 -0
- package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
- package/skills/clerk/spot-check/SKILL.md +52 -0
- package/skills/clerk/synthesis/SKILL.md +65 -0
- package/skills/table-follow-up/SKILL.md +42 -0
- package/src/cli.ts +319 -0
- package/src/codemap/README.md +13 -0
- package/src/codemap/engines.ts +102 -0
- package/src/codemap/facts.ts +504 -0
- package/src/context/agent-entry.ts +232 -0
- package/src/context/ask.ts +97 -0
- package/src/context/assemble.ts +1058 -0
- package/src/context/code-source.ts +103 -0
- package/src/context/k-briefs.ts +114 -0
- package/src/context/lineage.ts +100 -0
- package/src/context/object-brief.ts +300 -0
- package/src/context/owner-words.ts +191 -0
- package/src/context/pack-fixture.ts +342 -0
- package/src/context/project-rules.ts +160 -0
- package/src/intake/intake.ts +392 -0
- package/src/intake/material-rules.ts +166 -0
- package/src/intake/removal.ts +316 -0
- package/src/intake/skipped.ts +72 -0
- package/src/keeper/adjustment.ts +565 -0
- package/src/keeper/bounds/boundary.ts +509 -0
- package/src/keeper/bounds/command.ts +1665 -0
- package/src/keeper/bounds/paths.ts +164 -0
- package/src/keeper/bounds/reads.ts +718 -0
- package/src/keeper/bounds/shell-write-guard.ts +521 -0
- package/src/keeper/capabilities.ts +136 -0
- package/src/keeper/clerk-steps.ts +151 -0
- package/src/keeper/clerk-tools.ts +1424 -0
- package/src/keeper/conversation.ts +253 -0
- package/src/keeper/evidence.ts +662 -0
- package/src/keeper/fake-provider.ts +132 -0
- package/src/keeper/held.ts +15 -0
- package/src/keeper/history-tools.ts +163 -0
- package/src/keeper/jobs/answering.ts +53 -0
- package/src/keeper/key-check.ts +105 -0
- package/src/keeper/ledger-tools.ts +295 -0
- package/src/keeper/link-check.ts +185 -0
- package/src/keeper/merge.ts +260 -0
- package/src/keeper/note-origin.ts +56 -0
- package/src/keeper/numbers-check.ts +102 -0
- package/src/keeper/open-in-pi.ts +57 -0
- package/src/keeper/organize/absence.ts +266 -0
- package/src/keeper/organize/carried-on.ts +214 -0
- package/src/keeper/organize/clerk-coverage.ts +293 -0
- package/src/keeper/organize/clerk-prompts.ts +132 -0
- package/src/keeper/organize/clerk.ts +1395 -0
- package/src/keeper/organize/coverage-gate.ts +19 -0
- package/src/keeper/organize/coverage-tools.ts +765 -0
- package/src/keeper/organize/entries.ts +153 -0
- package/src/keeper/organize/entry-gate.ts +289 -0
- package/src/keeper/organize/fill-tools.ts +718 -0
- package/src/keeper/organize/follow-up.ts +280 -0
- package/src/keeper/organize/generation-check.ts +504 -0
- package/src/keeper/organize/graph.ts +230 -0
- package/src/keeper/organize/lane-tools.ts +289 -0
- package/src/keeper/organize/looked-tools.ts +54 -0
- package/src/keeper/organize/materials.ts +243 -0
- package/src/keeper/organize/owner-lines.ts +493 -0
- package/src/keeper/organize/place-tools.ts +216 -0
- package/src/keeper/organize/placement-inference.ts +1045 -0
- package/src/keeper/organize/placement-time.ts +320 -0
- package/src/keeper/organize/placing.ts +821 -0
- package/src/keeper/organize/positioning.ts +33 -0
- package/src/keeper/organize/readiness.ts +47 -0
- package/src/keeper/organize/reading.ts +401 -0
- package/src/keeper/organize/round-blocks.ts +65 -0
- package/src/keeper/organize/round-news.ts +164 -0
- package/src/keeper/organize/round-open.ts +586 -0
- package/src/keeper/organize/schedule-state.ts +39 -0
- package/src/keeper/organize/schedule.ts +137 -0
- package/src/keeper/organize/service.ts +409 -0
- package/src/keeper/organize/skills.ts +116 -0
- package/src/keeper/organize/slots.ts +85 -0
- package/src/keeper/organize/stage-gate.ts +175 -0
- package/src/keeper/organize/stage-tools.ts +471 -0
- package/src/keeper/organize/standing-notes.ts +141 -0
- package/src/keeper/organize/table-follow-up.ts +330 -0
- package/src/keeper/organize/takeover-clerk.ts +832 -0
- package/src/keeper/organize/takeover-state.ts +103 -0
- package/src/keeper/organize/takeover.ts +216 -0
- package/src/keeper/organize/update-pending.ts +535 -0
- package/src/keeper/organize/workbench-placement.ts +92 -0
- package/src/keeper/own-keys.ts +114 -0
- package/src/keeper/owner-text.ts +165 -0
- package/src/keeper/project-folder.ts +334 -0
- package/src/keeper/prompts.ts +101 -0
- package/src/keeper/references.ts +177 -0
- package/src/keeper/relook/compose.ts +120 -0
- package/src/keeper/relook/prompts.ts +36 -0
- package/src/keeper/roles.ts +259 -0
- package/src/keeper/round-tools.ts +227 -0
- package/src/keeper/route.ts +148 -0
- package/src/keeper/rules.ts +97 -0
- package/src/keeper/runtime.ts +1858 -0
- package/src/keeper/scope-tools.ts +194 -0
- package/src/keeper/sendback-text.ts +83 -0
- package/src/keeper/step-timing.ts +207 -0
- package/src/keeper/testing/clerk-script.ts +212 -0
- package/src/keeper/testing/streaming-arguments.ts +57 -0
- package/src/keeper/tools.ts +2340 -0
- package/src/keeper/utterance-tools.ts +64 -0
- package/src/keeper/verbatim.ts +397 -0
- package/src/ledger/adapters.ts +186 -0
- package/src/ledger/arrangements.ts +257 -0
- package/src/ledger/blame.ts +58 -0
- package/src/ledger/code-engine-child.ts +69 -0
- package/src/ledger/code-engine.ts +548 -0
- package/src/ledger/code.ts +607 -0
- package/src/ledger/docs.ts +294 -0
- package/src/ledger/git-read.ts +321 -0
- package/src/ledger/index.ts +1850 -0
- package/src/ledger/item-range.ts +16 -0
- package/src/ledger/lines.ts +328 -0
- package/src/ledger/numbering.ts +269 -0
- package/src/ledger/ranges.ts +74 -0
- package/src/ledger/rebuild-worker.ts +15 -0
- package/src/ledger/rebuild.ts +387 -0
- package/src/ledger/repo-scan.ts +181 -0
- package/src/ledger/schema.ts +450 -0
- package/src/ledger/sessions.ts +134 -0
- package/src/ledger/text-scan.ts +466 -0
- package/src/ledger/time.ts +185 -0
- package/src/ledger/ts-symbols.ts +180 -0
- package/src/ledger/views.ts +377 -0
- package/src/mcp/server.ts +127 -0
- package/src/mcp/tools.ts +187 -0
- package/src/model/ids.ts +23 -0
- package/src/model/k-types.ts +912 -0
- package/src/model/time.ts +85 -0
- package/src/model/types.ts +1382 -0
- package/src/model/views-k.ts +707 -0
- package/src/model/vocab.ts +269 -0
- package/src/process/analysis.ts +79 -0
- package/src/process/breakpoint-candidates.ts +383 -0
- package/src/process/breakpoints.ts +746 -0
- package/src/process/claim-check.ts +295 -0
- package/src/process/delivery-links.ts +52 -0
- package/src/process/facts.ts +487 -0
- package/src/process/generations.ts +138 -0
- package/src/process/index.ts +243 -0
- package/src/process/integration.ts +128 -0
- package/src/process/placement.ts +107 -0
- package/src/process/plan.ts +276 -0
- package/src/process/sendbacks.ts +244 -0
- package/src/process/text.ts +185 -0
- package/src/process/units.ts +543 -0
- package/src/process/unnumbered.ts +431 -0
- package/src/process/work.ts +884 -0
- package/src/scope/candidates.ts +86 -0
- package/src/scope/decisions.ts +229 -0
- package/src/scope/discover.ts +741 -0
- package/src/scope/ignore.ts +126 -0
- package/src/scope/ignored-place.ts +93 -0
- package/src/scope/skip.ts +161 -0
- package/src/scope/toolchain.ts +180 -0
- package/src/scope/worktree.ts +187 -0
- package/src/server/api.ts +549 -0
- package/src/server/app.ts +595 -0
- package/src/server/drafts-api.ts +170 -0
- package/src/server/folders-api.ts +243 -0
- package/src/server/graph-view.ts +639 -0
- package/src/server/http.ts +224 -0
- package/src/server/k-agent-api.ts +56 -0
- package/src/server/k-rounds-d99.ts +91 -0
- package/src/server/k-views.ts +405 -0
- package/src/server/keeper-page.ts +446 -0
- package/src/server/keys-api.ts +239 -0
- package/src/server/project-folder-api.ts +104 -0
- package/src/server/vendor.ts +14 -0
- package/src/server/workbench-content.ts +561 -0
- package/src/sources/anchor.ts +129 -0
- package/src/sources/files.ts +267 -0
- package/src/sources/gitobs.ts +41 -0
- package/src/sources/history.ts +231 -0
- package/src/sources/scan-worker.ts +10 -0
- package/src/sources/sessions/locate.ts +292 -0
- package/src/sources/sessions/read.ts +346 -0
- package/src/sources/sessions/scope.ts +42 -0
- package/src/sources/sessions/utterances.ts +223 -0
- package/src/sources/watch.ts +215 -0
- package/src/store/json-file.ts +50 -0
- package/src/store/paths.ts +39 -0
- package/src/store/project-store.ts +295 -0
- package/src/store/versions.ts +172 -0
- package/src/store/workspace.ts +216 -0
- package/src/table/check.ts +333 -0
- package/src/table/follow-up-tools.ts +30 -0
- package/src/table/follow-up.ts +344 -0
- package/src/table/import.ts +610 -0
- package/src/table/project-home.ts +61 -0
- package/src/table/sessions.ts +168 -0
- package/src/table/takeover.ts +483 -0
- package/src/util/git-check.ts +29 -0
- package/src/util/git.ts +411 -0
- package/src/util/own-ts.ts +15 -0
- package/src/util/paths.ts +225 -0
- package/template/FILLING-IN.md +148 -0
- package/template/examples/papertrail.json +747 -0
- package/template/projectkeeper.schema.json +1001 -0
- package/ui/app.js +1135 -0
- package/ui/failures.js +82 -0
- package/ui/flyout.js +120 -0
- package/ui/folder-chooser.js +264 -0
- package/ui/glossary.js +156 -0
- package/ui/graph-fit.js +157 -0
- package/ui/graph-material.js +61 -0
- package/ui/graph-palette.js +109 -0
- package/ui/graph-tools.js +62 -0
- package/ui/graph.js +1712 -0
- package/ui/index.html +31 -0
- package/ui/k/code-view.js +230 -0
- package/ui/k/draft-view.js +93 -0
- package/ui/k/k-views.css +224 -0
- package/ui/k/round-tree.js +450 -0
- package/ui/k/scope-k.js +159 -0
- package/ui/k-data.js +118 -0
- package/ui/k-fold.js +106 -0
- package/ui/k-process.css +302 -0
- package/ui/k-process.js +934 -0
- package/ui/keeper-activity.js +175 -0
- package/ui/keeper-view.js +801 -0
- package/ui/keeper.css +101 -0
- package/ui/key-order.js +62 -0
- package/ui/list-fold.js +67 -0
- package/ui/markdown.d.ts +21 -0
- package/ui/markdown.js +472 -0
- package/ui/placement.d.ts +92 -0
- package/ui/placement.js +445 -0
- package/ui/popover-place.d.ts +16 -0
- package/ui/popover-place.js +89 -0
- package/ui/popover.js +198 -0
- package/ui/project-folder.js +37 -0
- package/ui/shell-state.js +40 -0
- package/ui/strip-rows.js +171 -0
- package/ui/styles.css +492 -0
- package/ui/theme-pick.js +36 -0
- package/ui/theme.js +74 -0
- package/ui/themes/LICENSE.md +13 -0
- package/ui/themes/README.md +44 -0
- package/ui/themes/TOKENS.md +291 -0
- package/ui/themes/a1/board.css +1557 -0
- package/ui/themes/a1/pk.css +392 -0
- package/ui/themes/b1/board.css +1163 -0
- package/ui/themes/b1/pk.css +218 -0
- package/ui/themes/d2/board.css +518 -0
- package/ui/themes/d2/pk.css +243 -0
- package/ui/themes/guard.css +73 -0
- package/ui/themes/i1/board.css +776 -0
- package/ui/themes/i1/pk.css +223 -0
- package/ui/themes/manifest.json +13 -0
- package/ui/views.js +2796 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read native Claude Code and Codex session logs into segments (Spec §1.2 "一段会话").
|
|
3
|
+
*
|
|
4
|
+
* A segment is a run of messages with no long pause between them; its excerpt is a compact
|
|
5
|
+
* transcript: the owner's messages in full (they are owner statements), the assistant's
|
|
6
|
+
* text trimmed, tool calls as one-line summaries, tool results left out (they echo what the
|
|
7
|
+
* tool read, which is not what the session said). The native log stays where it is; the
|
|
8
|
+
* anchor points back to message positions.
|
|
9
|
+
*
|
|
10
|
+
* Only what the owner typed is shown as the owner (Spec §1.3, §3.3 "原话整理"). A user-role record is also where the
|
|
11
|
+
* host puts tool results, reminders, command wrappers and their output, background-task notices, the summary written
|
|
12
|
+
* when the context was compacted, and — in a subagent's branch — the parent agent's task; Codex puts the project's
|
|
13
|
+
* instruction files and the environment there. None of those is the owner speaking. A prompt the owner typed while the
|
|
14
|
+
* agent was still working is recorded by Claude Code as a queued command, not as a user record, and is the owner's.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import type { Source } from '../../model/types.ts';
|
|
18
|
+
import type { SessionHost } from '../../model/vocab.ts';
|
|
19
|
+
import { extractIds, makeSessionSource } from '../anchor.ts';
|
|
20
|
+
import type { LocatedSession } from './locate.ts';
|
|
21
|
+
|
|
22
|
+
export interface SessionMessage {
|
|
23
|
+
readonly index: number;
|
|
24
|
+
readonly role: 'user' | 'assistant';
|
|
25
|
+
readonly at: string | null;
|
|
26
|
+
readonly text: string;
|
|
27
|
+
readonly tools: readonly string[]; // "Edit D:\x\y.ts", "Bash: npm test"
|
|
28
|
+
readonly files: readonly string[]; // files written or edited
|
|
29
|
+
readonly sidechain: boolean;
|
|
30
|
+
readonly model: string | null;
|
|
31
|
+
/**
|
|
32
|
+
* A user-role message the owner did not type but that is worth keeping in the transcript — the owner interrupting
|
|
33
|
+
* the agent — says so here; the owner's own messages leave it unset. A subagent's branch is `sidechain`.
|
|
34
|
+
*/
|
|
35
|
+
readonly host?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ParsedSession {
|
|
39
|
+
readonly host: SessionHost;
|
|
40
|
+
readonly sessionId: string;
|
|
41
|
+
readonly file: string;
|
|
42
|
+
readonly cwd: string | null;
|
|
43
|
+
readonly messages: readonly SessionMessage[];
|
|
44
|
+
readonly startedAt: string | null;
|
|
45
|
+
readonly endedAt: string | null;
|
|
46
|
+
readonly brokenLines: number;
|
|
47
|
+
readonly bytes: number;
|
|
48
|
+
/** A non-interactive run (`codex exec`, a Claude Code SDK run): its prompts are usually written by a program or an agent. */
|
|
49
|
+
readonly headless?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether a user-role message is the owner's own words: not a subagent's branch, not something the host put there. */
|
|
53
|
+
export function isOwnerMessage(m: SessionMessage): boolean {
|
|
54
|
+
return m.role === 'user' && !m.sidechain && m.host === undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ASSISTANT_TRIM = 900;
|
|
58
|
+
const SEGMENT_GAP_MS = 45 * 60 * 1000;
|
|
59
|
+
const SEGMENT_MAX_CHARS = 12_000;
|
|
60
|
+
const SEGMENT_MAX_MESSAGES = 60;
|
|
61
|
+
|
|
62
|
+
function text(value: unknown): string {
|
|
63
|
+
return typeof value === 'string' ? value : '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function summarizeTool(name: string, input: unknown): { line: string; file: string | null } {
|
|
67
|
+
const i = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>;
|
|
68
|
+
const path = text(i.file_path) || text(i.path) || text(i.notebook_path);
|
|
69
|
+
if (name === 'Bash' || name === 'PowerShell' || name === 'bash' || name === 'powershell' || name === 'shell' || name === 'local_shell_call') {
|
|
70
|
+
const cmd = text(i.command) || text(i.cmd) || (Array.isArray(i.command) ? i.command.join(' ') : '');
|
|
71
|
+
return { line: `${name}: ${cmd.slice(0, 160)}`, file: null };
|
|
72
|
+
}
|
|
73
|
+
if (['Write', 'Edit', 'NotebookEdit', 'write', 'edit', 'apply_patch'].includes(name)) return { line: `${name} ${path}`, file: path || null };
|
|
74
|
+
if (['Read', 'read', 'Glob', 'Grep', 'grep', 'find', 'ls'].includes(name)) return { line: `${name} ${path || text(i.pattern) || text(i.query)}`.trim(), file: null };
|
|
75
|
+
if (name === 'Task' || name === 'Agent') return { line: `${name}: ${text(i.description) || text(i.prompt).slice(0, 120)}`, file: null };
|
|
76
|
+
return { line: `${name}${path ? ' ' + path : ''}`, file: null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isoOf(value: unknown): string | null {
|
|
80
|
+
if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return new Date(value).toISOString();
|
|
81
|
+
if (typeof value === 'number' && Number.isFinite(value)) return new Date(value > 1e12 ? value : value * 1000).toISOString();
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Blocks a host wraps around, or puts instead of, the owner's words. Claude Code: reminders, slash-command wrappers and
|
|
87
|
+
* their output, shell-mode input and output, hook output, background-task notices, IDE context. Codex: the environment,
|
|
88
|
+
* the project's instruction files, plugin and app context, the browser context, image wrappers, aborted-turn notices.
|
|
89
|
+
*/
|
|
90
|
+
const HARNESS_TAGS = [
|
|
91
|
+
'environment_context', 'user_instructions', 'recommended_plugins', 'permissions_instructions', 'apps_instructions', 'collaboration_mode',
|
|
92
|
+
'plugin_instructions', 'available_skills', 'skills', 'skills_instructions', 'system-reminder', 'command-name', 'command-message', 'command-args',
|
|
93
|
+
'local-command-stdout', 'local-command-stderr', 'local-command-caveat', 'bash-input', 'bash-stdout', 'bash-stderr', 'user-prompt-submit-hook',
|
|
94
|
+
'ide_opened_file', 'ide_selection', 'INSTRUCTIONS', 'task-notification', 'ci-monitor-event', 'persisted-output', 'in-app-browser-context',
|
|
95
|
+
'app-context', 'image', 'turn_aborted', 'user_shell_command', 'context_window', 'multi_agent_mode', 'multi_agent_role', 'model_switch',
|
|
96
|
+
'image_resize_notice',
|
|
97
|
+
];
|
|
98
|
+
const HARNESS_RE = new RegExp(`<(${HARNESS_TAGS.join('|')})(?:\\s[^>]*)?>[\\s\\S]*?</\\1>`, 'g');
|
|
99
|
+
/** What a host writes when the context was compacted: a summary of the conversation, never the owner's words. */
|
|
100
|
+
const COMPACTION_RE = /^(This session is being continued from a previous conversation|Another language model started to solve this problem)/;
|
|
101
|
+
/** The marker Claude Code leaves when the owner stops the agent: an act of the owner's, not words. */
|
|
102
|
+
const INTERRUPTION_RE = /^\[Request interrupted by user(?: for tool use)?\]\s*$/gm;
|
|
103
|
+
|
|
104
|
+
/** Remove blocks the host wraps around the owner's words (environment, plugin lists, reminders). */
|
|
105
|
+
function stripHarnessBlocks(t: string): string {
|
|
106
|
+
return t.replace(HARNESS_RE, '').replace(/^# AGENTS\.md instructions(?: for [^\n]*)?\s*$/gm, '').replace(/\n{3,}/g, '\n\n').trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Strip system-reminder / harness wrappers that Claude Code embeds in user messages. */
|
|
110
|
+
function cleanUserText(t: string): string {
|
|
111
|
+
return stripHarnessBlocks(t);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The owner's words in a user-role text, or how the host is to be shown instead: null when nothing of the owner's is
|
|
116
|
+
* left (a compaction summary, a message that was only wrapping), `{ host }` for an interruption marker on its own.
|
|
117
|
+
*/
|
|
118
|
+
function userSpeech(raw: string): { text: string; host?: string } | null {
|
|
119
|
+
const cleaned = cleanUserText(raw);
|
|
120
|
+
if (!cleaned || COMPACTION_RE.test(cleaned)) return null;
|
|
121
|
+
const words = cleaned.replace(INTERRUPTION_RE, '').replace(/\n{3,}/g, '\n\n').trim();
|
|
122
|
+
if (!words) return { text: cleaned, host: 'the owner interrupted the agent' };
|
|
123
|
+
return { text: words };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The text blocks of a Claude Code content value (a string, or blocks of which only the text ones are words). */
|
|
127
|
+
function textOf(content: unknown): { text: string; toolResultOnly: boolean } {
|
|
128
|
+
if (typeof content === 'string') return { text: content, toolResultOnly: false };
|
|
129
|
+
if (!Array.isArray(content)) return { text: '', toolResultOnly: false };
|
|
130
|
+
const parts: string[] = [];
|
|
131
|
+
let toolResult = false;
|
|
132
|
+
for (const block of content as Record<string, unknown>[]) {
|
|
133
|
+
if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text);
|
|
134
|
+
else if (block.type === 'tool_result') toolResult = true;
|
|
135
|
+
}
|
|
136
|
+
return { text: parts.join('\n'), toolResultOnly: toolResult && parts.length === 0 };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function parseClaudeSession(file: string): ParsedSession {
|
|
140
|
+
const raw = readFileSync(file, 'utf8');
|
|
141
|
+
const messages: SessionMessage[] = [];
|
|
142
|
+
let broken = 0;
|
|
143
|
+
let sessionId = file.split(/[\\/]/).pop()!.replace(/\.jsonl$/, '');
|
|
144
|
+
let cwd: string | null = null;
|
|
145
|
+
let entrypoint: string | null = null;
|
|
146
|
+
let index = 0;
|
|
147
|
+
for (const line of raw.split('\n')) {
|
|
148
|
+
if (!line.trim()) continue;
|
|
149
|
+
let rec: Record<string, unknown>;
|
|
150
|
+
try { rec = JSON.parse(line) as Record<string, unknown>; } catch { broken += 1; continue; }
|
|
151
|
+
const type = rec.type;
|
|
152
|
+
if (type !== 'user' && type !== 'assistant' && type !== 'attachment') continue;
|
|
153
|
+
if (typeof rec.sessionId === 'string') sessionId = rec.sessionId;
|
|
154
|
+
if (typeof rec.cwd === 'string' && !cwd) cwd = rec.cwd;
|
|
155
|
+
if (typeof rec.entrypoint === 'string' && !entrypoint) entrypoint = rec.entrypoint;
|
|
156
|
+
const sidechain = rec.isSidechain === true;
|
|
157
|
+
const at = isoOf(rec.timestamp);
|
|
158
|
+
if (type === 'attachment') {
|
|
159
|
+
// A prompt the owner typed while the agent was working reaches the conversation as a queued command.
|
|
160
|
+
const a = rec.attachment as Record<string, unknown> | undefined;
|
|
161
|
+
const origin = a?.origin as { kind?: string } | undefined;
|
|
162
|
+
if (a?.type !== 'queued_command' || a.commandMode !== 'prompt' || (origin && origin.kind !== 'human')) continue;
|
|
163
|
+
const speech = userSpeech(textOf(a.prompt).text);
|
|
164
|
+
if (!speech || speech.host) continue;
|
|
165
|
+
messages.push({ index, role: 'user', at: at ?? isoOf(a.timestamp), text: speech.text, tools: [], files: [], sidechain, model: null });
|
|
166
|
+
index += 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const message = rec.message as Record<string, unknown> | undefined;
|
|
170
|
+
if (!message) continue;
|
|
171
|
+
if (type === 'user') {
|
|
172
|
+
// Reminders and skill text the host injects (isMeta), the summary written at a compaction, and prompts of any
|
|
173
|
+
// origin but the owner's (background-task notices) are not the owner speaking.
|
|
174
|
+
if (rec.isMeta === true || rec.isCompactSummary === true) continue;
|
|
175
|
+
const origin = rec.origin as { kind?: string } | undefined;
|
|
176
|
+
if (origin && typeof origin.kind === 'string' && origin.kind !== 'human') continue;
|
|
177
|
+
const { text: body, toolResultOnly } = textOf(message.content);
|
|
178
|
+
if (toolResultOnly) continue; // tool echoes, not owner speech
|
|
179
|
+
const speech = userSpeech(body);
|
|
180
|
+
if (!speech) continue;
|
|
181
|
+
messages.push({ index, role: 'user', at, text: speech.text, tools: [], files: [], sidechain, model: null, ...(speech.host && !sidechain ? { host: speech.host } : {}) });
|
|
182
|
+
index += 1;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const parts: string[] = [];
|
|
186
|
+
const tools: string[] = [];
|
|
187
|
+
const files: string[] = [];
|
|
188
|
+
if (typeof message.content === 'string') parts.push(message.content);
|
|
189
|
+
else if (Array.isArray(message.content)) {
|
|
190
|
+
for (const block of message.content as Record<string, unknown>[]) {
|
|
191
|
+
if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text);
|
|
192
|
+
else if (block.type === 'tool_use') {
|
|
193
|
+
const s = summarizeTool(text(block.name) || '?', block.input);
|
|
194
|
+
tools.push(s.line);
|
|
195
|
+
if (s.file) files.push(s.file);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const body = parts.join('\n').trim();
|
|
200
|
+
if (body.length === 0 && tools.length === 0) continue;
|
|
201
|
+
messages.push({ index, role: 'assistant', at, text: body, tools, files, sidechain, model: typeof message.model === 'string' ? message.model : null });
|
|
202
|
+
index += 1;
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
host: 'claude', sessionId, file, cwd, messages, startedAt: messages[0]?.at ?? null, endedAt: messages[messages.length - 1]?.at ?? null,
|
|
206
|
+
brokenLines: broken, bytes: Buffer.byteLength(raw), headless: entrypoint !== null && /^sdk/i.test(entrypoint),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function parseCodexSession(file: string): ParsedSession {
|
|
211
|
+
const raw = readFileSync(file, 'utf8');
|
|
212
|
+
const messages: SessionMessage[] = [];
|
|
213
|
+
let broken = 0;
|
|
214
|
+
let sessionId = file.split(/[\\/]/).pop()!.replace(/^rollout-\d{4}-\d{2}-\d{2}T[\d-]+-/, '').replace(/\.jsonl$/, '');
|
|
215
|
+
let cwd: string | null = null;
|
|
216
|
+
let sub = false;
|
|
217
|
+
let headless = false;
|
|
218
|
+
let metaSeen = false;
|
|
219
|
+
let index = 0;
|
|
220
|
+
for (const line of raw.split('\n')) {
|
|
221
|
+
if (!line.trim()) continue;
|
|
222
|
+
let rec: Record<string, unknown>;
|
|
223
|
+
try { rec = JSON.parse(line) as Record<string, unknown>; } catch { broken += 1; continue; }
|
|
224
|
+
const payload = rec.payload as Record<string, unknown> | undefined;
|
|
225
|
+
if (!payload) continue;
|
|
226
|
+
const at = isoOf(rec.timestamp);
|
|
227
|
+
if (rec.type === 'session_meta') {
|
|
228
|
+
// The first header is this session's; a later one (a fork's copied history) does not change who is speaking.
|
|
229
|
+
if (metaSeen) continue;
|
|
230
|
+
metaSeen = true;
|
|
231
|
+
if (typeof payload.id === 'string') sessionId = payload.id;
|
|
232
|
+
if (typeof payload.cwd === 'string') cwd = payload.cwd;
|
|
233
|
+
const source = payload.source;
|
|
234
|
+
if (payload.thread_source === 'subagent' || source === 'subagent' || (source !== null && typeof source === 'object' && 'subagent' in (source as object))) sub = true;
|
|
235
|
+
if (source === 'exec' || (typeof payload.originator === 'string' && /exec/i.test(payload.originator))) headless = true;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (rec.type !== 'response_item') continue;
|
|
239
|
+
const itemType = payload.type;
|
|
240
|
+
if (itemType === 'function_call' || itemType === 'local_shell_call' || itemType === 'custom_tool_call') {
|
|
241
|
+
const name = text(payload.name) || String(itemType);
|
|
242
|
+
let args: unknown = payload.arguments ?? payload.input;
|
|
243
|
+
if (typeof args === 'string') { try { args = JSON.parse(args); } catch { args = { command: args }; } }
|
|
244
|
+
const s = summarizeTool(name, args);
|
|
245
|
+
const last = messages[messages.length - 1];
|
|
246
|
+
if (last && last.role === 'assistant') {
|
|
247
|
+
messages[messages.length - 1] = { ...last, tools: [...last.tools, s.line], files: s.file ? [...last.files, s.file] : last.files };
|
|
248
|
+
} else {
|
|
249
|
+
messages.push({ index, role: 'assistant', at, text: '', tools: [s.line], files: s.file ? [s.file] : [], sidechain: sub, model: null });
|
|
250
|
+
index += 1;
|
|
251
|
+
}
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (itemType !== 'message') continue;
|
|
255
|
+
const role = payload.role;
|
|
256
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
257
|
+
const content = payload.content;
|
|
258
|
+
const parts: string[] = [];
|
|
259
|
+
if (typeof content === 'string') parts.push(content);
|
|
260
|
+
else if (Array.isArray(content)) for (const p of content as Record<string, unknown>[]) { const t = text(p.text) || text(p.input_text) || text(p.output_text); if (t) parts.push(t); }
|
|
261
|
+
const body = parts.join('\n').trim();
|
|
262
|
+
if (!body) continue;
|
|
263
|
+
if (role === 'user') {
|
|
264
|
+
// Codex wraps harness context in tags and puts the instruction files and the environment in user messages; keep
|
|
265
|
+
// the owner's own words, and nothing of a compaction summary.
|
|
266
|
+
const speech = userSpeech(body);
|
|
267
|
+
if (!speech) continue;
|
|
268
|
+
messages.push({ index, role, at, text: speech.text, tools: [], files: [], sidechain: sub, model: null, ...(speech.host && !sub ? { host: speech.host } : {}) });
|
|
269
|
+
} else {
|
|
270
|
+
messages.push({ index, role, at, text: body, tools: [], files: [], sidechain: sub, model: null });
|
|
271
|
+
}
|
|
272
|
+
index += 1;
|
|
273
|
+
}
|
|
274
|
+
return { host: 'codex', sessionId, file, cwd, messages, startedAt: messages[0]?.at ?? null, endedAt: messages[messages.length - 1]?.at ?? null, brokenLines: broken, bytes: Buffer.byteLength(raw), headless };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function parseSession(located: LocatedSession): ParsedSession {
|
|
278
|
+
return located.host === 'codex' ? parseCodexSession(located.file) : parseClaudeSession(located.file);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface Segment {
|
|
282
|
+
readonly messageStart: number;
|
|
283
|
+
readonly messageEnd: number;
|
|
284
|
+
readonly at: string | null;
|
|
285
|
+
readonly endAt: string | null;
|
|
286
|
+
readonly excerpt: string;
|
|
287
|
+
readonly ownerTurns: number;
|
|
288
|
+
readonly assistantTurns: number;
|
|
289
|
+
readonly filesTouched: readonly string[];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function renderMessage(m: SessionMessage): string {
|
|
293
|
+
const time = m.at ? m.at.slice(0, 16).replace('T', ' ') : '';
|
|
294
|
+
const trim = (t: string) => (t.length > ASSISTANT_TRIM ? `${t.slice(0, ASSISTANT_TRIM)}…` : t);
|
|
295
|
+
if (m.role === 'user') {
|
|
296
|
+
// Only the owner's own words are shown as the owner's: a subagent's task comes from the agent that sent it.
|
|
297
|
+
if (m.sidechain) return `[${m.index}] SUBAGENT TASK ${time}\n${trim(m.text)}`;
|
|
298
|
+
if (m.host !== undefined) return `[${m.index}] HOST ${time}\n(${m.host})`;
|
|
299
|
+
return `[${m.index}] OWNER ${time}\n${m.text}`;
|
|
300
|
+
}
|
|
301
|
+
const body = trim(m.text);
|
|
302
|
+
const tools = m.tools.length ? `\n tools: ${m.tools.slice(0, 12).join(' | ')}${m.tools.length > 12 ? ` | +${m.tools.length - 12} more` : ''}` : '';
|
|
303
|
+
return `[${m.index}] ${m.sidechain ? 'SUBAGENT' : 'AGENT'}${m.model ? ` (${m.model})` : ''} ${time}${body ? `\n${body}` : ''}${tools}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Cut a session into segments by pauses and size. Every message lands in exactly one segment. */
|
|
307
|
+
export function segmentSession(session: ParsedSession): Segment[] {
|
|
308
|
+
const segments: Segment[] = [];
|
|
309
|
+
let buf: SessionMessage[] = [];
|
|
310
|
+
let size = 0;
|
|
311
|
+
const flush = () => {
|
|
312
|
+
if (buf.length === 0) return;
|
|
313
|
+
const excerpt = buf.map(renderMessage).join('\n\n');
|
|
314
|
+
segments.push({
|
|
315
|
+
messageStart: buf[0]!.index, messageEnd: buf[buf.length - 1]!.index, at: buf[0]!.at, endAt: buf[buf.length - 1]!.at, excerpt,
|
|
316
|
+
ownerTurns: buf.filter(isOwnerMessage).length, assistantTurns: buf.filter((m) => m.role === 'assistant').length,
|
|
317
|
+
filesTouched: [...new Set(buf.flatMap((m) => m.files))].sort(),
|
|
318
|
+
});
|
|
319
|
+
buf = [];
|
|
320
|
+
size = 0;
|
|
321
|
+
};
|
|
322
|
+
let lastAt: number | null = null;
|
|
323
|
+
for (const m of session.messages) {
|
|
324
|
+
const t = m.at ? Date.parse(m.at) : null;
|
|
325
|
+
const gap = t !== null && lastAt !== null ? t - lastAt : 0;
|
|
326
|
+
const rendered = renderMessage(m).length;
|
|
327
|
+
if (buf.length > 0 && (gap > SEGMENT_GAP_MS || size + rendered > SEGMENT_MAX_CHARS || buf.length >= SEGMENT_MAX_MESSAGES) && m.role === 'user') flush();
|
|
328
|
+
else if (buf.length > 0 && size + rendered > SEGMENT_MAX_CHARS * 1.4) flush();
|
|
329
|
+
buf.push(m);
|
|
330
|
+
size += rendered + 2;
|
|
331
|
+
if (t !== null) lastAt = t;
|
|
332
|
+
}
|
|
333
|
+
flush();
|
|
334
|
+
return segments;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function sessionSources(projectId: string, located: LocatedSession, scopeItemId: string): { sources: Source[]; parsed: ParsedSession } {
|
|
338
|
+
const parsed = parseSession(located);
|
|
339
|
+
const sources = segmentSession(parsed).map((seg, i, all) => makeSessionSource({
|
|
340
|
+
projectId, host: located.host, sessionId: parsed.sessionId, file: located.file, cwd: parsed.cwd,
|
|
341
|
+
messageStart: seg.messageStart, messageEnd: seg.messageEnd, at: seg.at, excerpt: seg.excerpt,
|
|
342
|
+
title: `${located.host === 'claude' ? 'Claude Code' : 'Codex'} session ${parsed.sessionId.slice(0, 8)}${all.length > 1 ? ` · segment ${i + 1}/${all.length}` : ''}${seg.at ? ` · ${seg.at.slice(0, 10)}` : ''}`,
|
|
343
|
+
scopeItemId, ids: extractIds(seg.excerpt),
|
|
344
|
+
}));
|
|
345
|
+
return { sources, parsed };
|
|
346
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Session scope identity shared by discovery and live watching (Spec §1.1). */
|
|
2
|
+
import type { ScopeItem } from '../../model/types.ts';
|
|
3
|
+
import type { SessionHost } from '../../model/vocab.ts';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { sameDirectory, samePath } from '../../util/paths.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Session items recorded before `sessionCwd` named the directory in their reason. An owner edit wraps the old reason
|
|
9
|
+
* in text such as "Excluded by the owner (was: …)", so the expression deliberately is not anchored.
|
|
10
|
+
*/
|
|
11
|
+
const REASON_CWD = /sessions whose working directory is (.+?)(?: \(the original of a copy; read-only\))?: \d+ found/;
|
|
12
|
+
|
|
13
|
+
/** The complete working directory represented by a session item, including the legacy on-disk shape. */
|
|
14
|
+
export function sessionCwdOf(item: ScopeItem): string | null {
|
|
15
|
+
return item.sessionCwd ?? REASON_CWD.exec(item.reason)?.[1] ?? null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Find only the item for this host and complete cwd. There is intentionally no first-item fallback. */
|
|
19
|
+
export function sessionItemForCwd(scope: readonly ScopeItem[], host: SessionHost, cwd: string): ScopeItem | undefined {
|
|
20
|
+
return scope.find((item) => {
|
|
21
|
+
if (item.category !== 'Session source' || item.sessionHost !== host) return false;
|
|
22
|
+
const itemCwd = sessionCwdOf(item);
|
|
23
|
+
return itemCwd !== null && samePath(itemCwd, cwd);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The native home a source reads; a copy's frozen root is recorded on the corresponding session item. */
|
|
28
|
+
export function sessionStoreRootOf(scope: readonly ScopeItem[], host: 'claude' | 'codex', cwd: string, defaultHome = homedir()): string {
|
|
29
|
+
return sessionItemForCwd(scope, host, cwd)?.sessionStoreRoot ?? defaultHome;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Same session-source identity across the pre-D5 and current persisted shapes. An item recorded before locations were
|
|
34
|
+
* kept in the file system's spelling names its directory the way the project was given then — through a junction,
|
|
35
|
+
* say — and is still that directory's item: what the owner decided about it holds for the item discovery finds now.
|
|
36
|
+
*/
|
|
37
|
+
export function sameSessionItem(a: ScopeItem, b: ScopeItem): boolean {
|
|
38
|
+
if (a.category !== 'Session source' || b.category !== 'Session source' || a.sessionHost !== b.sessionHost) return false;
|
|
39
|
+
const aCwd = sessionCwdOf(a);
|
|
40
|
+
const bCwd = sessionCwdOf(b);
|
|
41
|
+
return aCwd !== null && bCwd !== null && sameDirectory(aCwd, bCwd);
|
|
42
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The owner's own words, taken out of the project's sessions by rule (Spec §3.3 "原话整理", §1.3; CKC-05 AC-13): what
|
|
3
|
+
* the owner typed in Claude Code and Codex sessions, and what they said in the Keeper conversation. The rule is the
|
|
4
|
+
* session parser's (read.ts `isOwnerMessage`): tool results, blocks the host or system injected, command wrappers and
|
|
5
|
+
* their output, compaction summaries, subagent branches and the environment and instruction files Codex injects are
|
|
6
|
+
* never the owner's. Nothing is taken out of a message: text pasted into it (another agent's report, a log) stays, and
|
|
7
|
+
* where it is recognisable it is marked, so the framing round decides what the owner meant, not this code.
|
|
8
|
+
*
|
|
9
|
+
* Every utterance names the session source it can be cited by — the segment its message is in — and its words are in
|
|
10
|
+
* that source's excerpt. Parsing is cached per log file and re-done only when the file changed.
|
|
11
|
+
*/
|
|
12
|
+
import { statSync } from 'node:fs';
|
|
13
|
+
import type { Source } from '../../model/types.ts';
|
|
14
|
+
import type { SessionHost } from '../../model/vocab.ts';
|
|
15
|
+
import type { ProjectStore } from '../../store/project-store.ts';
|
|
16
|
+
import { redactCredentials } from '../anchor.ts';
|
|
17
|
+
import { isOwnerMessage, parseClaudeSession, parseCodexSession } from './read.ts';
|
|
18
|
+
|
|
19
|
+
export interface PastedBlock {
|
|
20
|
+
/** Character offsets into the utterance's text: `text.slice(start, end)` is the block. */
|
|
21
|
+
readonly start: number;
|
|
22
|
+
readonly end: number;
|
|
23
|
+
/** What makes it recognisable as pasted: a code block, quoted text, a Markdown document, a table, a paste placeholder. */
|
|
24
|
+
readonly clue: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface OwnerUtterance {
|
|
28
|
+
readonly sourceId: string;
|
|
29
|
+
readonly host: SessionHost;
|
|
30
|
+
readonly at: string | null;
|
|
31
|
+
readonly text: string;
|
|
32
|
+
readonly chars: number;
|
|
33
|
+
readonly pasted: readonly PastedBlock[];
|
|
34
|
+
/** From a non-interactive run (`codex exec`, an SDK run of Claude Code): usually a program's or an agent's prompt. */
|
|
35
|
+
readonly headless: boolean;
|
|
36
|
+
/** Which session it belongs to, for the session filter: host and native log, or the Keeper conversation. */
|
|
37
|
+
readonly session: string;
|
|
38
|
+
readonly order: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Parsed { readonly size: number; readonly mtimeMs: number; readonly headless: boolean; readonly sessionId: string; readonly messages: readonly { index: number; at: string | null; text: string }[] }
|
|
42
|
+
const cache = new Map<string, Parsed>();
|
|
43
|
+
|
|
44
|
+
/** The owner's messages of one native log (parsed once per version of the file), or null when it cannot be read. */
|
|
45
|
+
function ownerMessagesOf(host: 'claude' | 'codex', file: string): Parsed | null {
|
|
46
|
+
let st;
|
|
47
|
+
try { st = statSync(file); } catch { return null; }
|
|
48
|
+
const key = `${host}:${file}`;
|
|
49
|
+
const hit = cache.get(key);
|
|
50
|
+
if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit;
|
|
51
|
+
try {
|
|
52
|
+
const parsed = host === 'codex' ? parseCodexSession(file) : parseClaudeSession(file);
|
|
53
|
+
const entry: Parsed = {
|
|
54
|
+
size: st.size, mtimeMs: st.mtimeMs, headless: parsed.headless === true, sessionId: parsed.sessionId,
|
|
55
|
+
messages: parsed.messages.filter(isOwnerMessage).map((m) => ({ index: m.index, at: m.at, text: m.text })),
|
|
56
|
+
};
|
|
57
|
+
cache.set(key, entry);
|
|
58
|
+
return entry;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const chars = (s: string): number => [...s].length;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Blocks of an owner's message that are recognisably pasted: fenced code, quoted lines, a Markdown document (from a
|
|
68
|
+
* heading to the last structured line after it), a table, the placeholder a host leaves for a paste it did not keep.
|
|
69
|
+
* The owner's own lines around a block stay outside it; where a boundary is doubtful the block is left smaller.
|
|
70
|
+
*/
|
|
71
|
+
export function pastedBlocks(text: string): PastedBlock[] {
|
|
72
|
+
const lines = text.split('\n');
|
|
73
|
+
const starts: number[] = [];
|
|
74
|
+
let offset = 0;
|
|
75
|
+
for (const l of lines) { starts.push(offset); offset += l.length + 1; }
|
|
76
|
+
const plain = lines.map((l) => l.replace(/\r$/, ''));
|
|
77
|
+
const endOf = (i: number) => starts[i]! + plain[i]!.length;
|
|
78
|
+
const found: { from: number; to: number; clue: string }[] = [];
|
|
79
|
+
const fenced = new Set<number>();
|
|
80
|
+
for (let i = 0; i < plain.length; i++) {
|
|
81
|
+
const open = /^\s*(`{3,}|~{3,})/.exec(plain[i]!);
|
|
82
|
+
if (!open) continue;
|
|
83
|
+
const mark = open[1]!.charAt(0);
|
|
84
|
+
let j = i + 1;
|
|
85
|
+
while (j < plain.length && !(/^\s*(`{3,}|~{3,})\s*$/.test(plain[j]!) && plain[j]!.trim().charAt(0) === mark)) j++;
|
|
86
|
+
const to = Math.min(j, plain.length - 1);
|
|
87
|
+
for (let k = i; k <= to; k++) fenced.add(k);
|
|
88
|
+
found.push({ from: i, to, clue: 'code block' });
|
|
89
|
+
i = to;
|
|
90
|
+
}
|
|
91
|
+
const heading = (l: string) => /^#{1,6}\s+\S/.test(l);
|
|
92
|
+
const table = (l: string) => /^\s*\|.*\|\s*$/.test(l);
|
|
93
|
+
const list = (l: string) => /^\s*([-*+]|\d{1,3}[.)])\s+\S/.test(l);
|
|
94
|
+
const quote = (l: string) => /^\s*>/.test(l);
|
|
95
|
+
const structured = (i: number) => fenced.has(i) || heading(plain[i]!) || table(plain[i]!) || list(plain[i]!) || quote(plain[i]!);
|
|
96
|
+
// Quoted lines and tables on their own.
|
|
97
|
+
for (const [test, clue, least] of [[quote, 'quoted text', 1], [table, 'table', 2]] as const) {
|
|
98
|
+
for (let i = 0; i < plain.length; i++) {
|
|
99
|
+
if (fenced.has(i) || !test(plain[i]!)) continue;
|
|
100
|
+
let j = i;
|
|
101
|
+
while (j + 1 < plain.length && !fenced.has(j + 1) && test(plain[j + 1]!)) j++;
|
|
102
|
+
if (j - i + 1 >= least) found.push({ from: i, to: j, clue });
|
|
103
|
+
i = j;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// A Markdown document: from its first heading to the last structured line after it, with at least one more.
|
|
107
|
+
const first = plain.findIndex((l, i) => !fenced.has(i) && heading(l));
|
|
108
|
+
if (first >= 0) {
|
|
109
|
+
let last = first;
|
|
110
|
+
let count = 0;
|
|
111
|
+
for (let i = first; i < plain.length; i++) if (structured(i)) { last = i; count++; }
|
|
112
|
+
if (count >= 2) found.push({ from: first, to: last, clue: 'Markdown document' });
|
|
113
|
+
}
|
|
114
|
+
const blocks: { from: number; to: number; clue: string }[] = [];
|
|
115
|
+
for (const b of found.sort((a, b) => a.from - b.from || b.to - a.to)) {
|
|
116
|
+
const outer = blocks[blocks.length - 1];
|
|
117
|
+
if (outer && b.from <= outer.to) { if (b.to > outer.to) outer.to = b.to; continue; }
|
|
118
|
+
blocks.push({ ...b });
|
|
119
|
+
}
|
|
120
|
+
const out: PastedBlock[] = blocks.map((b) => ({ start: starts[b.from]!, end: endOf(b.to), clue: b.clue }));
|
|
121
|
+
// A host that kept only a placeholder for a paste: the placeholder is marked; the pasted text is not in the log.
|
|
122
|
+
for (const m of text.matchAll(/\[Pasted text #\d+(?: \+\d+ lines)?\]/g)) {
|
|
123
|
+
const start = m.index!;
|
|
124
|
+
if (!out.some((b) => start >= b.start && start < b.end)) out.push({ start, end: start + m[0].length, clue: 'paste placeholder (the pasted text is not in the log)' });
|
|
125
|
+
}
|
|
126
|
+
return out.sort((a, b) => a.start - b.start);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The owner's words in one Keeper conversation message, or null when the message is not the owner's (an execution
|
|
131
|
+
* agent's query is not). Who wrote it and where the words begin are what the source records (`said`), so the heading
|
|
132
|
+
* the message is shown under can change without the owner's words dropping out. A message stored before that was
|
|
133
|
+
* recorded is read from its heading: "[owner <time>]", a line break, and then what was said.
|
|
134
|
+
*/
|
|
135
|
+
function ownersWords(s: Source): string | null {
|
|
136
|
+
if (s.said) return s.said.by === 'owner' ? s.excerpt.slice(s.said.wordsFrom) : null;
|
|
137
|
+
const m = /^\[owner [^\]\n]*\]\n([\s\S]*)$/.exec(s.excerpt);
|
|
138
|
+
return m ? m[1]! : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface UtteranceQuery {
|
|
142
|
+
readonly since?: string | null;
|
|
143
|
+
/** Any source of the session, or the session's native id: the utterances of that whole session. */
|
|
144
|
+
readonly sessionSourceId?: string | null;
|
|
145
|
+
}
|
|
146
|
+
export interface UtteranceSet {
|
|
147
|
+
readonly utterances: readonly OwnerUtterance[];
|
|
148
|
+
/** Owner messages in the logs whose segment is not read into the assets yet (they cannot be cited until it is). */
|
|
149
|
+
readonly notYetRead: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Every owner utterance of the project's sessions, oldest first. `since` keeps those at or after an ISO time or a date
|
|
154
|
+
* (from its start in UTC); `sessionSourceId` keeps one session.
|
|
155
|
+
*/
|
|
156
|
+
export function ownerUtterances(store: ProjectStore, query: UtteranceQuery = {}): UtteranceSet | string {
|
|
157
|
+
const sessions = store.sources.filter((s) => s.anchor.kind === 'session');
|
|
158
|
+
const byFile = new Map<string, { host: 'claude' | 'codex'; file: string; ranges: { from: number; to: number; id: string }[] }>();
|
|
159
|
+
const pi: Source[] = [];
|
|
160
|
+
for (const s of sessions) {
|
|
161
|
+
const a = s.anchor;
|
|
162
|
+
if (a.kind !== 'session') continue;
|
|
163
|
+
if (a.host === 'pi') { pi.push(s); continue; }
|
|
164
|
+
const key = `${a.host}:${a.file}`;
|
|
165
|
+
const entry = byFile.get(key) ?? { host: a.host, file: a.file, ranges: [] };
|
|
166
|
+
entry.ranges.push({ from: a.messageStart, to: a.messageEnd, id: s.id });
|
|
167
|
+
byFile.set(key, entry);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let wanted: string | null = null;
|
|
171
|
+
const filter = (query.sessionSourceId ?? '').trim();
|
|
172
|
+
if (filter) {
|
|
173
|
+
const src = store.sources.get(filter);
|
|
174
|
+
if (src && src.anchor.kind === 'session') wanted = src.anchor.host === 'pi' ? `pi:${src.anchor.sessionId}` : `${src.anchor.host}:${src.anchor.file}`;
|
|
175
|
+
else {
|
|
176
|
+
const bySessionId = sessions.find((s) => s.anchor.kind === 'session' && s.anchor.sessionId === filter);
|
|
177
|
+
if (!bySessionId || bySessionId.anchor.kind !== 'session') return `${filter} is neither a session source of this project nor the id of one of its sessions. pk_list_sources with kind "session" lists them.`;
|
|
178
|
+
wanted = bySessionId.anchor.host === 'pi' ? `pi:${bySessionId.anchor.sessionId}` : `${bySessionId.anchor.host}:${bySessionId.anchor.file}`;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
let since: string | null = null;
|
|
182
|
+
const rawSince = (query.since ?? '').trim();
|
|
183
|
+
if (rawSince) {
|
|
184
|
+
const ms = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(rawSince) ? `${rawSince}T00:00:00Z` : rawSince);
|
|
185
|
+
if (Number.isNaN(ms)) return `since must be an ISO time or a date (2026-09-17); “${rawSince}” is neither.`;
|
|
186
|
+
since = new Date(ms).toISOString();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const out: OwnerUtterance[] = [];
|
|
190
|
+
let notYetRead = 0;
|
|
191
|
+
let order = 0;
|
|
192
|
+
const seen = new Set<string>();
|
|
193
|
+
const add = (u: Omit<OwnerUtterance, 'chars' | 'pasted' | 'order'>) => {
|
|
194
|
+
// A resumed or copied session repeats earlier messages with their own times: the same words at the same moment are one.
|
|
195
|
+
const key = u.at ? `${u.host}|${u.at}|${u.text}` : null;
|
|
196
|
+
if (key) { if (seen.has(key)) return; seen.add(key); }
|
|
197
|
+
out.push({ ...u, chars: chars(u.text), pasted: pastedBlocks(u.text), order: order++ });
|
|
198
|
+
};
|
|
199
|
+
for (const entry of [...byFile.values()].sort((a, b) => a.file.localeCompare(b.file))) {
|
|
200
|
+
const session = `${entry.host}:${entry.file}`;
|
|
201
|
+
if (wanted && wanted !== session) continue;
|
|
202
|
+
const parsed = ownerMessagesOf(entry.host, entry.file);
|
|
203
|
+
if (!parsed) continue;
|
|
204
|
+
const ranges = [...entry.ranges].sort((a, b) => a.from - b.from);
|
|
205
|
+
for (const m of parsed.messages) {
|
|
206
|
+
const range = ranges.find((r) => m.index >= r.from && m.index <= r.to);
|
|
207
|
+
if (!range) { notYetRead++; continue; }
|
|
208
|
+
add({ sourceId: range.id, host: entry.host, at: m.at, text: redactCredentials(m.text).text, headless: parsed.headless, session });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (const s of pi.sort((a, b) => a.id.localeCompare(b.id))) {
|
|
212
|
+
const a = s.anchor;
|
|
213
|
+
if (a.kind !== 'session') continue;
|
|
214
|
+
const session = `pi:${a.sessionId}`;
|
|
215
|
+
if (wanted && wanted !== session) continue;
|
|
216
|
+
const words = ownersWords(s);
|
|
217
|
+
if (words === null || !words.trim()) continue;
|
|
218
|
+
add({ sourceId: s.id, host: 'pi', at: a.at, text: words, headless: false, session });
|
|
219
|
+
}
|
|
220
|
+
const kept = out.filter((u) => !since || (u.at !== null && u.at >= since));
|
|
221
|
+
kept.sort((a, b) => (a.at ?? '').localeCompare(b.at ?? '') || a.order - b.order);
|
|
222
|
+
return { utterances: kept, notYetRead };
|
|
223
|
+
}
|