@letta-ai/letta-code 0.28.15 → 0.28.17
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/LICENSE +5 -5
- package/README.md +12 -11
- package/assets/tutor-profile.png +0 -0
- package/dist/agent-presets.js +165 -27
- package/dist/agent-presets.js.map +6 -6
- package/dist/types/agent/create-agent-request.d.ts.map +1 -1
- package/dist/types/agent/model-catalog.d.ts +22 -320
- package/dist/types/agent/model-catalog.d.ts.map +1 -1
- package/dist/types/agent/personality-presets.d.ts +16 -2
- package/dist/types/agent/personality-presets.d.ts.map +1 -1
- package/dist/types/agent/prompt-assets.d.ts.map +1 -1
- package/dist/types/agent-presets.d.ts +1 -1
- package/dist/types/agent-presets.d.ts.map +1 -1
- package/image-resize-worker.js +6586 -0
- package/letta.js +7826 -11977
- package/package.json +4 -1
- package/scripts/source-file-size-baseline.json +11 -11
- package/skills/creating-mods/SKILL.md +1 -1
- package/skills/creating-mods/references/architecture.md +1 -1
- package/skills/creating-mods/references/commands.md +1 -1
- package/skills/creating-mods/references/events.md +1 -1
- package/skills/creating-mods/references/providers.md +1 -1
- package/skills/letta-guide/SKILL.md +37 -18
- package/skills/letta-guide/scripts/fetch-letta-docs.mjs +440 -0
- package/skills/self-configuration/SKILL.md +19 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@letta-ai/letta-code",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.17",
|
|
4
4
|
"description": "Letta Code is a CLI tool for interacting with stateful Letta agents from the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1.3.0",
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
"LICENSE",
|
|
12
12
|
"README.md",
|
|
13
13
|
"letta.js",
|
|
14
|
+
"image-resize-worker.js",
|
|
15
|
+
"assets/tutor-profile.png",
|
|
14
16
|
"scripts",
|
|
15
17
|
"skills",
|
|
16
18
|
"vendor",
|
|
@@ -109,6 +111,7 @@
|
|
|
109
111
|
"lint-staged": "16.2.4",
|
|
110
112
|
"madge": "^8.0.0",
|
|
111
113
|
"minimatch": "^10.0.3",
|
|
114
|
+
"openai": "^6.48.0",
|
|
112
115
|
"picomatch": "^2.3.1",
|
|
113
116
|
"typescript": "^5.0.0"
|
|
114
117
|
},
|
|
@@ -5,16 +5,16 @@
|
|
|
5
5
|
"src/backend/local/local-backend.ts": 1058,
|
|
6
6
|
"src/backend/local/local-store.ts": 3594,
|
|
7
7
|
"src/backend/pi-stream-adapter.test.ts": 1442,
|
|
8
|
-
"src/cli/app/AppCoordinator.tsx":
|
|
8
|
+
"src/cli/app/AppCoordinator.tsx": 5204,
|
|
9
9
|
"src/cli/app/AppView.tsx": 1750,
|
|
10
10
|
"src/cli/app/use-approval-flow.ts": 1163,
|
|
11
11
|
"src/cli/app/use-configuration-handlers.ts": 1392,
|
|
12
|
-
"src/cli/app/use-conversation-loop.ts":
|
|
12
|
+
"src/cli/app/use-conversation-loop.ts": 2936,
|
|
13
13
|
"src/cli/app/use-submit-handler.ts": 4099,
|
|
14
|
-
"src/cli/components/AgentSelector.tsx":
|
|
14
|
+
"src/cli/components/AgentSelector.tsx": 1111,
|
|
15
15
|
"src/cli/components/InputRich.tsx": 2219,
|
|
16
|
-
"src/cli/components/ModelSelector.tsx":
|
|
17
|
-
"src/cli/components/ProviderSelector.tsx":
|
|
16
|
+
"src/cli/components/ModelSelector.tsx": 1213,
|
|
17
|
+
"src/cli/components/ProviderSelector.tsx": 1682,
|
|
18
18
|
"src/cli/components/ToolCallMessageRich.tsx": 1109,
|
|
19
19
|
"src/cli/helpers/accumulator.ts": 1596,
|
|
20
20
|
"src/cli/helpers/reflection-transcript.ts": 2080,
|
|
@@ -26,14 +26,14 @@
|
|
|
26
26
|
"src/hooks/integration.test.ts": 1147,
|
|
27
27
|
"src/index.ts": 2803,
|
|
28
28
|
"src/mods/learning-harness.ts": 2434,
|
|
29
|
-
"src/mods/mod-engine.test.ts":
|
|
30
|
-
"src/mods/mod-engine.ts":
|
|
29
|
+
"src/mods/mod-engine.test.ts": 2132,
|
|
30
|
+
"src/mods/mod-engine.ts": 1838,
|
|
31
31
|
"src/mods/package-installer.test.ts": 1248,
|
|
32
32
|
"src/mods/package-installer.ts": 1201,
|
|
33
33
|
"src/mods/package-registry.ts": 1033,
|
|
34
34
|
"src/permissions/read-only-shell.test.ts": 1303,
|
|
35
35
|
"src/permissions/read-only-shell.ts": 2009,
|
|
36
|
-
"src/providers/chatgpt-usage-service.ts":
|
|
36
|
+
"src/providers/chatgpt-usage-service.ts": 1112,
|
|
37
37
|
"src/settings-manager.test.ts": 1669,
|
|
38
38
|
"src/settings-manager.ts": 2113,
|
|
39
39
|
"src/tools/impl/enter-worktree.ts": 1260,
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
"src/tools/manager.ts": 3282,
|
|
42
42
|
"src/tools/tool-execution-context.test.ts": 1422,
|
|
43
43
|
"src/types/protocol_v2.ts": 2943,
|
|
44
|
-
"src/websocket/listen-client-concurrency.test.ts":
|
|
44
|
+
"src/websocket/listen-client-concurrency.test.ts": 3017,
|
|
45
45
|
"src/websocket/listen-client-protocol.test.ts": 6723,
|
|
46
46
|
"src/websocket/listener/commands/channels.ts": 1390,
|
|
47
47
|
"src/websocket/listener/commands/memory.ts": 1114,
|
|
48
48
|
"src/websocket/listener/file-commands.ts": 1030,
|
|
49
|
-
"src/websocket/listener/lifecycle.ts":
|
|
49
|
+
"src/websocket/listener/lifecycle.ts": 1762,
|
|
50
50
|
"src/websocket/listener/protocol-inbound.ts": 2279,
|
|
51
|
-
"src/websocket/listener/protocol-outbound.ts":
|
|
51
|
+
"src/websocket/listener/protocol-outbound.ts": 1278
|
|
52
52
|
}
|
|
@@ -118,7 +118,7 @@ Agents can inspect local mod diagnostics at:
|
|
|
118
118
|
## Rules
|
|
119
119
|
|
|
120
120
|
- Do not create project mods.
|
|
121
|
-
- Custom provider mods are local-backend/local-agent only. They do not add providers for
|
|
121
|
+
- Custom provider mods are local-backend/local-agent only. They do not add providers for agents managed through the Letta API.
|
|
122
122
|
- Provider mods may run in a provider-only listener context; keep provider registration independent from commands/tools/UI and guard everything else.
|
|
123
123
|
- Direct mod files should not assume third-party npm packages are available. Use Node/Bun built-ins unless packaging is explicitly requested.
|
|
124
124
|
- Do not do surprising side effects on startup; mods activate on app start and `/reload`.
|
|
@@ -139,7 +139,7 @@ ctx.conversation.updateLlmConfig(opts) // change model / reasoning effort / cont
|
|
|
139
139
|
|
|
140
140
|
A forked handle keeps the same agent/backend defaults and targets the forked conversation. Use forked handles for background model work. Use `getHistory({ limit, order, includeErrors })` when local logic needs conversation context.
|
|
141
141
|
|
|
142
|
-
`updateLlmConfig({ model?, reasoningEffort?, contextWindow?, scope? })` changes the model, reasoning effort, and/or context window, and works across local and
|
|
142
|
+
`updateLlmConfig({ model?, reasoningEffort?, contextWindow?, scope? })` changes the model, reasoning effort, and/or context window, and works across local and Letta Cloud backends. Only the fields you pass change; the rest are preserved, so `updateLlmConfig({ contextWindow })` adjusts just the context window without touching the model or reasoning effort. `scope` defaults to `"conversation"` (a conversation-scoped override that leaves the agent's default untouched); pass `scope: "agent"` to change the agent default. Changing reasoning effort without a model resolves the current model to rebuild provider-specific settings. The change takes effect on the next turn (the model is resolved per provider request).
|
|
143
143
|
|
|
144
144
|
Tools currently receive `ctx.conversation.getHistory()` but not fork/send helpers. If a tool needs model-side follow-up, return information for the model to act on instead of starting a hidden run from the tool.
|
|
145
145
|
|
|
@@ -112,7 +112,7 @@ export default function activate(letta) {
|
|
|
112
112
|
|
|
113
113
|
For commands with `runWhenBusy: true`, do not return `prompt` while the agent is running. Use the scoped conversation handle directly, update a panel/status if available, and return `{ type: "handled" }` quickly.
|
|
114
114
|
|
|
115
|
-
Use `ctx.conversation` for conversation operations that should work across local and
|
|
115
|
+
Use `ctx.conversation` for conversation operations that should work across local and Letta Cloud backends. The handle is bound to the active conversation and backend for that command invocation, so composed flows like fork-then-send stay on the same backend. Use `letta.client` only for server-specific API calls.
|
|
116
116
|
|
|
117
117
|
Common pattern:
|
|
118
118
|
|
|
@@ -61,7 +61,7 @@ Lifecycle, turn, tool, compaction, and llm events are wired today.
|
|
|
61
61
|
|
|
62
62
|
Lifecycle handlers are notification-only and should not return values. `turn_start` handlers can transform or cancel outbound user-message turns. `tool_start` handlers can transform the tool arguments before execution. Compaction and llm handlers are notification-only.
|
|
63
63
|
|
|
64
|
-
`compact_start`/`compact_end` and `llm_start`/`llm_end` only fire on the **local backend**, where compaction and provider requests run client-side. On the
|
|
64
|
+
`compact_start`/`compact_end` and `llm_start`/`llm_end` only fire on the **local backend**, where compaction and provider requests run client-side. On the Letta Cloud backend that work happens server-side and these events do not fire, so guard with `letta.capabilities.events.compact` / `letta.capabilities.events.llm` for portable mods.
|
|
65
65
|
|
|
66
66
|
## Supported events
|
|
67
67
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Use provider mods when the user wants a **local agent** to use a model provider that is not built into `/connect` and `/model`.
|
|
4
4
|
|
|
5
|
-
Important: provider mods are local-backend/local-agent only. They register local provider metadata for the TUI, headless local runtime, and desktop listener. They do not add providers for
|
|
5
|
+
Important: provider mods are local-backend/local-agent only. They register local provider metadata for the TUI, headless local runtime, and desktop listener. They do not add providers for agents managed through the Letta API.
|
|
6
6
|
|
|
7
7
|
For multi-capability mods that combine a provider with commands, tools, UI, or state, also read `architecture.md`.
|
|
8
8
|
|
|
@@ -14,19 +14,38 @@ how to answer questions about Letta correctly.
|
|
|
14
14
|
|
|
15
15
|
1. **Self-inspection first for questions about THIS agent.** "What model are
|
|
16
16
|
you using?", "what tools do you have?", "what's in your memory?" are
|
|
17
|
-
questions about the running session, not the docs.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
17
|
+
questions about the running session, not the docs. Load the
|
|
18
|
+
`self-configuration` skill for model or settings questions and use its
|
|
19
|
+
backend-aware active configuration report. Use the system prompt, agent
|
|
20
|
+
info, tool schemas, and MemFS for the other live facts. Do not infer active
|
|
21
|
+
state from recent/default preference lists, and do not fetch docs for these.
|
|
22
|
+
2. **Fetch the docs index directly.** For product questions, run:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
node <SKILL_DIR>/scripts/fetch-letta-docs.mjs
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The helper retrieves `https://docs.letta.com/llms.txt` from the docs host,
|
|
29
|
+
verifies its ETag against the body, and prints the paths to a current local
|
|
30
|
+
copy and heading outline. Read the outline, then read the relevant index
|
|
31
|
+
lines to pick the best page URL.
|
|
32
|
+
3. **Fetch the specific page directly.** Pass the exact canonical URL from the
|
|
33
|
+
index back to the same helper, for example:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
node <SKILL_DIR>/scripts/fetch-letta-docs.mjs \
|
|
37
|
+
--docs-url "https://docs.letta.com/configuration/models/index.md"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Read the returned docs path before running the helper for another URL. The
|
|
41
|
+
helper uses native HTTPS with a curl fallback; do not use `fetch_webpage`
|
|
42
|
+
for the normal docs route because its upstream content cache may be stale.
|
|
43
|
+
Cite the public doc URL so the user can go deeper.
|
|
44
|
+
4. **If direct retrieval fails**, use `fetch_webpage` only as a fallback with a
|
|
45
|
+
fresh query parameter on the same official docs URL. Disclose that the
|
|
46
|
+
fallback may be stale. If that also fails, say the docs are unreachable,
|
|
47
|
+
give your best answer, and clearly mark it as possibly out of date with a
|
|
48
|
+
link to https://docs.letta.com. Never silently fall back to memory.
|
|
30
49
|
|
|
31
50
|
## Hard rules
|
|
32
51
|
|
|
@@ -66,9 +85,9 @@ rather than leaving the user stuck.
|
|
|
66
85
|
|
|
67
86
|
## Caching
|
|
68
87
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
88
|
+
The helper owns the cache. It uses the first writable temporary directory from
|
|
89
|
+
`TMPDIR`, `TEMP`, `TMP`, `/private/tmp`, or `/tmp`, and accepts `--cache-dir`
|
|
90
|
+
when an explicit location is needed. Every invocation checks the live ETag and
|
|
91
|
+
reuses the local document only when its body hash still matches. Do not create
|
|
92
|
+
or manage a second cache yourself.
|
|
74
93
|
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { constants as fsConstants } from "node:fs";
|
|
6
|
+
import {
|
|
7
|
+
access,
|
|
8
|
+
mkdir,
|
|
9
|
+
readFile,
|
|
10
|
+
rename,
|
|
11
|
+
rm,
|
|
12
|
+
stat,
|
|
13
|
+
writeFile,
|
|
14
|
+
} from "node:fs/promises";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import process from "node:process";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
import { promisify } from "node:util";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_DOCS_URL = "https://docs.letta.com/llms.txt";
|
|
21
|
+
const CACHE_DIRECTORY_NAME = "letta-docs-cache";
|
|
22
|
+
const DOCUMENT_NAME = "letta-docs.md";
|
|
23
|
+
const OUTLINE_NAME = "letta-docs.outline.md";
|
|
24
|
+
const USER_AGENT = "letta-guide";
|
|
25
|
+
const runFile = promisify(execFile);
|
|
26
|
+
|
|
27
|
+
class DocsFetchError extends Error {
|
|
28
|
+
constructor(message, cause) {
|
|
29
|
+
super(message, cause ? { cause } : undefined);
|
|
30
|
+
this.name = "DocsFetchError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function bodyDigest(body) {
|
|
35
|
+
return createHash("md5").update(body).digest("hex");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hasProxyEnvironment() {
|
|
39
|
+
return ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"].some(
|
|
40
|
+
(key) => Boolean(process.env[key]),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function temporaryFile(directory, extension) {
|
|
45
|
+
return path.join(
|
|
46
|
+
directory,
|
|
47
|
+
`.letta-docs-${process.pid}-${Date.now()}-${randomBytes(5).toString("hex")}${extension}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseHeaderDump(raw) {
|
|
52
|
+
const responseBlocks = raw
|
|
53
|
+
.replace(/\r\n/g, "\n")
|
|
54
|
+
.trim()
|
|
55
|
+
.split(/\n\n+/)
|
|
56
|
+
.filter((block) => block.startsWith("HTTP/"));
|
|
57
|
+
const finalBlock = responseBlocks.at(-1);
|
|
58
|
+
if (!finalBlock) {
|
|
59
|
+
throw new DocsFetchError("curl returned no HTTP response headers.");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const [statusLine, ...lines] = finalBlock.split("\n");
|
|
63
|
+
const status = Number(/^HTTP\/\S+\s+(\d{3})/.exec(statusLine)?.[1]);
|
|
64
|
+
if (!Number.isInteger(status)) {
|
|
65
|
+
throw new DocsFetchError(
|
|
66
|
+
`curl returned an invalid status line: ${statusLine}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const headers = new Map();
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
const separator = line.indexOf(":");
|
|
73
|
+
if (separator < 1) continue;
|
|
74
|
+
headers.set(
|
|
75
|
+
line.slice(0, separator).trim().toLowerCase(),
|
|
76
|
+
line.slice(separator + 1).trim(),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return { headers, status };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function curlRequest(url, method, cacheDirectory, timeoutMs) {
|
|
83
|
+
const headersFile = temporaryFile(cacheDirectory, ".headers");
|
|
84
|
+
const bodyFile = temporaryFile(cacheDirectory, ".body");
|
|
85
|
+
const executables =
|
|
86
|
+
process.platform === "win32" ? ["curl.exe", "curl"] : ["curl"];
|
|
87
|
+
const argumentsList = [
|
|
88
|
+
"--silent",
|
|
89
|
+
"--show-error",
|
|
90
|
+
"--location",
|
|
91
|
+
"--dump-header",
|
|
92
|
+
headersFile,
|
|
93
|
+
"--output",
|
|
94
|
+
bodyFile,
|
|
95
|
+
"--user-agent",
|
|
96
|
+
USER_AGENT,
|
|
97
|
+
"--max-time",
|
|
98
|
+
String(Math.max(1, Math.ceil(timeoutMs / 1000))),
|
|
99
|
+
...(method === "HEAD" ? ["--head"] : ["--request", method]),
|
|
100
|
+
url,
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
let failure;
|
|
104
|
+
for (const executable of executables) {
|
|
105
|
+
try {
|
|
106
|
+
await runFile(executable, argumentsList, { windowsHide: true });
|
|
107
|
+
const [rawHeaders, body] = await Promise.all([
|
|
108
|
+
readFile(headersFile, "utf8"),
|
|
109
|
+
readFile(bodyFile, "utf8"),
|
|
110
|
+
]);
|
|
111
|
+
return { ...parseHeaderDump(rawHeaders), body };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
failure = error;
|
|
114
|
+
if (error?.code !== "ENOENT") break;
|
|
115
|
+
} finally {
|
|
116
|
+
await Promise.all([
|
|
117
|
+
rm(headersFile, { force: true }),
|
|
118
|
+
rm(bodyFile, { force: true }),
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
throw new DocsFetchError(
|
|
123
|
+
failure?.code === "ENOENT"
|
|
124
|
+
? "curl is unavailable in this environment."
|
|
125
|
+
: `curl could not ${method} ${url}.`,
|
|
126
|
+
failure,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function nodeRequest(url, method, _cacheDirectory, timeoutMs) {
|
|
131
|
+
if (typeof fetch !== "function") {
|
|
132
|
+
throw new DocsFetchError("Native fetch requires Node.js 18 or newer.");
|
|
133
|
+
}
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
136
|
+
try {
|
|
137
|
+
const response = await fetch(url, {
|
|
138
|
+
method,
|
|
139
|
+
headers: { "User-Agent": USER_AGENT },
|
|
140
|
+
redirect: "follow",
|
|
141
|
+
signal: controller.signal,
|
|
142
|
+
});
|
|
143
|
+
const headers = new Map();
|
|
144
|
+
response.headers.forEach((value, key) => {
|
|
145
|
+
headers.set(key.toLowerCase(), value);
|
|
146
|
+
});
|
|
147
|
+
return {
|
|
148
|
+
body: method === "HEAD" ? "" : await response.text(),
|
|
149
|
+
headers,
|
|
150
|
+
status: response.status,
|
|
151
|
+
};
|
|
152
|
+
} finally {
|
|
153
|
+
clearTimeout(timeout);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function requestDocument(url, method, cacheDirectory, timeoutMs) {
|
|
158
|
+
const transports = hasProxyEnvironment()
|
|
159
|
+
? [curlRequest, nodeRequest]
|
|
160
|
+
: [nodeRequest, curlRequest];
|
|
161
|
+
let failure;
|
|
162
|
+
for (const transport of transports) {
|
|
163
|
+
try {
|
|
164
|
+
const result = await transport(url, method, cacheDirectory, timeoutMs);
|
|
165
|
+
if (result.status < 200 || result.status >= 300) {
|
|
166
|
+
throw new DocsFetchError(
|
|
167
|
+
`${method} ${url} failed with HTTP ${result.status}.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
failure = error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
throw new DocsFetchError(`${method} ${url} could not be fetched.`, failure);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function digestFromEtag(headers) {
|
|
179
|
+
const etag = headers.get("etag") ?? "";
|
|
180
|
+
const digest = /^(?:W\/)?"([a-f0-9]{32})"$/i.exec(etag)?.[1];
|
|
181
|
+
if (!digest) {
|
|
182
|
+
throw new DocsFetchError(
|
|
183
|
+
"Letta docs response is missing a content-MD5 ETag.",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return digest.toLowerCase();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function nearestExistingDirectory(candidate) {
|
|
190
|
+
let current = path.resolve(candidate);
|
|
191
|
+
while (true) {
|
|
192
|
+
try {
|
|
193
|
+
return (await stat(current)).isDirectory() ? current : null;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error?.code !== "ENOENT") return null;
|
|
196
|
+
}
|
|
197
|
+
const parent = path.dirname(current);
|
|
198
|
+
if (parent === current) return null;
|
|
199
|
+
current = parent;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function writableCacheDirectory(candidate) {
|
|
204
|
+
if (!candidate) return null;
|
|
205
|
+
const resolved = path.resolve(candidate);
|
|
206
|
+
try {
|
|
207
|
+
const existing = await stat(resolved);
|
|
208
|
+
if (!existing.isDirectory()) return null;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error?.code !== "ENOENT") return null;
|
|
211
|
+
}
|
|
212
|
+
const existingParent = await nearestExistingDirectory(resolved);
|
|
213
|
+
if (!existingParent) return null;
|
|
214
|
+
try {
|
|
215
|
+
await access(existingParent, fsConstants.W_OK | fsConstants.X_OK);
|
|
216
|
+
return resolved;
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function selectCacheDirectory(override) {
|
|
223
|
+
if (override) return writableCacheDirectory(override);
|
|
224
|
+
const candidates = [process.env.TMPDIR, process.env.TEMP, process.env.TMP]
|
|
225
|
+
.filter(Boolean)
|
|
226
|
+
.map((directory) => path.join(directory, CACHE_DIRECTORY_NAME));
|
|
227
|
+
if (process.platform !== "win32") {
|
|
228
|
+
candidates.push(
|
|
229
|
+
path.join("/private/tmp", CACHE_DIRECTORY_NAME),
|
|
230
|
+
path.join("/tmp", CACHE_DIRECTORY_NAME),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
for (const candidate of new Set(candidates)) {
|
|
234
|
+
const usable = await writableCacheDirectory(candidate);
|
|
235
|
+
if (usable) return usable;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function atomicWrite(destination, contents) {
|
|
241
|
+
const temporary = temporaryFile(
|
|
242
|
+
path.dirname(destination),
|
|
243
|
+
`.${path.basename(destination)}.tmp`,
|
|
244
|
+
);
|
|
245
|
+
await writeFile(temporary, contents, "utf8");
|
|
246
|
+
await rename(temporary, destination);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function createOutline(markdown) {
|
|
250
|
+
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
|
251
|
+
if (lines.at(-1) === "") lines.pop();
|
|
252
|
+
const entries = [];
|
|
253
|
+
let fenced = false;
|
|
254
|
+
lines.forEach((line, index) => {
|
|
255
|
+
if (/^\s*(?:```|~~~)/.test(line)) {
|
|
256
|
+
fenced = !fenced;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (fenced) return;
|
|
260
|
+
const heading = /^(#{2,3})\s+(.+?)\s*$/.exec(line);
|
|
261
|
+
if (!heading) return;
|
|
262
|
+
entries.push({
|
|
263
|
+
level: heading[1].length,
|
|
264
|
+
title: heading[2]
|
|
265
|
+
.replace(/\s+#+\s*$/, "")
|
|
266
|
+
.replace(/\s+/g, " ")
|
|
267
|
+
.trim(),
|
|
268
|
+
start: index + 1,
|
|
269
|
+
end: lines.length,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
entries.forEach((entry, index) => {
|
|
273
|
+
const nextPeer = entries
|
|
274
|
+
.slice(index + 1)
|
|
275
|
+
.find((candidate) => candidate.level <= entry.level);
|
|
276
|
+
if (nextPeer) entry.end = nextPeer.start - 1;
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const lowestLevel = entries.length
|
|
280
|
+
? Math.min(...entries.map((entry) => entry.level))
|
|
281
|
+
: 2;
|
|
282
|
+
const text = entries.length
|
|
283
|
+
? entries
|
|
284
|
+
.map(
|
|
285
|
+
(entry) =>
|
|
286
|
+
`${" ".repeat(entry.level - lowestLevel)}- ${entry.title} (lines ${entry.start}-${entry.end})`,
|
|
287
|
+
)
|
|
288
|
+
.join("\n")
|
|
289
|
+
: "No markdown headings found.";
|
|
290
|
+
return {
|
|
291
|
+
headingCount: entries.length,
|
|
292
|
+
lineCount: lines.length,
|
|
293
|
+
markdown: `# Letta Docs Outline\n\n${text}\n`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function cachedBody(documentPath, expectedDigest) {
|
|
298
|
+
try {
|
|
299
|
+
const body = await readFile(documentPath, "utf8");
|
|
300
|
+
return bodyDigest(body) === expectedDigest ? body : null;
|
|
301
|
+
} catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function fetchLettaDocs({
|
|
307
|
+
docsUrl = DEFAULT_DOCS_URL,
|
|
308
|
+
cacheDir,
|
|
309
|
+
timeoutMs = 30000,
|
|
310
|
+
} = {}) {
|
|
311
|
+
const selectedCache = await selectCacheDirectory(cacheDir);
|
|
312
|
+
if (!selectedCache) {
|
|
313
|
+
throw new DocsFetchError(
|
|
314
|
+
"No writable docs cache is available; pass --cache-dir to override.",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
await mkdir(selectedCache, { recursive: true });
|
|
318
|
+
|
|
319
|
+
const documentPath = path.join(selectedCache, DOCUMENT_NAME);
|
|
320
|
+
const outlinePath = path.join(selectedCache, OUTLINE_NAME);
|
|
321
|
+
const head = await requestDocument(docsUrl, "HEAD", selectedCache, timeoutMs);
|
|
322
|
+
const expectedDigest = digestFromEtag(head.headers);
|
|
323
|
+
let body = await cachedBody(documentPath, expectedDigest);
|
|
324
|
+
const cacheStatus = body === null ? "updated" : "hit";
|
|
325
|
+
|
|
326
|
+
if (body === null) {
|
|
327
|
+
const get = await requestDocument(docsUrl, "GET", selectedCache, timeoutMs);
|
|
328
|
+
const getDigest = digestFromEtag(get.headers);
|
|
329
|
+
if (getDigest !== expectedDigest) {
|
|
330
|
+
throw new DocsFetchError(
|
|
331
|
+
`ETag changed between HEAD and GET for ${docsUrl}.`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
if (bodyDigest(get.body) !== expectedDigest) {
|
|
335
|
+
throw new DocsFetchError(
|
|
336
|
+
`ETag did not match the fetched body for ${docsUrl}.`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
body = get.body;
|
|
340
|
+
await atomicWrite(documentPath, body);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const outline = createOutline(body);
|
|
344
|
+
await atomicWrite(outlinePath, outline.markdown);
|
|
345
|
+
return {
|
|
346
|
+
outline: outline.markdown,
|
|
347
|
+
status: {
|
|
348
|
+
docsUrl,
|
|
349
|
+
etagMd5: expectedDigest,
|
|
350
|
+
fetchedMd5: bodyDigest(body),
|
|
351
|
+
contentMatchesEtag: true,
|
|
352
|
+
cacheStatus,
|
|
353
|
+
cacheDir: selectedCache,
|
|
354
|
+
docsPath: documentPath,
|
|
355
|
+
outlinePath,
|
|
356
|
+
checkedAt: new Date().toISOString(),
|
|
357
|
+
lineCount: outline.lineCount,
|
|
358
|
+
headingCount: outline.headingCount,
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function parseArguments(argv) {
|
|
364
|
+
const result = {
|
|
365
|
+
docsUrl: DEFAULT_DOCS_URL,
|
|
366
|
+
cacheDir: undefined,
|
|
367
|
+
timeoutMs: 30000,
|
|
368
|
+
statusJson: false,
|
|
369
|
+
};
|
|
370
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
371
|
+
const value = argv[index];
|
|
372
|
+
if (value === "--docs-url") result.docsUrl = argv[++index];
|
|
373
|
+
else if (value === "--cache-dir") result.cacheDir = argv[++index];
|
|
374
|
+
else if (value === "--timeout-ms") result.timeoutMs = Number(argv[++index]);
|
|
375
|
+
else if (value === "--status-json") result.statusJson = true;
|
|
376
|
+
else throw new DocsFetchError(`Unknown argument: ${value}`);
|
|
377
|
+
}
|
|
378
|
+
if (!result.docsUrl) throw new DocsFetchError("--docs-url cannot be empty.");
|
|
379
|
+
if (!Number.isFinite(result.timeoutMs) || result.timeoutMs <= 0) {
|
|
380
|
+
throw new DocsFetchError("--timeout-ms must be a positive number.");
|
|
381
|
+
}
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function outputFor(status, outline) {
|
|
386
|
+
return [
|
|
387
|
+
`Docs path: ${status.docsPath}`,
|
|
388
|
+
`Outline path: ${status.outlinePath}`,
|
|
389
|
+
status.cacheStatus === "hit"
|
|
390
|
+
? "Docs status: local document was already current."
|
|
391
|
+
: "Docs status: local document was updated.",
|
|
392
|
+
"",
|
|
393
|
+
outline,
|
|
394
|
+
].join("\n");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function errorChain(error) {
|
|
398
|
+
const messages = [];
|
|
399
|
+
let current = error;
|
|
400
|
+
while (current) {
|
|
401
|
+
messages.push(
|
|
402
|
+
current instanceof Error
|
|
403
|
+
? `${current.name}: ${current.message}`
|
|
404
|
+
: String(current),
|
|
405
|
+
);
|
|
406
|
+
current = current?.cause;
|
|
407
|
+
}
|
|
408
|
+
return messages.join("\nCaused by: ");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function main() {
|
|
412
|
+
const options = parseArguments(process.argv.slice(2));
|
|
413
|
+
const result = await fetchLettaDocs(options);
|
|
414
|
+
process.stdout.write(outputFor(result.status, result.outline));
|
|
415
|
+
if (options.statusJson) console.error(JSON.stringify(result.status));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (
|
|
419
|
+
process.argv[1] &&
|
|
420
|
+
pathToFileURL(process.argv[1]).href === import.meta.url
|
|
421
|
+
) {
|
|
422
|
+
main().catch((error) => {
|
|
423
|
+
console.error(`Error: ${error.message}`);
|
|
424
|
+
if (hasProxyEnvironment()) {
|
|
425
|
+
console.error(
|
|
426
|
+
"Hint: proxy variables are set, so curl is tried before native fetch.",
|
|
427
|
+
);
|
|
428
|
+
} else if (typeof fetch !== "function") {
|
|
429
|
+
console.error("Hint: install curl or use Node.js 18 or newer.");
|
|
430
|
+
} else if (process.platform === "win32") {
|
|
431
|
+
console.error("Hint: use a cache directory under %TEMP% or %TMP%.");
|
|
432
|
+
}
|
|
433
|
+
console.error("");
|
|
434
|
+
console.error("Details:");
|
|
435
|
+
console.error(errorChain(error));
|
|
436
|
+
process.exitCode = 1;
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export { DEFAULT_DOCS_URL, createOutline, fetchLettaDocs };
|