pim-agent 0.9.0 → 0.11.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.
Files changed (71) hide show
  1. package/bin/pim.ts +1 -1
  2. package/package.json +2 -3
  3. package/packages/core/src/extensions/CoreExtensions.ts +19 -16
  4. package/packages/core/src/extensions/apply-patch/render.ts +16 -55
  5. package/packages/core/src/extensions/bash/index.ts +2 -1
  6. package/packages/core/src/extensions/bash/run.ts +8 -2
  7. package/packages/core/src/extensions/system-prompt/index.ts +45 -22
  8. package/packages/core/src/extensions/system-prompt/prompt.ts +6 -3
  9. package/packages/core/src/picker/PickerService.ts +23 -1
  10. package/packages/core/src/picker/token.ts +12 -5
  11. package/packages/core/src/session/EventLog.ts +22 -79
  12. package/packages/core/src/session/SearchIndex.ts +752 -0
  13. package/packages/core/src/session/SearchTokens.ts +97 -0
  14. package/packages/core/src/session/SessionDigest.ts +161 -0
  15. package/packages/core/src/session/SessionHost.ts +52 -10
  16. package/packages/core/src/session/SessionMeta.ts +267 -0
  17. package/packages/core/src/session/SessionName.ts +18 -0
  18. package/packages/core/src/session/SessionRegistry.ts +111 -15
  19. package/packages/core/src/session/SessionUi.ts +127 -0
  20. package/packages/core/src/session/WriteMark.ts +15 -1
  21. package/packages/core/src/shared/DiffLines.ts +1 -1
  22. package/packages/core/src/shared/DiffPatch.ts +70 -0
  23. package/packages/core/src/shared/DiffRenderer.ts +1 -1
  24. package/packages/core/src/shared/Directories.ts +39 -5
  25. package/packages/core/src/shared/ExtensionToggles.ts +8 -0
  26. package/packages/core/src/shared/FileWatch.ts +15 -1
  27. package/packages/core/src/shared/Git.ts +483 -42
  28. package/packages/core/src/shared/GitMonitor.ts +221 -0
  29. package/packages/core/src/shared/Levenshtein.ts +54 -1
  30. package/packages/core/src/shared/PiExtensions.ts +228 -0
  31. package/packages/core/src/shared/Proc.ts +21 -1
  32. package/packages/core/src/shared/RepoDiff.ts +741 -0
  33. package/packages/core/src/shared/Surface.ts +2 -0
  34. package/packages/core/src/shared/Updater.ts +21 -5
  35. package/packages/core/src/view/DiffExpand.ts +214 -0
  36. package/packages/core/src/view/DiffLayout.ts +7 -7
  37. package/packages/core/src/view/DiffPairs.ts +47 -0
  38. package/packages/core/src/view/MovePath.ts +70 -0
  39. package/packages/daemon/src/WebSurface.ts +1 -2
  40. package/packages/protocol/src/Command.ts +151 -3
  41. package/packages/protocol/src/Diff.ts +15 -0
  42. package/packages/protocol/src/ServerEvent.ts +191 -2
  43. package/packages/server/src/ClientConnection.ts +15 -3
  44. package/packages/server/src/ProbeClient.ts +111 -28
  45. package/packages/server/src/SessionCatalogue.ts +292 -38
  46. package/packages/server/src/SessionStream.ts +297 -45
  47. package/packages/server/src/StaticClient.ts +95 -12
  48. package/packages/server/src/WsGateway.ts +417 -45
  49. package/packages/server/src/probe.ts +77 -7
  50. package/packages/telegram/src/Session.ts +1 -0
  51. package/packages/tui/src/extensions/command-picker/index.ts +64 -32
  52. package/packages/tui/src/extensions/footer/index.ts +19 -43
  53. package/packages/tui/src/extensions/session-lease/index.ts +8 -3
  54. package/packages/web/dist/client/assets/CommitMonoV143-VF-ytizKI8U.woff2 +0 -0
  55. package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-D3tyj0PP.js} +1 -1
  56. package/packages/web/dist/client/assets/index-CTrlTM53.js +45 -0
  57. package/packages/web/dist/client/assets/index-JnduS4Cd.css +1 -0
  58. package/packages/web/dist/client/index.html +2 -2
  59. package/packages/protocol/src/Protocol.ts +0 -6
  60. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
  61. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
  62. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
  63. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
  64. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
  65. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
  66. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
  67. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
  68. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
  69. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
  70. package/packages/web/dist/client/assets/index-BMNwleWO.js +0 -40
  71. package/packages/web/dist/client/assets/index-CqjB2RPr.css +0 -1
