@corenel/term 0.1.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/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @corenel/term
2
+
3
+ Corenel's terminal face: a headless chat controller and the [Ink](https://github.com/vadimdemedes/ink)
4
+ views that render it. Published so [`@corenel/cli`](https://www.npmjs.com/package/@corenel/cli)
5
+ can depend on it; usable directly if you are building a terminal agent of your own.
6
+
7
+ Requires Node 18+.
8
+
9
+ ## The split that matters
10
+
11
+ `./session` and its siblings are **Ink-free**. Only `./ink` pulls in React.
12
+
13
+ That is deliberate: the CLI runs the same controller behind two faces — the full
14
+ terminal UI on a TTY, and a plain readline loop when output is piped, under
15
+ `TERM=dumb`, in CI, or with `--no-tui`. Both drive one controller, so a fix to
16
+ turn handling lands in both faces at once rather than in whichever one somebody
17
+ remembered.
18
+
19
+ | Export | What it is | Pulls React |
20
+ | --- | --- | --- |
21
+ | `./session` | The turn controller — runs a turn, emits deltas | no |
22
+ | `./state` | The conversation state a face renders | no |
23
+ | `./commands` | Slash commands (`/clear`, `/resume`, …) | no |
24
+ | `./describe` | Human-readable renderings of harness events | no |
25
+ | `./persist` | Save and reopen conversations | no |
26
+ | `./ink` | The full terminal UI: scrollback, live tail, modals, palette | **yes** |
27
+
28
+ ```ts
29
+ import { TermSession } from '@corenel/term/session';
30
+ ```
31
+
32
+ ## Packaging
33
+
34
+ `dist/` is bundled (see `build.mjs`): `@corenel/harness` and `@corenel/protocol`
35
+ are inlined at build time and are **not** runtime dependencies. The only things
36
+ installed alongside this package are `ink`, `ink-text-input`, `openai` and
37
+ `react`.
38
+
39
+ One consequence worth knowing before you use this from TypeScript: the published
40
+ `.d.ts` files still reference `@corenel/harness` and `@corenel/protocol` for
41
+ types. The JavaScript runs without them, but a TypeScript consumer needs both
42
+ installed for those types to resolve. Both are on npm.
43
+
44
+ Licensed under Elastic-2.0.
@@ -0,0 +1,20 @@
1
+ // src/state.ts
2
+ function emptyState() {
3
+ return {
4
+ seq: 0,
5
+ transcriptGen: 0,
6
+ committed: [],
7
+ live: null,
8
+ queued: [],
9
+ modal: null,
10
+ usage: { promptTokens: 0, completionTokens: 0 },
11
+ turnUsage: { promptTokens: 0, completionTokens: 0 },
12
+ session: null,
13
+ status: "idle",
14
+ error: null
15
+ };
16
+ }
17
+
18
+ export {
19
+ emptyState
20
+ };
@@ -0,0 +1,235 @@
1
+ // ../protocol/files.ts
2
+ function flattenFiles(nodes, out = []) {
3
+ for (const n of nodes) {
4
+ if (n.kind === "file") out.push(n);
5
+ if (n.children?.length) flattenFiles(n.children, out);
6
+ }
7
+ return out;
8
+ }
9
+
10
+ // ../protocol/stateDir.ts
11
+ var DEFAULT_STATE_DIR = ".corenel";
12
+ var dirName = DEFAULT_STATE_DIR;
13
+ function sessionsRoot() {
14
+ return `${dirName}/sessions`;
15
+ }
16
+
17
+ // ../harness/sessions/jsonl.ts
18
+ function encodeLine(line) {
19
+ return JSON.stringify(line) + "\n";
20
+ }
21
+ function foldSession(text) {
22
+ let meta = null;
23
+ const turns = [];
24
+ let title = "";
25
+ let titleLocked;
26
+ let updatedAt = 0;
27
+ for (const raw of text.split("\n")) {
28
+ const s = raw.trim();
29
+ if (!s) continue;
30
+ let line;
31
+ try {
32
+ line = JSON.parse(s);
33
+ } catch {
34
+ continue;
35
+ }
36
+ if (line.k === "meta" && !meta && typeof line.id === "string" && typeof line.createdAt === "number") {
37
+ meta = line;
38
+ title = typeof line.title === "string" ? line.title : "";
39
+ updatedAt = Math.max(updatedAt, line.createdAt);
40
+ } else if (line.k === "meta-update" && typeof line.at === "number") {
41
+ if (typeof line.title === "string") title = line.title;
42
+ if (typeof line.titleLocked === "boolean") titleLocked = line.titleLocked;
43
+ updatedAt = Math.max(updatedAt, line.at);
44
+ } else if (line.k === "turn" && Number.isInteger(line.i) && line.i >= 0 && typeof line.at === "number") {
45
+ turns[line.i] = line.m;
46
+ updatedAt = Math.max(updatedAt, line.at);
47
+ }
48
+ }
49
+ if (!meta) return null;
50
+ return {
51
+ id: meta.id,
52
+ title,
53
+ createdAt: meta.createdAt,
54
+ updatedAt: updatedAt || meta.createdAt,
55
+ messages: turns.filter((t) => t !== void 0),
56
+ ...meta.surface ? { surface: meta.surface } : {},
57
+ ...titleLocked !== void 0 ? { titleLocked } : {}
58
+ };
59
+ }
60
+
61
+ // ../harness/sessions/fileSessionStore.ts
62
+ function uid(prefix) {
63
+ return `${prefix}_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
64
+ }
65
+ var lastTs = 0;
66
+ function now() {
67
+ const t = Date.now();
68
+ lastTs = t > lastTs ? t : lastTs + 1;
69
+ return lastTs;
70
+ }
71
+ var JsonlSessionStore = class {
72
+ constructor(getFiles, root = sessionsRoot) {
73
+ this.getFiles = getFiles;
74
+ this.root = root;
75
+ }
76
+ cursors = /* @__PURE__ */ new Map();
77
+ async files() {
78
+ const f = await this.getFiles();
79
+ if (!f) throw new Error("No file workspace is connected \u2014 sessions are unavailable.");
80
+ return f;
81
+ }
82
+ path(id) {
83
+ return `${this.root()}/${id}/SESSION.jsonl`;
84
+ }
85
+ async create(title, surface) {
86
+ const createdAt = now();
87
+ const id = uid("ses");
88
+ const meta = { k: "meta", id, title, createdAt, ...surface ? { surface } : {} };
89
+ const text = encodeLine(meta);
90
+ await (await this.files()).write(this.path(id), text);
91
+ this.cursors.set(id, { text, turnJson: [], title });
92
+ return { id, title, createdAt, updatedAt: createdAt, messages: [], ...surface ? { surface } : {} };
93
+ }
94
+ async get(id) {
95
+ const text = await (await this.files()).read(this.path(id)).catch(() => null);
96
+ if (text == null) return null;
97
+ const ses = foldSession(text);
98
+ if (ses) {
99
+ this.cursors.set(id, {
100
+ text,
101
+ turnJson: ses.messages.map((m) => JSON.stringify(m)),
102
+ title: ses.title,
103
+ titleLocked: ses.titleLocked
104
+ });
105
+ }
106
+ return ses;
107
+ }
108
+ async list(surface) {
109
+ const files = await this.files();
110
+ const root = this.root();
111
+ const nodes = await files.list(root).catch(() => []);
112
+ const sessionFiles = flattenFiles(nodes).filter((f) => f.name === "SESSION.jsonl" && f.path.startsWith(root + "/"));
113
+ const out = [];
114
+ for (const f of sessionFiles) {
115
+ const text = await files.read(f.path).catch(() => null);
116
+ const ses = text != null ? foldSession(text) : null;
117
+ if (ses) out.push(ses);
118
+ }
119
+ const scoped = surface ? out.filter((s) => s.surface === surface || surface === "agent" && !s.surface) : out;
120
+ return scoped.sort((a, b) => b.updatedAt - a.updatedAt);
121
+ }
122
+ async save(session) {
123
+ const at = now();
124
+ const cur = this.cursors.get(session.id) ?? await this.seed(session);
125
+ const turnJson = session.messages.map((m) => JSON.stringify(m));
126
+ const appended = [];
127
+ for (let i = 0; i < turnJson.length; i++) {
128
+ if (cur.turnJson[i] !== turnJson[i]) {
129
+ appended.push(encodeLine({ k: "turn", i, at, m: session.messages[i] }));
130
+ }
131
+ }
132
+ if (session.title !== cur.title || session.titleLocked !== cur.titleLocked) {
133
+ appended.push(encodeLine({
134
+ k: "meta-update",
135
+ at,
136
+ title: session.title,
137
+ ...session.titleLocked !== void 0 ? { titleLocked: session.titleLocked } : {}
138
+ }));
139
+ }
140
+ if (!appended.length) return;
141
+ session.updatedAt = at;
142
+ const text = cur.text + appended.join("");
143
+ await (await this.files()).write(this.path(session.id), text);
144
+ this.cursors.set(session.id, { text, turnJson, title: session.title, titleLocked: session.titleLocked });
145
+ }
146
+ /** Cursor for a session this instance hasn't touched (e.g. saved after a
147
+ * reload): fold the existing file, or start a fresh log from the session's
148
+ * own meta. */
149
+ async seed(session) {
150
+ const text = await (await this.files()).read(this.path(session.id)).catch(() => null);
151
+ if (text != null) {
152
+ const ses = foldSession(text);
153
+ if (ses) {
154
+ return { text, turnJson: ses.messages.map((m) => JSON.stringify(m)), title: ses.title, titleLocked: ses.titleLocked };
155
+ }
156
+ }
157
+ const meta = encodeLine({
158
+ k: "meta",
159
+ id: session.id,
160
+ title: session.title,
161
+ createdAt: session.createdAt,
162
+ ...session.surface ? { surface: session.surface } : {}
163
+ });
164
+ return { text: meta, turnJson: [], title: session.title, titleLocked: void 0 };
165
+ }
166
+ async rename(id, title) {
167
+ const cur = this.cursors.get(id);
168
+ const text = cur?.text ?? await (await this.files()).read(this.path(id)).catch(() => null);
169
+ if (text == null) return;
170
+ const next = text + encodeLine({ k: "meta-update", at: now(), title, titleLocked: true });
171
+ await (await this.files()).write(this.path(id), next);
172
+ const folded = foldSession(next);
173
+ if (folded) {
174
+ this.cursors.set(id, { text: next, turnJson: folded.messages.map((m) => JSON.stringify(m)), title, titleLocked: true });
175
+ }
176
+ }
177
+ async delete(id) {
178
+ this.cursors.delete(id);
179
+ await (await this.files()).remove(`${this.root()}/${id}`).catch(() => {
180
+ });
181
+ }
182
+ };
183
+
184
+ // src/persist.ts
185
+ function createSessionPersistence(files) {
186
+ const store = new JsonlSessionStore(() => files, () => sessionsRoot());
187
+ return {
188
+ async save(input) {
189
+ const session = input.id ? await store.get(input.id) : await store.create(input.title, "agent");
190
+ if (!session) {
191
+ const fresh = await store.create(input.title, "agent");
192
+ fresh.messages = input.messages;
193
+ await store.save(fresh);
194
+ return { id: fresh.id };
195
+ }
196
+ session.messages = input.messages;
197
+ await store.save(session);
198
+ return { id: session.id };
199
+ },
200
+ async load(id) {
201
+ if (id) {
202
+ const s = await store.get(id);
203
+ return s ? { id: s.id, title: s.title, messages: s.messages } : null;
204
+ }
205
+ const all = await store.list("agent");
206
+ const newest = [...all].sort((a, b) => b.updatedAt - a.updatedAt)[0];
207
+ return newest ? { id: newest.id, title: newest.title, messages: newest.messages } : null;
208
+ },
209
+ list: () => store.list("agent")
210
+ };
211
+ }
212
+ function committedFromMessages(messages) {
213
+ const items = [];
214
+ let n = 0;
215
+ for (const m of messages) {
216
+ if (m.role === "user" && typeof m.content === "string") {
217
+ items.push({ kind: "user", id: `h${n++}`, text: m.content });
218
+ } else if (m.role === "assistant" && typeof m.content === "string" && m.content.trim()) {
219
+ items.push({ kind: "assistant", id: `h${n++}`, text: m.content });
220
+ }
221
+ }
222
+ return items;
223
+ }
224
+ function titleFrom(messages) {
225
+ const first = messages.find((m) => m.role === "user");
226
+ const text = typeof first?.content === "string" ? first.content : "";
227
+ const line = text.split("\n")[0].trim();
228
+ return line.length > 60 ? `${line.slice(0, 59)}\u2026` : line || "conversation";
229
+ }
230
+
231
+ export {
232
+ createSessionPersistence,
233
+ committedFromMessages,
234
+ titleFrom
235
+ };