@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 +44 -0
- package/dist/chunk-47IH6X5H.js +20 -0
- package/dist/chunk-HAQGHLW6.js +235 -0
- package/dist/chunk-HYWLODZT.js +1230 -0
- package/dist/chunk-R6AXTD55.js +47 -0
- package/dist/chunk-TCVDVYMF.js +40 -0
- package/dist/commands.d.ts +25 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +10 -0
- package/dist/describe.d.ts +25 -0
- package/dist/describe.d.ts.map +1 -0
- package/dist/describe.js +14 -0
- package/dist/ink/App.d.ts +30 -0
- package/dist/ink/App.d.ts.map +1 -0
- package/dist/ink/Modal.d.ts +6 -0
- package/dist/ink/Modal.d.ts.map +1 -0
- package/dist/ink/Palette.d.ts +7 -0
- package/dist/ink/Palette.d.ts.map +1 -0
- package/dist/ink/index.d.ts +18 -0
- package/dist/ink/index.d.ts.map +1 -0
- package/dist/ink/index.js +573 -0
- package/dist/ink/items.d.ts +12 -0
- package/dist/ink/items.d.ts.map +1 -0
- package/dist/ink/scroll.d.ts +17 -0
- package/dist/ink/scroll.d.ts.map +1 -0
- package/dist/ink/status.d.ts +21 -0
- package/dist/ink/status.d.ts.map +1 -0
- package/dist/ink/theme.d.ts +22 -0
- package/dist/ink/theme.d.ts.map +1 -0
- package/dist/markdown.d.ts +33 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/persist.d.ts +30 -0
- package/dist/persist.d.ts.map +1 -0
- package/dist/persist.js +10 -0
- package/dist/session.d.ts +153 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +12 -0
- package/dist/state.d.ts +75 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +6 -0
- package/package.json +63 -0
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
|
+
};
|