@@ -0,0 +1,97 @@
1
+ /** One indexable word and where it sits in the string it came from. */
2
+ export type SearchToken = {
3
+ /** Lowercased: the corpus itself is never lowered, only its tokens. */
4
+ readonly text: string;
5
+ readonly start: number;
6
+ readonly end: number;
7
+ };
8
+
9
+ const RUN = /[\p{L}\p{N}_]+/gu;
10
+
11
+ function scan(text: string): readonly SearchToken[] {
12
+ const tokens: SearchToken[] = [];
13
+ for (const run of text.matchAll(RUN)) {
14
+ const at = run.index;
15
+ const word = run[0];
16
+ const pieces = piecesOf(word);
17
+ for (const [from, to] of pieces) {
18
+ tokens.push({
19
+ text: word.slice(from, to).toLowerCase(),
20
+ start: at + from,
21
+ end: at + to,
22
+ });
23
+ }
24
+ if (pieces.length > 1) {
25
+ tokens.push({
26
+ text: word.toLowerCase(),
27
+ start: at,
28
+ end: at + word.length,
29
+ });
30
+ }
31
+ }
32
+ return tokens;
33
+ }
34
+
35
+ /**
36
+ * The words a query asks for: its pieces alone, never the identifier they
37
+ * spell. `SessionLease` asks for `session` and `lease`, which the index holds
38
+ * for `SessionLease` and for `session lease` alike; asking for `sessionlease`
39
+ * too would AND away the second.
40
+ */
41
+ function words(query: string): readonly string[] {
42
+ const found: string[] = [];
43
+ for (const run of query.matchAll(RUN)) {
44
+ for (const [from, to] of piecesOf(run[0])) {
45
+ const word = run[0].slice(from, to).toLowerCase();
46
+ if (!found.includes(word)) {
47
+ found.push(word);
48
+ }
49
+ }
50
+ }
51
+ return found;
52
+ }
53
+
54
+ /** Half-open slices of one run: `WsGateway` is `ws` and `gateway`, `read_file` is `read` and `file`. */
55
+ function piecesOf(run: string): readonly (readonly [number, number])[] {
56
+ const pieces: (readonly [number, number])[] = [];
57
+ let start = -1;
58
+ for (let at = 0; at < run.length; at++) {
59
+ if (run[at] === "_") {
60
+ if (start >= 0) {
61
+ pieces.push([start, at]);
62
+ start = -1;
63
+ }
64
+ continue;
65
+ }
66
+ if (start < 0) {
67
+ start = at;
68
+ } else if (breaksAt(run, at)) {
69
+ pieces.push([start, at]);
70
+ start = at;
71
+ }
72
+ }
73
+ if (start >= 0) {
74
+ pieces.push([start, run.length]);
75
+ }
76
+ return pieces;
77
+ }
78
+
79
+ function breaksAt(run: string, at: number): boolean {
80
+ const here = run[at]!;
81
+ if (!isUpper(here)) {
82
+ return false;
83
+ }
84
+ const before = run[at - 1]!;
85
+ const after = run[at + 1];
86
+ return !isUpper(before) || (after !== undefined && isLower(after));
87
+ }
88
+
89
+ function isUpper(char: string): boolean {
90
+ return char !== char.toLowerCase() && char === char.toUpperCase();
91
+ }
92
+
93
+ function isLower(char: string): boolean {
94
+ return char !== char.toUpperCase() && char === char.toLowerCase();
95
+ }
96
+
97
+ export const SearchTokens = { scan, words };
@@ -0,0 +1,161 @@
1
+ import { parseSessionEntries } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { Attachments } from "../attachments/Attachments";
4
+ import { MessageText } from "./MessageText";
5
+
6
+ /** What the session catalogue shows for one file without opening a session. */
7
+ export type SessionDigest = {
8
+ /** The session's name if it has one, else its first user message; absent when it has neither. */
9
+ readonly title?: string;
10
+ /** True when `title` is a name somebody wrote rather than the opening message. */
11
+ readonly named?: true;
12
+ /** When the agent last wrote; it moves with an in-flight turn, so read it before starting one. */
13
+ readonly settledAt?: number;
14
+ };
15
+
16
+ /**
17
+ * The digest before it is clamped, so a reader that tails an appended region
18
+ * can fold that region into what an earlier read of the same file found.
19
+ */
20
+ export type DigestParts = {
21
+ readonly name?: string;
22
+ /** The opening ask, unclamped. */
23
+ readonly opening?: string;
24
+ readonly settledAt?: number;
25
+ };
26
+
27
+ /**
28
+ * A session file's durable bytes, undecoded: a whole session is megabytes of
29
+ * tool output and the digest wants a handful of lines of it, so the search
30
+ * runs over the bytes and only the lines it keeps become strings.
31
+ */
32
+ export type Durable = {
33
+ readonly bytes: Buffer;
34
+ /** Past the last newline; a trailing fragment is a write in progress and is not a line. */
35
+ readonly end: number;
36
+ };
37
+
38
+ const PROBE_LINES = 16;
39
+
40
+ const TAIL_LINES = 10;
41
+
42
+ const TITLE_LIMIT = 120;
43
+
44
+ /** Cheap reject before decoding a line: pi writes the discriminant verbatim. */
45
+ const NAMED = Buffer.from('"session_info"');
46
+
47
+ export const NEWLINE = 0x0a;
48
+
49
+ function durable(read: Uint8Array): Durable | undefined {
50
+ const bytes = Buffer.from(read.buffer, read.byteOffset, read.byteLength);
51
+ const end = bytes.lastIndexOf(NEWLINE) + 1;
52
+ return end === 0 ? undefined : { bytes, end };
53
+ }
54
+
55
+ function partsOf(body: Durable): DigestParts {
56
+ return {
57
+ name: nameOf(body),
58
+ opening: firstUserMessage(headLinesOf(body, PROBE_LINES)),
59
+ settledAt: lastAgentTime(tailLinesOf(body, TAIL_LINES).reverse()),
60
+ };
61
+ }
62
+
63
+ /** An append only adds, so the opening ask stands and a later name or agent line wins. */
64
+ function merge(before: DigestParts, appended: DigestParts): DigestParts {
65
+ return {
66
+ name: appended.name ?? before.name,
67
+ opening: before.opening ?? appended.opening,
68
+ settledAt: appended.settledAt ?? before.settledAt,
69
+ };
70
+ }
71
+
72
+ function of({ name, opening, settledAt }: DigestParts): SessionDigest {
73
+ const title = name ?? opening;
74
+ return {
75
+ ...(title === undefined ? {} : { title: clamp(title) }),
76
+ ...(name === undefined ? {} : { named: true as const }),
77
+ ...(settledAt === undefined ? {} : { settledAt }),
78
+ };
79
+ }
80
+
81
+ /** The last `session_info` line, found by walking the marker's occurrences backwards; a line that merely quotes the marker parses as something else, and the walk carries on past it. */
82
+ function nameOf({ bytes, end }: Durable): string | undefined {
83
+ let at = end - 1;
84
+ while (at >= 0) {
85
+ const found = bytes.lastIndexOf(NAMED, at);
86
+ if (found === -1) {
87
+ return undefined;
88
+ }
89
+ const from = bytes.lastIndexOf(NEWLINE, found) + 1;
90
+ const to = bytes.indexOf(NEWLINE, found);
91
+ const entry = parseSessionEntries(bytes.toString("utf8", from, to))[0];
92
+ if (entry?.type === "session_info") {
93
+ return entry.name?.trim() || undefined;
94
+ }
95
+ at = from - 1;
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ function headLinesOf({ bytes, end }: Durable, count: number): string[] {
101
+ let at = 0;
102
+ for (let taken = 0; taken < count && at < end; taken++) {
103
+ at = bytes.indexOf(NEWLINE, at) + 1;
104
+ }
105
+ return split(bytes.toString("utf8", 0, at));
106
+ }
107
+
108
+ function tailLinesOf({ bytes, end }: Durable, count: number): string[] {
109
+ let at = end - 1;
110
+ for (let taken = 0; taken < count && at > 0; taken++) {
111
+ at = bytes.lastIndexOf(NEWLINE, at - 1);
112
+ }
113
+ return split(bytes.toString("utf8", at + 1, end));
114
+ }
115
+
116
+ function split(text: string): string[] {
117
+ const lines = text.split("\n");
118
+ lines.pop();
119
+ return lines;
120
+ }
121
+
122
+ function lastAgentTime(lines: readonly string[]): number | undefined {
123
+ for (const line of lines) {
124
+ const entry = parseSessionEntries(line)[0];
125
+ if (entry?.type !== "message") {
126
+ continue;
127
+ }
128
+ const role = entry.message.role;
129
+ if (role === "assistant" || role === "toolResult") {
130
+ const at = Date.parse(entry.timestamp);
131
+ return Number.isNaN(at) ? undefined : at;
132
+ }
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ function clamp(text: string): string {
138
+ return text.length > TITLE_LIMIT
139
+ ? `${text.slice(0, TITLE_LIMIT).trimEnd()}…`
140
+ : text;
141
+ }
142
+
143
+ /** Over the head window the caller has already cut, not the whole file. */
144
+ function firstUserMessage(lines: readonly string[]): string | undefined {
145
+ for (const line of lines) {
146
+ const entry = parseSessionEntries(line)[0];
147
+ if (entry?.type !== "message" || entry.message.role !== "user") {
148
+ continue;
149
+ }
150
+ const said = Attachments.parse(MessageText.textOf(entry.message.content));
151
+ const text =
152
+ said.text.trim() ||
153
+ said.files.map((file) => Attachments.nameOf(file.path)).join(", ");
154
+ if (text !== "") {
155
+ return text;
156
+ }
157
+ }
158
+ return undefined;
159
+ }
160
+
161
+ export const SessionDigest = { durable, partsOf, merge, of, nameOf };
@@ -15,6 +15,7 @@ import {
15
15
  SettingsManager,
16
16
  type AgentSessionEvent,
17
17
  type CompactionResult,
18
+ type ExtensionError,
18
19
  type ToolDefinition,
19
20
  } from "@earendil-works/pi-coding-agent";
20
21
  import { unlink } from "node:fs/promises";
@@ -22,12 +23,15 @@ import { unlink } from "node:fs/promises";
22
23
  import { CoreExtensions } from "../extensions/CoreExtensions";
23
24
  import { Directories } from "../shared/Directories";
24
25
  import { FuzzyMatcher, type FuzzyCandidate } from "../shared/FuzzyMatcher";
26
+ import type { Surface } from "../shared/Surface";
25
27
  import { EventLog } from "./EventLog";
26
28
  import {
27
29
  SessionLease,
28
30
  type LeaseFrontend,
29
31
  type LeaseRecord,
30
32
  } from "./SessionLease";
33
+ import { SessionName } from "./SessionName";
34
+ import { adaptSessionUi, type SessionUi } from "./SessionUi";
31
35
  import { WriteMark } from "./WriteMark";
32
36
 
33
37
  /** What the agent is doing right now. */
@@ -93,11 +97,20 @@ export type SessionHostDeps = {
93
97
  readonly mainSessionPath?: () => string;
94
98
  readonly isolatedSessionPath?: () => string;
95
99
  readonly systemInstruction?: () => Promise<string | undefined>;
100
+ /** Where this host's human is reading; surfaces in the system prompt's environment block. */
101
+ readonly surface?: Surface;
96
102
  readonly customTools?: (
97
103
  context: CustomToolContext
98
104
  ) => readonly ToolDefinition[];
99
105
  /** Runs after the agent is disposed, before the session file is forgotten. */
100
106
  readonly onRetire?: (sessionPath: string) => Promise<void>;
107
+ /**
108
+ * Where this host's extensions say things, resolved per call so an agent
109
+ * rebuilt under the host follows its live target. Omit to leave pi's no-op
110
+ * UI in place: pi freezes the mode at `bindExtensions`, so this is a dep
111
+ * rather than something a caller can turn on once a session exists.
112
+ */
113
+ readonly ui?: () => SessionUi | undefined;
101
114
  };
102
115
 
103
116
  /** What a frontend's own tools are built against. */
@@ -399,6 +412,18 @@ export class SessionHost {
399
412
  });
400
413
  }
401
414
 
415
+ /**
416
+ * Rename this session through pi's own session name, so its `/resume` picker
417
+ * shows it too; `null` clears it. Answers with the name pi kept.
418
+ */
419
+ public setName(name: string | null): Promise<string | undefined> {
420
+ return this.withLease(async () => {
421
+ const agent = await this.ensureCached();
422
+ agent.setSessionName(SessionName.normalise(name));
423
+ return agent.sessionManager.getSessionName();
424
+ });
425
+ }
426
+
402
427
  public compact(customInstructions?: string): Promise<SessionCompactResult> {
403
428
  return this.withLease(async (): Promise<SessionCompactResult> => {
404
429
  const agent = await this.ensureCached();
@@ -716,7 +741,7 @@ export class SessionHost {
716
741
  agentDir: this.deps.agentDir,
717
742
  settingsManager,
718
743
  // Load core tools in-process: a disk-loaded copy registers views into a second `Tools`.
719
- extensionFactories: CoreExtensions.gated(),
744
+ extensionFactories: CoreExtensions.gated(this.deps.surface),
720
745
  appendSystemPromptOverride: (base) => {
721
746
  return promptRef.wrapped ? [...base, promptRef.wrapped] : base;
722
747
  },
@@ -754,16 +779,33 @@ export class SessionHost {
754
779
  ],
755
780
  });
756
781
 
757
- // Emits session_start; without it extension tools are registered but never usable.
758
- await agent.bindExtensions({
759
- mode: "print",
760
- onError: (err) => {
761
- console.warn(
762
- `[${this.label}] extension ${err.extensionPath} (${err.event}):`,
763
- err.error
782
+ const onError = (err: ExtensionError) => {
783
+ console.warn(
784
+ `[${this.label}] extension ${err.extensionPath} (${err.event}):`,
785
+ err.error
786
+ );
787
+ // Pi answers a command handler that threw as if it had succeeded, so
788
+ // without this the user's message vanishes and only stderr says why.
789
+ this.deps
790
+ .ui?.()
791
+ ?.notify(
792
+ `${err.extensionPath} (${err.event}) failed: ${err.error}`,
793
+ "error"
764
794
  );
765
- },
766
- });
795
+ };
796
+ const ui = this.deps.ui;
797
+ // Emits session_start; without it extension tools are registered but never usable.
798
+ await agent.bindExtensions(
799
+ ui
800
+ ? // Any context at all makes pi's `hasUI()` true (`uiContext !== noOpUIContext`,
801
+ // runner.js:318); "rpc" is its own name for dialog-capable but not a terminal.
802
+ {
803
+ uiContext: adaptSessionUi(ui),
804
+ mode: "rpc",
805
+ onError,
806
+ }
807
+ : { mode: "print", onError }
808
+ );
767
809
 
768
810
  return { agent, cwd };
769
811
  }
@@ -0,0 +1,267 @@
1
+ import { join } from "node:path";
2
+
3
+ import { Fs } from "../shared/Fs";
4
+ import { Json } from "../shared/Json";
5
+ import { Paths } from "../shared/Paths";
6
+
7
+ export type SessionEntry = {
8
+ readonly archived?: boolean;
9
+ /** Sticky: set by hand, and only a read clears it. */
10
+ readonly unread?: boolean;
11
+ };
12
+
13
+ export type ProjectEntry = {
14
+ readonly pinned?: boolean;
15
+ /** The sidebar group stands unfolded; absent is folded, which is where a project starts. */
16
+ readonly expanded?: boolean;
17
+ /** What to call the directory instead of its base name; the directory itself is never touched. */
18
+ readonly label?: string;
19
+ };
20
+
21
+ /** The pin flags and the order they are shown in, from one read of the file. */
22
+ export type Pinning = {
23
+ /** Keyed by absolute cwd. */
24
+ readonly projects: ReadonlyMap<string, ProjectEntry>;
25
+ /** The pinned directories, in the order they are shown. */
26
+ readonly order: readonly string[];
27
+ };
28
+
29
+ type Stored = {
30
+ readonly version: 1;
31
+ readonly sessions: Record<string, SessionEntry>;
32
+ readonly projects: Record<string, ProjectEntry>;
33
+ /**
34
+ * Where the pinned directories sort, and only that: `projects` stays the
35
+ * truth of whether one is pinned. An entry here that is not pinned is
36
+ * ignored and a pin it has never heard of still sorts, so a file written by
37
+ * a pim that predates the order — or by one that does not write it — needs
38
+ * no migration.
39
+ */
40
+ readonly pins: readonly string[];
41
+ };
42
+
43
+ type Loaded = {
44
+ readonly sessions: Map<string, SessionEntry>;
45
+ readonly projects: Map<string, ProjectEntry>;
46
+ pins: string[];
47
+ };
48
+
49
+ const NONE: SessionEntry = {};
50
+
51
+ /** Per-session and per-project overrides, shared by every surface on a machine. */
52
+ export class SessionMeta {
53
+ private readonly file: string;
54
+ private readonly writes = Fs.serialised();
55
+
56
+ /** Defaults to `~/.pim/sessions.json`. */
57
+ public constructor(path?: string) {
58
+ this.file = path ?? join(Paths.pimHomeDir(), "sessions.json");
59
+ }
60
+
61
+ public async of(sessionId: string): Promise<SessionEntry> {
62
+ return (await this.sessions()).get(sessionId) ?? NONE;
63
+ }
64
+
65
+ /** One read for a whole listing; `of` per row is one read per row. */
66
+ public async sessions(): Promise<ReadonlyMap<string, SessionEntry>> {
67
+ return (await this.read()).sessions;
68
+ }
69
+
70
+ /** The pinned directories in display order; a listing wants both halves, and this is one read for them. */
71
+ public async pinning(): Promise<Pinning> {
72
+ const loaded = await this.read();
73
+ return { projects: loaded.projects, order: ordered(loaded) };
74
+ }
75
+
76
+ /** The pinned directories in display order. */
77
+ public async pins(): Promise<readonly string[]> {
78
+ return ordered(await this.read());
79
+ }
80
+
81
+ public setArchived(sessionId: string, archived: boolean): Promise<void> {
82
+ return this.mutate((loaded) =>
83
+ put(loaded.sessions, sessionId, { archived })
84
+ );
85
+ }
86
+
87
+ public setUnread(sessionId: string, unread: boolean): Promise<void> {
88
+ return this.mutate((loaded) => put(loaded.sessions, sessionId, { unread }));
89
+ }
90
+
91
+ /** A new pin goes to the top, where you have just put it; an old one leaves the order with the flag. */
92
+ public setPinned(cwd: string, pinned: boolean): Promise<void> {
93
+ return this.mutate((loaded) => {
94
+ const at = loaded.pins.indexOf(cwd);
95
+ if (at !== -1) {
96
+ loaded.pins.splice(at, 1);
97
+ }
98
+ if (pinned) {
99
+ loaded.pins.unshift(cwd);
100
+ }
101
+ return put(loaded.projects, cwd, { pinned });
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Folds a project's group, or unfolds it. Kept beside the pin rather than
107
+ * in the browser, so the fold a phone made is the fold a desktop opens to.
108
+ */
109
+ public setExpanded(cwd: string, expanded: boolean): Promise<void> {
110
+ return this.mutate((loaded) => put(loaded.projects, cwd, { expanded }));
111
+ }
112
+
113
+ /** Names the project for the sidebar; `null` puts it back to its base name. */
114
+ public setLabel(cwd: string, label: string | null): Promise<void> {
115
+ return this.mutate((loaded) =>
116
+ put(loaded.projects, cwd, { label: label ?? "" })
117
+ );
118
+ }
119
+
120
+ /** Takes the whole order rather than a move, so two surfaces settle on the last one written. */
121
+ public setPinOrder(order: readonly string[]): Promise<void> {
122
+ return this.mutate((loaded) => {
123
+ loaded.pins = [...new Set(order)].filter(
124
+ (cwd) => loaded.projects.get(cwd)?.pinned === true
125
+ );
126
+ return true;
127
+ });
128
+ }
129
+
130
+ /** Drops the sessions that are gone; a pin outlives every session of its project. */
131
+ public prune(alive: ReadonlySet<string>): Promise<void> {
132
+ return this.mutate((loaded) => {
133
+ let dropped = false;
134
+ for (const sessionId of loaded.sessions.keys()) {
135
+ if (!alive.has(sessionId)) {
136
+ loaded.sessions.delete(sessionId);
137
+ dropped = true;
138
+ }
139
+ }
140
+ return dropped;
141
+ });
142
+ }
143
+
144
+ /** Settles the writes taken so far, for a clean stop. */
145
+ public async flush(): Promise<void> {
146
+ await this.writes.run(async () => undefined);
147
+ }
148
+
149
+ private async read(): Promise<Loaded> {
150
+ const raw = Json.asRecord(
151
+ await Fs.readJsonOr<unknown>(this.file, undefined)
152
+ );
153
+ if (raw?.version !== 1) {
154
+ return { sessions: new Map(), projects: new Map(), pins: [] };
155
+ }
156
+ return {
157
+ sessions: parseAll(raw.sessions, parseSession),
158
+ projects: parseAll(raw.projects, parseProject),
159
+ pins: Array.isArray(raw.pins)
160
+ ? raw.pins.filter((cwd) => typeof cwd === "string")
161
+ : [],
162
+ };
163
+ }
164
+
165
+ /** Re-reads under the lock, so a write by another process survives this one. */
166
+ private async mutate(update: (loaded: Loaded) => boolean): Promise<void> {
167
+ await this.writes.run(async () => {
168
+ const loaded = await this.read();
169
+ if (!update(loaded)) {
170
+ return;
171
+ }
172
+ await Paths.ensurePimHome();
173
+ await Fs.writeJson(this.file, {
174
+ version: 1,
175
+ sessions: Object.fromEntries(loaded.sessions),
176
+ projects: Object.fromEntries(loaded.projects),
177
+ pins: loaded.pins,
178
+ } satisfies Stored);
179
+ });
180
+ }
181
+ }
182
+
183
+ /**
184
+ * The pinned directories in display order: the ones the stored order names,
185
+ * then any pin it has not heard of, by path. A duplicate in the order is
186
+ * taken once, at the first place it appears.
187
+ */
188
+ function ordered({ projects, pins }: Loaded): readonly string[] {
189
+ const unplaced = new Set(
190
+ [...projects]
191
+ .filter(([, entry]) => entry.pinned === true)
192
+ .map(([cwd]) => cwd)
193
+ );
194
+ const listed = pins.filter((cwd) => unplaced.delete(cwd));
195
+ return [...listed, ...[...unplaced].sort()];
196
+ }
197
+
198
+ function put<T extends object>(
199
+ into: Map<string, T>,
200
+ key: string,
201
+ patch: T
202
+ ): boolean {
203
+ const next = onlySet({ ...into.get(key), ...patch });
204
+ if (Object.keys(next).length === 0) {
205
+ into.delete(key);
206
+ } else {
207
+ into.set(key, next);
208
+ }
209
+ return true;
210
+ }
211
+
212
+ /** Every field is opt-in, so a false flag or an empty name is the same as an absent one. */
213
+ function onlySet<T extends object>(entry: T): T {
214
+ return Object.fromEntries(
215
+ Object.entries(entry).filter(
216
+ ([, value]) =>
217
+ value === true || (typeof value === "string" && value !== "")
218
+ )
219
+ ) as T;
220
+ }
221
+
222
+ function parseAll<T>(
223
+ value: unknown,
224
+ parse: (value: unknown) => T | undefined
225
+ ): Map<string, T> {
226
+ const parsed = new Map<string, T>();
227
+ for (const [key, entry] of Object.entries(Json.asRecord(value) ?? {})) {
228
+ const valid = parse(entry);
229
+ if (valid !== undefined) {
230
+ parsed.set(key, valid);
231
+ }
232
+ }
233
+ return parsed;
234
+ }
235
+
236
+ function parseSession(value: unknown): SessionEntry | undefined {
237
+ const raw = Json.asRecord(value);
238
+ if (raw === undefined || !isFlag(raw.archived) || !isFlag(raw.unread)) {
239
+ return undefined;
240
+ }
241
+ return onlySet({ archived: raw.archived, unread: raw.unread });
242
+ }
243
+
244
+ function parseProject(value: unknown): ProjectEntry | undefined {
245
+ const raw = Json.asRecord(value);
246
+ if (
247
+ raw === undefined ||
248
+ !isFlag(raw.pinned) ||
249
+ !isFlag(raw.expanded) ||
250
+ !isName(raw.label)
251
+ ) {
252
+ return undefined;
253
+ }
254
+ return onlySet({
255
+ pinned: raw.pinned,
256
+ expanded: raw.expanded,
257
+ label: raw.label,
258
+ });
259
+ }
260
+
261
+ function isFlag(value: unknown): value is boolean | undefined {
262
+ return value === undefined || typeof value === "boolean";
263
+ }
264
+
265
+ function isName(value: unknown): value is string | undefined {
266
+ return value === undefined || typeof value === "string";
267
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Twice the width a sidebar row can show, so a name is never silently cut at the
3
+ * length it happens to render at, and a pasted essay still cannot become one.
4
+ */
5
+ const LIMIT = 80;
6
+
7
+ /** What pi should store for this name: whitespace collapsed, controls gone, `""` to clear. */
8
+ function normalise(name: string | null | undefined): string {
9
+ const clean = (name ?? "")
10
+ .replace(/\p{Cc}/gu, " ")
11
+ .replace(/\s+/gu, " ")
12
+ .trim();
13
+ // By code point: a cap that splits a surrogate pair stores half a character.
14
+ return [...clean].slice(0, LIMIT).join("").trim();
15
+ }
16
+
17
+ /** The one spelling of a session name pim writes into pi's `session_info`. */
18
+ export const SessionName = { normalise };