@audiofool/moondog 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 +125 -0
- package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
- package/contracts/v1/capability-policy.schema.json +572 -0
- package/contracts/v1/definitions.schema.json +365 -0
- package/contracts/v1/library-track-observation.schema.json +138 -0
- package/contracts/v1/listening-event.schema.json +111 -0
- package/contracts/v1/profile-evidence.schema.json +237 -0
- package/contracts/v1/taste-event.schema.json +258 -0
- package/contracts/v1/tool-invocation.schema.json +318 -0
- package/contracts/v1/tool-receipt.schema.json +1066 -0
- package/contracts/v1/track-ref.schema.json +275 -0
- package/contracts/v2/profile-evidence.schema.json +284 -0
- package/package.json +105 -0
- package/scripts/contract-lib.mjs +361 -0
- package/scripts/contract-semantics.mjs +2630 -0
- package/scripts/demo-studio.mjs +58 -0
- package/scripts/moondog +52 -0
- package/scripts/moondog.mjs +954 -0
- package/src/core/apple-data-migration.mjs +50 -0
- package/src/core/apple-library-source-status.mjs +220 -0
- package/src/core/apple-projection-domain-services.mjs +2356 -0
- package/src/core/capability-catalog.mjs +577 -0
- package/src/core/listening-profile-domain-services.mjs +1010 -0
- package/src/core/lyric-library.mjs +121 -0
- package/src/core/lyric-profile.mjs +50 -0
- package/src/core/lyric-service.mjs +41 -0
- package/src/core/moondog-application.mjs +1827 -0
- package/src/core/state-directory.mjs +19 -0
- package/src/core/synthetic-domain-services.mjs +1485 -0
- package/src/core/uuid-v5.mjs +42 -0
- package/src/demo/fictional-spotify-history.mjs +400 -0
- package/src/demo/moondog-demo.mjs +355 -0
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
- package/src/demo/moondog-tasteprint-demo.mjs +631 -0
- package/src/importers/apple-music-library/import-batch.mjs +290 -0
- package/src/importers/apple-music-library/index.mjs +76 -0
- package/src/importers/apple-music-library/normalize.mjs +1126 -0
- package/src/importers/apple-music-library/parse-plist.mjs +357 -0
- package/src/importers/apple-music-library/projection-records.mjs +557 -0
- package/src/importers/apple-music-library/read-batch.mjs +276 -0
- package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
- package/src/importers/apple-music-library/stable-ids.mjs +19 -0
- package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
- package/src/integrations/apple-music/catalog.mjs +770 -0
- package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
- package/src/integrations/listenbrainz/history-file.mjs +560 -0
- package/src/integrations/lyrics/lrclib.mjs +55 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
- package/src/integrations/public-playlists.mjs +104 -0
- package/src/integrations/spotify/account-data-history.mjs +545 -0
- package/src/integrations/spotify/account-data-profile.mjs +918 -0
- package/src/integrations/spotify/authentication.mjs +791 -0
- package/src/integrations/spotify/catalog-resolver.mjs +657 -0
- package/src/integrations/spotify/connection.mjs +125 -0
- package/src/integrations/spotify/credential-store.mjs +26 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
- package/src/integrations/spotify/history-archive.mjs +59 -0
- package/src/integrations/spotify/index.mjs +80 -0
- package/src/integrations/spotify/recent-activity.mjs +198 -0
- package/src/integrations/spotify/resolution-cache.mjs +260 -0
- package/src/integrations/spotify/service.mjs +713 -0
- package/src/integrations/spotify/settings.mjs +89 -0
- package/src/integrations/spotify/web-api-client.mjs +761 -0
- package/src/integrations/web/codex-web.mjs +263 -0
- package/src/integrations/youtube-music/takeout.mjs +143 -0
- package/src/memory/local-memory-store.mjs +1705 -0
- package/src/memory/reflection-worker.mjs +87 -0
- package/src/profile/apple-library-import.mjs +28 -0
- package/src/profile/history-import.mjs +163 -0
- package/src/profile/listener-corrections.mjs +217 -0
- package/src/profile/listening-history-store.mjs +2055 -0
- package/src/profile/listening-profile-projection.mjs +2520 -0
- package/src/profile/local-music-data.mjs +812 -0
- package/src/profile/music-import-bundle.mjs +91 -0
- package/src/profile/music-providers.mjs +25 -0
- package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
- package/src/profile/spotify-archive-taste.mjs +482 -0
- package/src/runtime/pi/agent-runtime.mjs +6444 -0
- package/src/runtime/pi/authentication.mjs +104 -0
- package/src/runtime/pi/configured-runtime.mjs +98 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
- package/src/runtime/pi/model-catalog.mjs +62 -0
- package/src/runtime/pi/model-transport.mjs +117 -0
- package/src/runtime/pi/persistent-credential-store.mjs +670 -0
- package/src/runtime/pi/provider-registry.mjs +74 -0
- package/src/runtime/pi/runtime-settings.mjs +81 -0
- package/src/surfaces/cli/auth-command.mjs +355 -0
- package/src/surfaces/cli/brand-components.mjs +390 -0
- package/src/surfaces/cli/brand-theme.mjs +231 -0
- package/src/surfaces/cli/catalog-command.mjs +509 -0
- package/src/surfaces/cli/command-completions.mjs +113 -0
- package/src/surfaces/cli/data-command.mjs +214 -0
- package/src/surfaces/cli/format-output.mjs +750 -0
- package/src/surfaces/cli/history-import-view.mjs +445 -0
- package/src/surfaces/cli/import-guides.mjs +9 -0
- package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
- package/src/surfaces/cli/listening-menu.mjs +171 -0
- package/src/surfaces/cli/profile-command.mjs +290 -0
- package/src/surfaces/cli/spotify-command.mjs +849 -0
- package/src/surfaces/cli/taste-profile-model.mjs +306 -0
- package/src/surfaces/cli/taste-profile-view.mjs +272 -0
- package/src/surfaces/cli/terminal-art.mjs +295 -0
- package/src/surfaces/cli/tui.mjs +1714 -0
- package/src/surfaces/cli/web-command.mjs +52 -0
- package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
- package/src/surfaces/html/tasteprint.mjs +3575 -0
- package/src/surfaces/web/showcase.mjs +272 -0
- package/src/surfaces/web/studio-page.mjs +4148 -0
- package/src/surfaces/web/studio.mjs +2288 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { CURSOR_MARKER, Editor, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
import { stripVTControlCharacters } from "node:util";
|
|
3
|
+
import { sanitizeTerminalText } from "./format-output.mjs";
|
|
4
|
+
import { LOGO_MOTION_FRAMES, renderLunarRecord, renderMoondogWordmark } from "./terminal-art.mjs";
|
|
5
|
+
export { ListeningMenu } from "./listening-menu.mjs";
|
|
6
|
+
|
|
7
|
+
export function paintBrandLine(line, width, theme) {
|
|
8
|
+
const clipped = truncateToWidth(line, Math.max(1, width), "");
|
|
9
|
+
const padding = " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
10
|
+
// NO_COLOR affects styling, not Pi's zero-width cursor marker or IME placement.
|
|
11
|
+
if (theme.plain) return (clipped + padding).replace(/\u001b\[[\d;:]*m/g, "");
|
|
12
|
+
return theme.background(theme.text(clipped + padding));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function pad(text, width) {
|
|
16
|
+
const clipped = truncateToWidth(text, Math.max(1, width), "");
|
|
17
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class BrandSurface {
|
|
21
|
+
constructor(component, getTheme, padding = 0) {
|
|
22
|
+
this.component = component;
|
|
23
|
+
this.getTheme = getTheme;
|
|
24
|
+
this.padding = padding;
|
|
25
|
+
}
|
|
26
|
+
handleInput(data) { this.component.handleInput?.(data); }
|
|
27
|
+
invalidate() {
|
|
28
|
+
this.paintCache = undefined;
|
|
29
|
+
this.component.invalidate?.();
|
|
30
|
+
}
|
|
31
|
+
render(width) {
|
|
32
|
+
const theme = this.getTheme();
|
|
33
|
+
const padding = Math.min(this.padding, Math.max(0, Math.floor((width - 1) / 2)));
|
|
34
|
+
const lines = this.component.render(Math.max(1, width - padding * 2));
|
|
35
|
+
if (this.paintTheme !== theme || this.paintWidth !== width || this.paintPadding !== padding) {
|
|
36
|
+
this.paintCache = new Map();
|
|
37
|
+
this.paintTheme = theme;
|
|
38
|
+
this.paintWidth = width;
|
|
39
|
+
this.paintPadding = padding;
|
|
40
|
+
}
|
|
41
|
+
const prefix = " ".repeat(padding);
|
|
42
|
+
return lines.map((line) => {
|
|
43
|
+
const painted = this.paintCache.get(line);
|
|
44
|
+
if (painted !== undefined) return painted;
|
|
45
|
+
const next = paintBrandLine(prefix + line, width, theme);
|
|
46
|
+
if (this.paintCache.size > 4000) this.paintCache.clear();
|
|
47
|
+
this.paintCache.set(line, next);
|
|
48
|
+
return next;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class ListeningHeader {
|
|
54
|
+
constructor(getTheme, getStatus) {
|
|
55
|
+
this.getTheme = getTheme;
|
|
56
|
+
this.getStatus = getStatus;
|
|
57
|
+
}
|
|
58
|
+
invalidate() {}
|
|
59
|
+
render(width) {
|
|
60
|
+
const theme = this.getTheme();
|
|
61
|
+
const { profileReady, modelReady, spotifyReady, provider, model } = this.getStatus();
|
|
62
|
+
const title = theme.bold("MOONDOG") + (width >= 46 ? theme.muted(" / your listening room") : "");
|
|
63
|
+
const compact = width < 60;
|
|
64
|
+
const clean = (value) => sanitizeTerminalText(stripVTControlCharacters(String(value ?? "")))
|
|
65
|
+
.replace(/\s+/gu, " ").trim();
|
|
66
|
+
const modelName = clean(model);
|
|
67
|
+
const providerName = clean(provider);
|
|
68
|
+
const profileLabel = profileReady
|
|
69
|
+
? compact ? "profile ready" : "local profile"
|
|
70
|
+
: compact ? "no history" : "bring your history";
|
|
71
|
+
const modelLabel = modelReady ? modelName || (compact ? "model ready" : "conversation ready") : "model offline";
|
|
72
|
+
const identity = modelReady && providerName ? `${providerName} / ${modelLabel}` : modelLabel;
|
|
73
|
+
const available = Math.max(1, width - 1);
|
|
74
|
+
// Keep the active model readable before spending space on optional status.
|
|
75
|
+
const candidates = [
|
|
76
|
+
[profileLabel, identity, ...(spotifyReady && !compact ? ["Spotify connected"] : [])],
|
|
77
|
+
[profileLabel, identity],
|
|
78
|
+
[profileLabel, modelLabel],
|
|
79
|
+
[identity],
|
|
80
|
+
[modelLabel],
|
|
81
|
+
].map((parts) => parts.join(" · "));
|
|
82
|
+
const status = candidates.find((candidate) => visibleWidth(candidate) <= available)
|
|
83
|
+
?? truncateToWidth(modelLabel, available, "…");
|
|
84
|
+
return [
|
|
85
|
+
paintBrandLine(` ${title}`, width, theme),
|
|
86
|
+
paintBrandLine(` ${theme.faint(status)}`, width, theme),
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Keep Pi's editing, paste, IME, history, and autocomplete intact. */
|
|
92
|
+
export class ListeningEditor extends Editor {
|
|
93
|
+
constructor(tui, theme, getTheme, getState) {
|
|
94
|
+
super(tui, theme, { paddingX: 1, autocompleteMaxVisible: 5 });
|
|
95
|
+
this.getTheme = getTheme;
|
|
96
|
+
this.getState = getState;
|
|
97
|
+
this.rowCount = 3;
|
|
98
|
+
}
|
|
99
|
+
// The sleeve measures this during the same frame. The signature is taken again
|
|
100
|
+
// after painting because the editor can adjust its own scroll while rendering.
|
|
101
|
+
renderSignature() {
|
|
102
|
+
const { busy, homeVisible, homeFocused } = this.getState();
|
|
103
|
+
const list = this.autocompleteState ? this.autocompleteList : null;
|
|
104
|
+
const items = list?.filteredItems ?? [];
|
|
105
|
+
return [
|
|
106
|
+
this.focused ? 1 : 0,
|
|
107
|
+
this.disableSubmit ? 1 : 0,
|
|
108
|
+
busy ? 1 : 0,
|
|
109
|
+
homeVisible ? 1 : 0,
|
|
110
|
+
homeFocused ? 1 : 0,
|
|
111
|
+
this.state.cursorLine,
|
|
112
|
+
this.state.cursorCol,
|
|
113
|
+
this.scrollOffset,
|
|
114
|
+
this.state.lines.join("\n"),
|
|
115
|
+
list ? list.selectedIndex : "",
|
|
116
|
+
items.map((item) => `${item.value ?? ""}\t${item.label ?? ""}`).join("\n"),
|
|
117
|
+
].join("\0");
|
|
118
|
+
}
|
|
119
|
+
render(width) {
|
|
120
|
+
const theme = this.getTheme();
|
|
121
|
+
const signature = `${width}\0${this.renderSignature()}`;
|
|
122
|
+
if (this.renderCache?.signature === signature && this.renderCache.theme === theme) {
|
|
123
|
+
this.rowCount = this.renderCache.lines.length;
|
|
124
|
+
return this.renderCache.lines;
|
|
125
|
+
}
|
|
126
|
+
const lines = super.render(width).map((line) => this.focused ? line : line.replace(/\x1b\[7m([\s\S]*?)\x1b\[0m/g, "$1"));
|
|
127
|
+
const { busy, homeVisible, homeFocused } = this.getState();
|
|
128
|
+
if (!/[↑↓]/u.test(lines[0])) {
|
|
129
|
+
const label = busy ? " DRAFT " : homeFocused ? " TAB TO TYPE " : " YOU ";
|
|
130
|
+
lines[0] = width >= label.length + 4
|
|
131
|
+
? theme.faint("─ ") + theme.muted(label) + theme.faint("─".repeat(width - label.length - 2))
|
|
132
|
+
: theme.faint("─".repeat(width));
|
|
133
|
+
}
|
|
134
|
+
if (!busy && !this.getText() && lines.length === 3 && width >= 28) {
|
|
135
|
+
const hint = homeFocused ? "Esc returns to your next thought." : homeVisible ? "What have you been listening to?" : "Keep the conversation going...";
|
|
136
|
+
const text = truncateToWidth(hint, width - 2, "");
|
|
137
|
+
lines[1] = " " + (this.focused ? CURSOR_MARKER + theme.inverse(text[0]) : theme.faint(text[0])) + theme.faint(text.slice(1));
|
|
138
|
+
}
|
|
139
|
+
this.rowCount = lines.length;
|
|
140
|
+
this.renderCache = { signature: `${width}\0${this.renderSignature()}`, theme, lines };
|
|
141
|
+
return lines;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const homeActions = [
|
|
146
|
+
{ command: "taste", label: "Listening profile", short: "Profile", description: "See what your listening reveals. Tell me what I missed." },
|
|
147
|
+
{ command: "import", label: "Import your music", short: "Import music", description: "Bring songs, playlists or listening history." },
|
|
148
|
+
{ command: "theme", label: "Appearance", short: "Appearance", description: "Paper, charcoal, or your terminal colors." },
|
|
149
|
+
{ command: "help", label: "Help & commands", short: "Help", description: "Commands, connections and keyboard shortcuts." },
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
const sleeveNotes = [
|
|
153
|
+
// Welcome to the Machine: https://www.pinkfloyd.com/albums/wish-you-were-here/
|
|
154
|
+
"Where have you been?",
|
|
155
|
+
// https://www.pinkfloyd.com/albums/the-wall/
|
|
156
|
+
"Is there anybody out there?",
|
|
157
|
+
// https://www.pinkfloyd.com/albums/wish-you-were-here/
|
|
158
|
+
"Wish you were here.",
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
const lyricSegments = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
162
|
+
|
|
163
|
+
function scrollingLyric(text, columns, position) {
|
|
164
|
+
let line = "";
|
|
165
|
+
let filled = 0;
|
|
166
|
+
for (const { segment } of lyricSegments.segment(text)) {
|
|
167
|
+
const width = visibleWidth(segment);
|
|
168
|
+
// A terminal cannot draw half a wide character; leave its clipped cell blank.
|
|
169
|
+
if (position >= 0 && position + width <= columns) {
|
|
170
|
+
line += " ".repeat(Math.max(0, position - filled)) + segment;
|
|
171
|
+
filled = position + width;
|
|
172
|
+
}
|
|
173
|
+
position += width;
|
|
174
|
+
if (position >= columns) break;
|
|
175
|
+
}
|
|
176
|
+
return line;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A character-native listening room; it never transmits image protocols. */
|
|
180
|
+
export class RecordSleeve {
|
|
181
|
+
constructor({ terminal, getTheme, environment = process.env, getState = () => ({}), random = Math.random, getNextLyric = () => null }) {
|
|
182
|
+
this.terminal = terminal;
|
|
183
|
+
this.getTheme = getTheme;
|
|
184
|
+
this.getState = getState;
|
|
185
|
+
this.environment = environment;
|
|
186
|
+
this.random = random;
|
|
187
|
+
this.getNextLyric = getNextLyric;
|
|
188
|
+
this.artMode = environment.MOONDOG_ART ?? "auto";
|
|
189
|
+
// Redraws preserve the line; only a completed passage asks for another.
|
|
190
|
+
this.sleeveNote = sleeveNotes[Math.floor(random() * sleeveNotes.length)];
|
|
191
|
+
this.defaultSleeveNote = this.sleeveNote;
|
|
192
|
+
this.phase = 0;
|
|
193
|
+
this.lyricPosition = 0;
|
|
194
|
+
this.lyricHold = 12; // Give the opening 1.5 seconds before it starts moving.
|
|
195
|
+
this.lyricColumns = 0;
|
|
196
|
+
this.canAnimate = false;
|
|
197
|
+
this.cache = new Map();
|
|
198
|
+
this.colored = new Map();
|
|
199
|
+
}
|
|
200
|
+
invalidate() {
|
|
201
|
+
this.cache.clear();
|
|
202
|
+
this.colored.clear();
|
|
203
|
+
this.frame = undefined;
|
|
204
|
+
this.colorTheme = undefined;
|
|
205
|
+
}
|
|
206
|
+
setLyric(text, { enterFromRight = false } = {}) {
|
|
207
|
+
const note = text ? sanitizeTerminalText(text).replace(/\s+/gu, " ").trim() : this.defaultSleeveNote;
|
|
208
|
+
if (note !== this.sleeveNote || enterFromRight) {
|
|
209
|
+
this.lyricPosition = enterFromRight ? this.lyricColumns : 0;
|
|
210
|
+
this.lyricHold = enterFromRight ? 0 : 12;
|
|
211
|
+
}
|
|
212
|
+
this.sleeveNote = note;
|
|
213
|
+
this.invalidate();
|
|
214
|
+
}
|
|
215
|
+
setArtMode(mode) { this.artMode = mode; this.invalidate(); }
|
|
216
|
+
advance() {
|
|
217
|
+
this.phase = (this.phase + 1) % LOGO_MOTION_FRAMES;
|
|
218
|
+
if (!this.lyricColumns) return;
|
|
219
|
+
if (this.lyricHold > 0) { this.lyricHold--; return; }
|
|
220
|
+
this.lyricPosition--;
|
|
221
|
+
if (this.lyricPosition <= -visibleWidth(`"${this.sleeveNote}"`) - 8) {
|
|
222
|
+
const fallback = sleeveNotes.filter((note) => note !== this.sleeveNote);
|
|
223
|
+
const next = this.getNextLyric() ?? fallback[Math.floor(this.random() * fallback.length)];
|
|
224
|
+
this.setLyric(next, { enterFromRight: true });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
render(width) {
|
|
228
|
+
const theme = this.getTheme();
|
|
229
|
+
const { focused = false, selected = 0, editorRows = 3,
|
|
230
|
+
motionEnabled = this.environment.MOONDOG_MOTION !== "off" } = this.getState();
|
|
231
|
+
// Fill the viewport between the header and composer, independently of art size.
|
|
232
|
+
const rows = Math.max(1, this.terminal.rows - 2 - editorRows - (this.terminal.rows >= 20 ? 2 : 1));
|
|
233
|
+
const frameKey = [
|
|
234
|
+
width, rows, this.phase, this.lyricPosition, this.sleeveNote, focused ? 1 : 0, selected,
|
|
235
|
+
motionEnabled ? 1 : 0, this.artMode, this.environment.TERM ?? "", theme.plain ? 1 : 0,
|
|
236
|
+
].join("\0");
|
|
237
|
+
if (this.frame?.key === frameKey && this.frame.theme === theme) {
|
|
238
|
+
this.canAnimate = this.frame.canAnimate;
|
|
239
|
+
this.lyricColumns = this.frame.lyricColumns;
|
|
240
|
+
return this.frame.lines;
|
|
241
|
+
}
|
|
242
|
+
const finish = (lines) => {
|
|
243
|
+
this.frame = { key: frameKey, theme, lines, canAnimate: this.canAnimate, lyricColumns: this.lyricColumns };
|
|
244
|
+
return lines;
|
|
245
|
+
};
|
|
246
|
+
const paint = (line) => paintBrandLine(line, width, theme);
|
|
247
|
+
this.canAnimate = false;
|
|
248
|
+
this.lyricColumns = 0;
|
|
249
|
+
const roomy = width >= 76 && rows >= 14;
|
|
250
|
+
const narrow = width < 54;
|
|
251
|
+
const available = Math.max(1, width - 4);
|
|
252
|
+
const artWidth = Math.min(50, Math.floor(available * (narrow ? 0.40 : 0.48)));
|
|
253
|
+
const rightWidth = Math.max(1, available - artWidth - 3);
|
|
254
|
+
const actionLines = homeActions.map((action, index) => {
|
|
255
|
+
const label = narrow ? action.short : action.label;
|
|
256
|
+
const active = focused && selected === index;
|
|
257
|
+
const prefix = active ? theme.accent("› ") : theme.faint(`${index + 1} `);
|
|
258
|
+
return prefix + (active ? theme.inverse(theme.bold(` ${label} `)) : theme.text(` ${label} `));
|
|
259
|
+
});
|
|
260
|
+
const pixelTitle = rightWidth >= 41 && rows >= 20 && !["ascii", "text"].includes(this.artMode) && this.environment.TERM !== "dumb";
|
|
261
|
+
const withNote = (lines, columns) => {
|
|
262
|
+
const scrolling = motionEnabled && !theme.plain;
|
|
263
|
+
const text = `"${this.sleeveNote}"`;
|
|
264
|
+
const noteLines = ["", ...(scrolling ? [scrollingLyric(text, columns, this.lyricPosition)] : wrapTextWithAnsi(text, columns)).map(theme.muted)];
|
|
265
|
+
if (lines.length + noteLines.length > rows) return lines;
|
|
266
|
+
this.canAnimate ||= scrolling;
|
|
267
|
+
this.lyricColumns = scrolling ? columns : 0;
|
|
268
|
+
return [...lines, ...noteLines];
|
|
269
|
+
};
|
|
270
|
+
const copy = withNote([
|
|
271
|
+
...(pixelTitle ? renderMoondogWordmark().map(theme.text) : [theme.bold(roomy ? "M O O N D O G" : "MOONDOG")]),
|
|
272
|
+
...(rows >= 12 ? [...wrapDescription("Your personal music agent.", rightWidth).map(theme.muted), ""] : [""]),
|
|
273
|
+
...actionLines,
|
|
274
|
+
...(roomy ? ["", ...wrapDescription(homeActions[selected].description, rightWidth).map(theme.muted)] : []),
|
|
275
|
+
], rightWidth);
|
|
276
|
+
if (rows < 7 || width < 34 || this.artMode === "off") {
|
|
277
|
+
this.canAnimate = false;
|
|
278
|
+
this.lyricColumns = 0;
|
|
279
|
+
const compact = withNote([theme.bold("MOONDOG ◎"),
|
|
280
|
+
...(rows >= 9 && width >= 30 ? [theme.muted("Your personal music agent."), ""] : []),
|
|
281
|
+
...actionLines,
|
|
282
|
+
], Math.max(1, width - 1)).map((line) => ` ${line}`);
|
|
283
|
+
const top = Math.max(0, Math.floor((rows - compact.length) / 2));
|
|
284
|
+
return finish(Array.from({ length: rows }, (_, row) => paint(compact[row - top] ?? "")));
|
|
285
|
+
}
|
|
286
|
+
const height = Math.min(22, rows - (roomy ? 2 : 0));
|
|
287
|
+
const mode = this.artMode === "ascii" || this.artMode === "text" || this.environment.TERM === "dumb" ? "ascii" : "braille";
|
|
288
|
+
this.canAnimate ||= !theme.plain && artWidth >= 20 && height >= 9;
|
|
289
|
+
const phase = theme.plain ? 0 : this.phase;
|
|
290
|
+
const key = `${artWidth}:${height}:${mode}:${phase}`;
|
|
291
|
+
if (!this.cache.has(key)) {
|
|
292
|
+
if (this.cache.size >= LOGO_MOTION_FRAMES * 2) this.cache.clear();
|
|
293
|
+
this.cache.set(key, renderLunarRecord({ columns: artWidth, rows: height, style: mode, phase }));
|
|
294
|
+
}
|
|
295
|
+
const art = this.cache.get(key);
|
|
296
|
+
if (this.colorTheme !== theme) {
|
|
297
|
+
this.colored.clear();
|
|
298
|
+
this.colorTheme = theme;
|
|
299
|
+
}
|
|
300
|
+
let colored = this.colored.get(key);
|
|
301
|
+
if (!colored) {
|
|
302
|
+
colored = art.map((ink) => {
|
|
303
|
+
let run = "";
|
|
304
|
+
let foreground = false;
|
|
305
|
+
let left = "";
|
|
306
|
+
for (const character of ink) {
|
|
307
|
+
const mask = character.codePointAt(0) - 0x2800;
|
|
308
|
+
const density = mask >= 0 && mask <= 255 ? mask.toString(2).replaceAll("0", "").length : 1;
|
|
309
|
+
const next = density >= 4;
|
|
310
|
+
if (next !== foreground && run) {
|
|
311
|
+
left += (foreground ? theme.text : theme.muted)(run);
|
|
312
|
+
run = "";
|
|
313
|
+
}
|
|
314
|
+
foreground = next;
|
|
315
|
+
run += character;
|
|
316
|
+
}
|
|
317
|
+
return left + (foreground ? theme.text : theme.muted)(run);
|
|
318
|
+
});
|
|
319
|
+
if (this.colored.size > LOGO_MOTION_FRAMES * 4) this.colored.clear();
|
|
320
|
+
this.colored.set(key, colored);
|
|
321
|
+
}
|
|
322
|
+
const count = rows;
|
|
323
|
+
const copyTop = Math.max(0, Math.floor((count - copy.length) / 2));
|
|
324
|
+
const artTop = Math.max(0, Math.floor((count - colored.length) / 2));
|
|
325
|
+
const lines = Array.from({ length: count }, (_, row) => {
|
|
326
|
+
const left = colored[row - artTop] ?? "";
|
|
327
|
+
return ` ${pad(left, artWidth)} ${copy[row - copyTop] ?? ""}`;
|
|
328
|
+
});
|
|
329
|
+
return finish(lines.map(paint));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function wrapDescription(text, width) {
|
|
334
|
+
const lines = [];
|
|
335
|
+
let line = "";
|
|
336
|
+
for (const word of text.split(" ")) {
|
|
337
|
+
if (line && visibleWidth(`${line} ${word}`) > width) { lines.push(line); line = ""; }
|
|
338
|
+
line += (line ? " " : "") + word;
|
|
339
|
+
}
|
|
340
|
+
if (line) lines.push(line);
|
|
341
|
+
return lines;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function listeningHelp() {
|
|
345
|
+
return `# Your listening room
|
|
346
|
+
|
|
347
|
+
## 01 / Listen and discover
|
|
348
|
+
|
|
349
|
+
Just type to talk about music, ask for a playlist, or go exploring.
|
|
350
|
+
To talk, Moondog needs a model: \`/auth\` signs you in, then \`/model\` picks one.
|
|
351
|
+
|
|
352
|
+
- \`/taste\` - your profile: open any song or artist to see why it's there
|
|
353
|
+
- \`/taste report\` - the whole picture on one page
|
|
354
|
+
- \`/import\` - bring in your history, your library, or a playlist
|
|
355
|
+
- \`/lyrics\` - the lyrics on the sleeve; \`/lyrics sync\` finds more
|
|
356
|
+
- \`/spotify\` - play, queue, and make playlists in Spotify
|
|
357
|
+
- \`/web search <query>\` or \`/web read <url>\` - look things up on public music sites
|
|
358
|
+
|
|
359
|
+
## 02 / Make it yours
|
|
360
|
+
|
|
361
|
+
- \`/profile\` - the same profile as \`/taste\`
|
|
362
|
+
- \`/profile corrections\` - everything you've told me
|
|
363
|
+
- \`/profile retract <id>\` - undo one of those
|
|
364
|
+
- \`/profile help\` - the full set of profile commands
|
|
365
|
+
- \`/remember <text>\`, \`/memory\`, \`/forget <id>\` - what I remember between conversations
|
|
366
|
+
|
|
367
|
+
## 03 / Around the room
|
|
368
|
+
|
|
369
|
+
- \`/home\` - back to the record sleeve; the conversation stays
|
|
370
|
+
- \`/theme paper|charcoal|terminal|auto\` - change the look
|
|
371
|
+
- \`/art braille|ascii|off\` - change the artwork
|
|
372
|
+
- \`/motion on|off\` - turn the animation on or off
|
|
373
|
+
- \`/commands\` or Ctrl+P - search every command; your message stays put
|
|
374
|
+
- Page Up and Page Down scroll the conversation. The header and your draft stay put
|
|
375
|
+
- \`/resume\` - pick up a saved conversation (type to filter, Enter opens)
|
|
376
|
+
- \`/new\` - start fresh; this conversation stays saved
|
|
377
|
+
- \`/status\`, \`/sources\`, \`/tools\`, \`/doctor\` - see what's connected and working
|
|
378
|
+
- \`/reload\` - reload model settings and sign-ins
|
|
379
|
+
- \`/help all\` - every command, including the ones for the shell
|
|
380
|
+
- \`/quit\` - leave
|
|
381
|
+
|
|
382
|
+
Tab moves to the menu on the home screen. Esc goes back to typing.
|
|
383
|
+
Enter sends, and Shift+Enter starts a new line.
|
|
384
|
+
Tab or Enter accepts a suggestion.
|
|
385
|
+
Ctrl+C stops a model or web request, or leaves when nothing is running.
|
|
386
|
+
In the profile, type to filter, Tab switches lists, and Enter shows what you can do.
|
|
387
|
+
Ctrl+R refreshes it and Ctrl+O opens the full report.
|
|
388
|
+
Your profile works without a model.
|
|
389
|
+
Put quotes around paths, titles, and names that have spaces.`;
|
|
390
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
|
|
3
|
+
const escape = "\u001b[";
|
|
4
|
+
const modes = new Set(["auto", "paper", "charcoal", "terminal"]);
|
|
5
|
+
const palettes = {
|
|
6
|
+
paper: {
|
|
7
|
+
background: "#eee7d5",
|
|
8
|
+
foreground: "#242722",
|
|
9
|
+
muted: "#606359",
|
|
10
|
+
faint: "#65685d",
|
|
11
|
+
accent: "#746044",
|
|
12
|
+
success: "#50644b",
|
|
13
|
+
warning: "#7c5d25",
|
|
14
|
+
error: "#a04136",
|
|
15
|
+
},
|
|
16
|
+
charcoal: {
|
|
17
|
+
background: "#222521",
|
|
18
|
+
foreground: "#eee7d5",
|
|
19
|
+
muted: "#a9ab9c",
|
|
20
|
+
faint: "#96988d",
|
|
21
|
+
accent: "#ccb995",
|
|
22
|
+
success: "#a8b497",
|
|
23
|
+
warning: "#d0ad73",
|
|
24
|
+
error: "#d39b8c",
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
const ansi16 = [
|
|
28
|
+
[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
|
|
29
|
+
[0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192],
|
|
30
|
+
[128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0],
|
|
31
|
+
[0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function rgb(hex) {
|
|
35
|
+
return [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function xtermRgb(index) {
|
|
39
|
+
if (index < 16) return ansi16[index];
|
|
40
|
+
if (index >= 232) return [1, 1, 1].map(() => 8 + (index - 232) * 10);
|
|
41
|
+
const cube = index - 16;
|
|
42
|
+
return [Math.floor(cube / 36), Math.floor(cube / 6) % 6, cube % 6]
|
|
43
|
+
.map((value) => value === 0 ? 0 : 55 + value * 40);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function nearestXterm(hex) {
|
|
47
|
+
const target = rgb(hex);
|
|
48
|
+
let nearest = 16;
|
|
49
|
+
let distance = Infinity;
|
|
50
|
+
// The first sixteen colors are user-configurable; the remaining palette is fixed.
|
|
51
|
+
for (let index = 16; index < 256; index += 1) {
|
|
52
|
+
const difference = xtermRgb(index).reduce(
|
|
53
|
+
(sum, value, channel) => sum + (value - target[channel]) ** 2,
|
|
54
|
+
0,
|
|
55
|
+
);
|
|
56
|
+
if (difference < distance) {
|
|
57
|
+
distance = difference;
|
|
58
|
+
nearest = index;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return nearest;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function detectedMode(environment) {
|
|
65
|
+
const hint = String(environment.COLORFGBG ?? "").split(";").at(-1);
|
|
66
|
+
if (!/^\d+$/.test(hint)) return "charcoal";
|
|
67
|
+
const index = Number(hint);
|
|
68
|
+
if (index > 255) return "charcoal";
|
|
69
|
+
const luminance = xtermRgb(index).reduce((sum, value, channel) => {
|
|
70
|
+
const normalized = value / 255;
|
|
71
|
+
const linear = normalized <= 0.04045
|
|
72
|
+
? normalized / 12.92
|
|
73
|
+
: ((normalized + 0.055) / 1.055) ** 2.4;
|
|
74
|
+
return sum + linear * [0.2126, 0.7152, 0.0722][channel];
|
|
75
|
+
}, 0);
|
|
76
|
+
return luminance > 0.4 ? "paper" : "charcoal";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function colorDepth(environment) {
|
|
80
|
+
if (Object.hasOwn(environment, "NO_COLOR") || environment.TERM === "dumb") return 0;
|
|
81
|
+
if (/^(truecolor|24bit)$/i.test(environment.COLORTERM ?? "")) return 24;
|
|
82
|
+
if (/^(iTerm\.app|WezTerm|ghostty|vscode|Hyper|Windows_Terminal)$/i.test(environment.TERM_PROGRAM ?? "")) return 24;
|
|
83
|
+
if (/(direct|truecolor|kitty|ghostty)/i.test(environment.TERM ?? "")) return 24;
|
|
84
|
+
if (/256color/i.test(environment.TERM ?? "") || environment.TERM_PROGRAM === "Apple_Terminal") return 8;
|
|
85
|
+
return 4;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Treat extended color parameters as one command so an RGB zero is never a reset.
|
|
89
|
+
function sgrCommands(parameters) {
|
|
90
|
+
const values = parameters.split(";");
|
|
91
|
+
const commands = [];
|
|
92
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
93
|
+
const first = values[index];
|
|
94
|
+
const code = Number(first.split(":", 1)[0]);
|
|
95
|
+
let length = 1;
|
|
96
|
+
if (!first.includes(":") && [38, 48, 58].includes(code)) {
|
|
97
|
+
if (values[index + 1] === "5") length = 3;
|
|
98
|
+
if (values[index + 1] === "2") length = 5;
|
|
99
|
+
}
|
|
100
|
+
commands.push({ code, parameters: values.slice(index, index + length).join(";") });
|
|
101
|
+
index += length - 1;
|
|
102
|
+
}
|
|
103
|
+
return commands;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function style(openParameters, closeCode) {
|
|
107
|
+
const open = `${escape}${openParameters}m`;
|
|
108
|
+
const close = `${escape}${closeCode}m`;
|
|
109
|
+
return (value) => {
|
|
110
|
+
const content = String(value ?? "").replace(/\u001b\[([\d;:]*)m/g, (sequence, parameters) => {
|
|
111
|
+
const commands = sgrCommands(parameters);
|
|
112
|
+
if (!commands.some(({ code }) => code === 0 || code === closeCode)) return sequence;
|
|
113
|
+
// Reopen immediately after a reset, preserving later explicit nested styles.
|
|
114
|
+
return commands.map(({ code, parameters: command }) =>
|
|
115
|
+
`${escape}${command}m${code === 0 || code === closeCode ? open : ""}`,
|
|
116
|
+
).join("");
|
|
117
|
+
});
|
|
118
|
+
return open + content + close;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The lunar-record identity, scoped to rendered text rather than terminal settings.
|
|
124
|
+
* Explicit modes override MOONDOG_THEME; auto uses the environment then COLORFGBG.
|
|
125
|
+
* Terminal mode leaves the user's background and base foreground intact.
|
|
126
|
+
*/
|
|
127
|
+
export function createMoondogTheme({ mode = "auto", environment = process.env } = {}) {
|
|
128
|
+
const env = environment ?? {};
|
|
129
|
+
const option = String(mode).trim().toLowerCase();
|
|
130
|
+
const preference = String(env.MOONDOG_THEME ?? "").trim().toLowerCase();
|
|
131
|
+
let resolvedMode = modes.has(option) ? option : "auto";
|
|
132
|
+
if (resolvedMode === "auto" && modes.has(preference)) resolvedMode = preference;
|
|
133
|
+
if (resolvedMode === "auto") resolvedMode = detectedMode(env);
|
|
134
|
+
const depth = colorDepth(env);
|
|
135
|
+
const terminal = resolvedMode === "terminal";
|
|
136
|
+
const palette = Object.freeze(terminal
|
|
137
|
+
? { ...palettes[detectedMode(env)], background: null, foreground: null }
|
|
138
|
+
: { ...palettes[resolvedMode] });
|
|
139
|
+
const plain = (value) => String(value ?? "");
|
|
140
|
+
const unstyled = (value) => stripVTControlCharacters(plain(value));
|
|
141
|
+
const decoration = (open, close) => depth === 0 ? unstyled : style(open, close);
|
|
142
|
+
const bold = decoration(1, 22);
|
|
143
|
+
const italic = decoration(3, 23);
|
|
144
|
+
const underline = decoration(4, 24);
|
|
145
|
+
const inverse = decoration(7, 27);
|
|
146
|
+
const strikethrough = decoration(9, 29);
|
|
147
|
+
const light = resolvedMode === "paper";
|
|
148
|
+
const fallback = {
|
|
149
|
+
foreground: light ? 30 : 37,
|
|
150
|
+
muted: light ? 30 : 37,
|
|
151
|
+
faint: light ? 30 : 90,
|
|
152
|
+
accent: light ? 30 : 33,
|
|
153
|
+
success: light ? 32 : 92,
|
|
154
|
+
warning: light ? 30 : 33,
|
|
155
|
+
error: light ? 31 : 91,
|
|
156
|
+
};
|
|
157
|
+
const foreground = (name) => {
|
|
158
|
+
if (depth === 0) return unstyled;
|
|
159
|
+
if (terminal) {
|
|
160
|
+
if (name === "accent") return bold;
|
|
161
|
+
if (name === "success") return style(32, 39);
|
|
162
|
+
if (name === "warning") return style(33, 39);
|
|
163
|
+
if (name === "error") return style(31, 39);
|
|
164
|
+
return plain;
|
|
165
|
+
}
|
|
166
|
+
if (depth === 24) return style(`38;2;${rgb(palette[name]).join(";")}`, 39);
|
|
167
|
+
if (depth === 8) return style(`38;5;${nearestXterm(palette[name])}`, 39);
|
|
168
|
+
return style(fallback[name], 39);
|
|
169
|
+
};
|
|
170
|
+
const text = foreground("foreground");
|
|
171
|
+
const muted = foreground("muted");
|
|
172
|
+
const faint = foreground("faint");
|
|
173
|
+
const accent = foreground("accent");
|
|
174
|
+
const success = foreground("success");
|
|
175
|
+
const warning = foreground("warning");
|
|
176
|
+
const error = foreground("error");
|
|
177
|
+
let background = plain;
|
|
178
|
+
if (depth === 0) background = unstyled;
|
|
179
|
+
else if (!terminal) {
|
|
180
|
+
const code = depth === 24
|
|
181
|
+
? `48;2;${rgb(palette.background).join(";")}`
|
|
182
|
+
: depth === 8 ? `48;5;${nearestXterm(palette.background)}` : light ? 107 : 40;
|
|
183
|
+
background = style(code, 49);
|
|
184
|
+
}
|
|
185
|
+
const selected = (value) => inverse(bold(text(value)));
|
|
186
|
+
const selectListTheme = {
|
|
187
|
+
selectedPrefix: selected,
|
|
188
|
+
selectedText: selected,
|
|
189
|
+
description: muted,
|
|
190
|
+
scrollInfo: muted,
|
|
191
|
+
noMatch: muted,
|
|
192
|
+
};
|
|
193
|
+
const editorTheme = { borderColor: faint, selectList: selectListTheme };
|
|
194
|
+
const markdownTheme = {
|
|
195
|
+
heading: (value) => bold(text(value)),
|
|
196
|
+
link: (value) => underline(text(value)),
|
|
197
|
+
linkUrl: muted,
|
|
198
|
+
code: text,
|
|
199
|
+
codeBlock: text,
|
|
200
|
+
codeBlockBorder: faint,
|
|
201
|
+
quote: muted,
|
|
202
|
+
quoteBorder: faint,
|
|
203
|
+
hr: faint,
|
|
204
|
+
listBullet: muted,
|
|
205
|
+
bold,
|
|
206
|
+
italic,
|
|
207
|
+
strikethrough,
|
|
208
|
+
underline,
|
|
209
|
+
};
|
|
210
|
+
return {
|
|
211
|
+
mode: resolvedMode,
|
|
212
|
+
plain: depth === 0,
|
|
213
|
+
palette,
|
|
214
|
+
text,
|
|
215
|
+
muted,
|
|
216
|
+
faint,
|
|
217
|
+
accent,
|
|
218
|
+
success,
|
|
219
|
+
warning,
|
|
220
|
+
error,
|
|
221
|
+
bold,
|
|
222
|
+
italic,
|
|
223
|
+
underline,
|
|
224
|
+
inverse,
|
|
225
|
+
background,
|
|
226
|
+
editorTheme,
|
|
227
|
+
selectListTheme,
|
|
228
|
+
markdownTheme,
|
|
229
|
+
defaultTextStyle: { color: text, bgColor: background },
|
|
230
|
+
};
|
|
231
|
+
}
|