@lightcone-ai/daemon 0.15.48 → 0.15.50

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightcone-ai/daemon",
3
- "version": "0.15.48",
3
+ "version": "0.15.50",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -6,11 +6,7 @@ import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync }
6
6
  import { createHash, randomUUID } from 'crypto';
7
7
  import path, { extname } from 'path';
8
8
  import { recordUrlNarration } from './_vendor/video/recorder/index.js';
9
- import {
10
- VIDEO_EXT,
11
- VIDEO_MIME,
12
- writeLocalFileToWorkspace,
13
- } from './workspace-file-upload.js';
9
+ import { writeLocalFileToWorkspace } from './workspace-file-upload.js';
14
10
  import { runRecordUrlNarrationTool } from './record-url-narration-tool.js';
15
11
  import { runSubmitToLibraryTool } from './submit-to-library-tool.js';
16
12
  import { runRenderTextToImageTool } from './tools/render-text-to-image.js';
@@ -491,7 +487,7 @@ async function directApi(method, apiPath, body) {
491
487
  async function directApiVideoUpload(apiPath, {
492
488
  localPath,
493
489
  filename,
494
- contentType = VIDEO_MIME,
490
+ contentType = 'video/mp4',
495
491
  }) {
496
492
  const url = `${SERVER_URL}/internal/agent/${AGENT_ID}${apiPath}`;
497
493
  const headers = {
@@ -1090,44 +1086,22 @@ server.tool('write_workspace_file', 'Write a local file directly to the shared w
1090
1086
  uploadWorkspaceMemory: async ({ workspacePath, workspaceId, content }) => {
1091
1087
  await api('PUT', `/workspace-memory?path=${encodeURIComponent(workspacePath)}&workspaceId=${encodeURIComponent(workspaceId)}`, { content });
1092
1088
  },
1093
- uploadVideo: async ({
1094
- localPath: videoLocalPath,
1095
- workspacePath,
1096
- workspaceId,
1097
- filename,
1098
- mime,
1099
- }) => {
1100
- const uploadResult = await runMandatoryLocalTool({
1101
- toolName: 'write_workspace_file',
1102
- toolInput: {
1103
- file_path,
1104
- path: workspacePath,
1105
- upload_mode: 'upload_video',
1106
- },
1107
- executor: async () => directApiVideoUpload(
1108
- `/upload-video?workspaceId=${encodeURIComponent(workspaceId)}&filename=${encodeURIComponent(filename)}&path=${encodeURIComponent(workspacePath)}`,
1109
- {
1110
- localPath: videoLocalPath,
1111
- filename,
1112
- contentType: mime,
1113
- }
1114
- ),
1115
- });
1116
- return {
1117
- mode: 'upload-video',
1118
- mime: VIDEO_MIME,
1119
- bytes: Number(uploadResult?.sizeBytes) || 0,
1120
- };
1089
+ presign: async ({ workspaceId, path: filePath, size, mime, sha256 }) => {
1090
+ return api('POST', '/storage/presign', { workspaceId, path: filePath, size, mime, sha256 });
1091
+ },
1092
+ confirmUpload: async ({ workspaceId, path: filePath, objectKey }) => {
1093
+ await api('POST', '/storage/confirm', { workspaceId, path: filePath, objectKey });
1121
1094
  },
1122
1095
  });
1123
1096
 
1124
- const finalMime = result?.mime ?? (extname(path || localPath).toLowerCase() === VIDEO_EXT ? VIDEO_MIME : 'application/octet-stream');
1097
+ const finalMime = result?.mime ?? 'application/octet-stream';
1125
1098
  const finalBytes = Number.isFinite(result?.bytes) ? result.bytes : 0;
1126
1099
  const sizeText = finalBytes > 0 ? formatBytes(finalBytes) : 'unknown size';
1100
+ const asyncNote = result?.async ? ' (uploading in background)' : '';
1127
1101
  return {
1128
1102
  content: [{
1129
1103
  type: 'text',
1130
- text: `Saved local file to workspace: ${path} (${finalMime}, ${sizeText})`,
1104
+ text: `Saved local file to workspace: ${path} (${finalMime}, ${sizeText})${asyncNote}`,
1131
1105
  }],
1132
1106
  };
1133
1107
  });
@@ -1616,10 +1590,10 @@ server.tool('execute_approved_action',
1616
1590
 
1617
1591
  // ── promote_context ───────────────────────────────────────────────────────────
1618
1592
  server.tool('promote_context',
1619
- 'Submit a workspace-level knowledge candidate for human review. Use this after finishing a task when you discover a stable fact, convention, or learning that future agents in this workspace should know. The candidate appears in the workspace owner\'s "My Context → Pending Proposals" panel; once a human (or the workspace Orchestrator) confirms it, it is auto-injected into every future agent\'s system prompt under "## Workspace context". This is the only sanctioned path for sharing workspace-level knowledge do NOT write a "knowledge index" file via write_workspace.',
1593
+ 'Submit a candidate for human review to add to the workspace\'s persistent context. Use this (1) after finishing a task when you discover a stable fact, convention, or learning future agents should know, or (2) immediately after responding to a user message when the user expressed a persistent operational preference (e.g. "always add links", "use subtitles by default"). The candidate appears in the workspace owner\'s "My Context → Pending Proposals" panel; once confirmed, it is auto-injected into every future agent\'s system prompt under "## Workspace context". This is the only sanctioned path for shared knowledge or preference governance.',
1620
1594
  {
1621
1595
  workspace_id: z.string().optional().describe('Target workspace id. Defaults to your current workspace if omitted.'),
1622
- type: z.enum(['knowledge', 'workspace_norm', 'memory']).optional().describe('Candidate type. Default "knowledge" use "workspace_norm" for standing rules, "memory" for durable facts.'),
1596
+ type: z.enum(['knowledge', 'workspace_norm', 'memory', 'preference']).optional().describe('Candidate type: "knowledge" for facts/learnings, "workspace_norm" for standing rules, "memory" for durable facts, "preference" for persistent user preferences expressed in conversation.'),
1623
1597
  summary: z.string().describe('One-line title that reviewers will see in the Pending Proposals list.'),
1624
1598
  content: z.string().describe('Full candidate text that future agents will read. Be concrete, citable, and self-contained.'),
1625
1599
  source_message_id: z.string().optional().describe('Optional message id that motivated this candidate, for audit trail.'),
@@ -1648,11 +1622,12 @@ server.tool('promote_context',
1648
1622
  reason,
1649
1623
  });
1650
1624
  const proposal = data?.proposal ?? {};
1625
+ const resolvedType = type ?? 'knowledge';
1651
1626
  return {
1652
1627
  content: [{
1653
1628
  type: 'text',
1654
1629
  text:
1655
- `Knowledge candidate submitted.\n` +
1630
+ `${resolvedType === 'preference' ? 'Preference' : 'Knowledge'} candidate submitted.\n` +
1656
1631
  `proposal_id=${proposal.id ?? 'unknown'} workspace=${proposal.workspaceId ?? targetWorkspaceId} status=${proposal.status ?? 'candidate'}\n` +
1657
1632
  `It is now visible in the workspace owner's "My Context → Pending Proposals" panel; once confirmed, it will be injected into every future agent's "## Workspace context".`,
1658
1633
  }],
@@ -238,7 +238,7 @@ The active workspace context (Goal State, constraints, decisions, knowledge) is
238
238
 
239
239
  **Write rule:**
240
240
  - Personal learnings → \`${t("write_memory")}\`
241
- - Workspace-level knowledge worth sharing across all future agents → \`${t("promote_context")}\` (submits a knowledge candidate; see below). Do **not** dump shared knowledge into ad-hoc files inside the workspace shared workspace.
241
+ - Workspace-level knowledge or persistent user preferences → \`${t("promote_context")}\` (see below). Do **not** dump shared knowledge into ad-hoc files inside the workspace shared workspace.
242
242
  - **Any file you produce for a task** → \`${t("write_workspace")}({ path: "artifacts/your-file.ext", ... })\` or \`${t("write_workspace_file")}({ file_path, path: "artifacts/your-file.ext" })\`
243
243
 
244
244
  Temporary local files belong under \`tmp/\` in your personal workspace. If you need to show an image in chat, first save the durable copy to \`artifacts/\`, then optionally call \`${t("upload_image")}\` for a temporary public preview URL.
@@ -258,8 +258,8 @@ Example: writing a web page → \`${t("write_workspace")}({ path: "artifacts/job
258
258
  - \`${t("write_workspace_file")}({ file_path, path })\` — write a local file from your workspace to a workspace artifact without putting base64 in context
259
259
  - \`${t("list_workspace")}()\` — list all files in the workspace
260
260
 
261
- **Workspace knowledge governance:**
262
- - \`${t("promote_context")}({ workspace_id, type, summary, content })\` — submit a workspace-level knowledge candidate. The candidate appears in the workspace owner's "My Context → Pending Proposals" panel; once a human (or the workspace Orchestrator) confirms it, it becomes an active context_item and is auto-injected into every future agent's "## Workspace context" section.
261
+ **Workspace knowledge & preference governance:**
262
+ - \`${t("promote_context")}({ workspace_id, type, summary, content })\` — submit a candidate for human review. Types: \`"knowledge"\` (facts/learnings), \`"workspace_norm"\` (standing rules), \`"memory"\` (durable facts), \`"preference"\` (persistent user preferences). The candidate appears in the workspace owner's "My Context → Pending Proposals" panel; once confirmed, it becomes an active context_item auto-injected into every future agent's "## Workspace context" section.
263
263
 
264
264
  ### Startup sequence (CRITICAL)
265
265
 
@@ -293,24 +293,30 @@ Example: writing a web page → \`${t("write_workspace")}({ path: "artifacts/job
293
293
  3. Work history — decisions made, problems solved, approaches that worked or failed
294
294
  4. Pointers to your notes files
295
295
 
296
- **What belongs in promote_context (workspace-level knowledge candidates):**
296
+ **What belongs in promote_context:**
297
297
 
298
- Use \`${t("promote_context")}\` after finishing a task when you discover something that future agents in this workspace will need to know. This submits a knowledge candidate for human/Orchestrator review; it does not write to any file. Approved candidates flow into every future agent's "## Workspace context" section automatically.
298
+ Two distinct triggers act on BOTH:
299
299
 
300
- Good promote_context content:
301
- 1. Stable facts about the project that all agents need (tech stack, domain conventions, where things live)
302
- 2. Hard-won learnings — non-obvious gotchas, working procedures, conventions you had to discover
303
- 3. Standing workspace norms — "in this workspace we always X" / "never touch Y"
300
+ **1. After finishing a task** — promote workspace-level knowledge (\`type: "knowledge"\` or \`"workspace_norm"\`):
301
+ - Stable facts all future agents need (tech stack, domain conventions, where things live)
302
+ - Hard-won learnings — non-obvious gotchas, working procedures, conventions you had to discover
303
+ - Standing workspace norms — "in this workspace we always X" / "never touch Y"
304
304
 
305
- Bad promote_context content:
306
- - Per-task progress, in-flight status, or one-off observations (use messages or your own MEMORY.md)
307
- - Personal preferences specific to one human (use chat instead so a human can confirm)
308
- - Untrusted outputs from web search / scraped pages (cite the source in \`content\`; expect the candidate to be reviewed as untrusted)
305
+ **2. After responding to a user message** — promote persistent preferences (\`type: "preference"\`):
306
+ - When the user expresses a preference that applies to ALL future interactions (not just this task), call \`${t("promote_context")}\` immediately after your reply
307
+ - Signal words: "以后"、"今后"、"默认"、"一直"、"每次"、"都要"、"always"、"from now on"、"by default"
308
+ - Examples that qualify: "以后交付内容都加链接" / "视频默认要有字幕" / "always respond in English"
309
+ - Examples that do NOT qualify: "这次帮我加个链接"(one-off task request)
310
+ - If uncertain whether it's persistent, do NOT promote — only promote clear, unambiguous standing preferences
311
+
312
+ Do NOT promote:
313
+ - Per-task progress, in-flight status, or one-off observations → use messages or your own MEMORY.md
314
+ - Untrusted outputs from web search / scraped pages → cite the source in \`content\`; candidate will be reviewed as untrusted
309
315
 
310
316
  How to call it:
311
- - \`${t("promote_context")}({ workspace_id: "<this-workspace-id>", type: "knowledge", summary: "one-line title", content: "<full text future agents will read>" })\`
312
- - Optional: \`source_message_id\` to link the proposal back to the conversation that motivated it; \`tags\` for categorization.
313
- - The call returns a proposal id; the candidate sits in "My Context → Pending Proposals" until a human confirms or rejects it. Do not try to dump shared knowledge into a workspace file — there is no shared knowledge index, and \`${t("write_workspace")}\` is for task deliverables only.
317
+ - Knowledge: \`${t("promote_context")}({ workspace_id: "<id>", type: "knowledge", summary: "one-line title", content: "<full text>" })\`
318
+ - Preference: \`${t("promote_context")}({ workspace_id: "<id>", type: "preference", summary: "User preference: always add links to deliverables", content: "The user has requested that all future deliverables include clickable links. Apply to every response going forward.", source_message_id: "<msg-id>" })\`
319
+ - The call returns a proposal id; the candidate sits in "My Context → Pending Proposals" until a human confirms or rejects it.
314
320
 
315
321
  ### Compaction safety
316
322
 
@@ -294,7 +294,6 @@ export async function materializeWorkspaceMedia({
294
294
  machineApiKey,
295
295
  jobId,
296
296
  fetchWorkspaceFile = fetchPublishJobWorkspaceFile,
297
- streamVideoFile = streamPublishJobVideo,
298
297
  writeBinaryFile = writeFileAtomically,
299
298
  }) {
300
299
  if (!filePath) return filePath;
@@ -317,20 +316,33 @@ export async function materializeWorkspaceMedia({
317
316
  jobId,
318
317
  relPath: workspacePath.relPath,
319
318
  });
320
- if (isWorkspaceCacheValid(localPath, data)) return localPath;
321
319
 
322
- if (data?.videoId) {
323
- await streamVideoFile({
324
- serverUrl,
325
- machineApiKey,
326
- jobId,
327
- videoId: data.videoId,
328
- localPath,
329
- });
330
- } else {
320
+ // New: signed URL from object storage
321
+ if (data?.signedUrl) {
322
+ // Use sha256-based cache key in tmp/cache to avoid duplicate downloads
323
+ const cacheDir = path.join(workspaceRootDir, 'tmp', 'cache');
324
+ const ext = path.extname(workspacePath.relPath) || '';
325
+ const cacheKey = data.object_hash ?? data.size_bytes?.toString() ?? 'nokey';
326
+ const cachePath = path.join(cacheDir, `${cacheKey}${ext}`);
327
+
328
+ if (existsSync(cachePath)) return cachePath;
329
+
330
+ mkdirSync(cacheDir, { recursive: true });
331
+ const res = await fetch(data.signedUrl);
332
+ if (!res.ok) throw new Error(`Failed to download from signed URL: ${res.status}`);
333
+ const buf = Buffer.from(await res.arrayBuffer());
334
+ writeFileAtomically(cachePath, buf);
335
+ return cachePath;
336
+ }
337
+
338
+ // Legacy: text content in DB
339
+ if (data?.content != null) {
340
+ if (isWorkspaceCacheValid(localPath, data)) return localPath;
331
341
  writeBinaryFile(localPath, decodeWorkspaceContent(data.content));
342
+ return localPath;
332
343
  }
333
- return localPath;
344
+
345
+ throw new Error(`No content or signedUrl in workspace file response for: ${filePath}`);
334
346
  }
335
347
 
336
348
  async function materializeMedia({
@@ -1,36 +1,90 @@
1
1
  import path, { extname } from 'node:path';
2
- import { readFileSync } from 'node:fs';
2
+ import { readFileSync, createReadStream } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
3
4
 
4
5
  export const WORKSPACE_BINARY_MIME = {
5
- '.png': 'image/png',
6
- '.jpg': 'image/jpeg',
6
+ '.mp4': 'video/mp4',
7
+ '.png': 'image/png',
8
+ '.jpg': 'image/jpeg',
7
9
  '.jpeg': 'image/jpeg',
8
10
  '.webp': 'image/webp',
9
- '.gif': 'image/gif',
10
- '.pdf': 'application/pdf',
11
+ '.gif': 'image/gif',
12
+ '.pdf': 'application/pdf',
13
+ '.mp3': 'audio/mpeg',
14
+ '.wav': 'audio/wav',
15
+ '.m4a': 'audio/mp4',
11
16
  };
12
17
 
13
- export const VIDEO_EXT = '.mp4';
14
- export const VIDEO_MIME = 'video/mp4';
18
+ const TEXT_EXTS = new Set([
19
+ '.md', '.txt', '.json', '.js', '.ts', '.html', '.css',
20
+ '.csv', '.xml', '.yaml', '.yml', '.sh',
21
+ ]);
22
+
23
+ const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; // 5MB
15
24
 
16
25
  export function resolveWorkspaceFileUploadPlan({ localPath, workspacePath }) {
17
26
  const target = String(workspacePath ?? '').trim() || String(localPath ?? '').trim();
18
27
  const ext = extname(target).toLowerCase();
19
28
  const filename = path.basename(target || localPath);
20
- if (ext === VIDEO_EXT) {
21
- return {
22
- mode: 'upload-video',
23
- ext,
24
- filename,
25
- mime: VIDEO_MIME,
26
- };
29
+ const mime = WORKSPACE_BINARY_MIME[ext] ?? 'application/octet-stream';
30
+ const isText = TEXT_EXTS.has(ext);
31
+ return { ext, filename, mime, isText };
32
+ }
33
+
34
+ function sha256ofBuffer(buf) {
35
+ return createHash('sha256').update(buf).digest('hex');
36
+ }
37
+
38
+ /**
39
+ * Upload a local file to the workspace via the new presign → PUT → confirm flow.
40
+ * For large files (>= 5MB), presign returns immediately (DB status = uploading)
41
+ * and the PUT + confirm happen in the background.
42
+ */
43
+ async function uploadBinaryFile({
44
+ localPath,
45
+ workspacePath,
46
+ workspaceId,
47
+ mime,
48
+ presign, // async fn({ workspaceId, path, size, mime, sha256 }) → { uploadUrl, objectKey, alreadyExists }
49
+ confirmUpload, // async fn({ workspaceId, path, objectKey }) → void
50
+ }) {
51
+ const buf = readFileSync(localPath);
52
+ const size = buf.length;
53
+ const sha256 = sha256ofBuffer(buf);
54
+
55
+ const { uploadUrl, objectKey, alreadyExists } = await presign({
56
+ workspaceId,
57
+ path: workspacePath,
58
+ size,
59
+ mime,
60
+ sha256,
61
+ });
62
+
63
+ if (alreadyExists) {
64
+ return { mode: 'object-storage', mime, bytes: size, objectKey, deduped: true };
27
65
  }
28
- return {
29
- mode: 'workspace-memory',
30
- ext,
31
- filename,
32
- mime: WORKSPACE_BINARY_MIME[ext] ?? 'application/octet-stream',
66
+
67
+ const isLarge = size >= LARGE_FILE_THRESHOLD;
68
+
69
+ const doPut = async () => {
70
+ const { default: fetch } = await import('node-fetch');
71
+ const resp = await fetch(uploadUrl, {
72
+ method: 'PUT',
73
+ body: buf,
74
+ headers: { 'Content-Type': mime, 'Content-Length': String(size) },
75
+ });
76
+ if (!resp.ok) throw new Error(`COS PUT failed: ${resp.status} ${await resp.text()}`);
77
+ await confirmUpload({ workspaceId, path: workspacePath, objectKey });
33
78
  };
79
+
80
+ if (isLarge) {
81
+ // Fire-and-forget for large files; server already wrote `uploading` status
82
+ doPut().catch(err => console.error('[workspace-upload] large file background upload failed:', err?.message));
83
+ return { mode: 'object-storage', mime, bytes: size, objectKey, async: true };
84
+ }
85
+
86
+ await doPut();
87
+ return { mode: 'object-storage', mime, bytes: size, objectKey, async: false };
34
88
  }
35
89
 
36
90
  export async function writeLocalFileToWorkspace({
@@ -38,34 +92,33 @@ export async function writeLocalFileToWorkspace({
38
92
  workspacePath,
39
93
  workspaceId,
40
94
  readFileSyncFn = readFileSync,
41
- uploadWorkspaceMemory,
42
- uploadVideo,
95
+ uploadWorkspaceMemory, // legacy text path
96
+ presign,
97
+ confirmUpload,
43
98
  }) {
44
99
  const plan = resolveWorkspaceFileUploadPlan({ localPath, workspacePath });
45
- if (plan.mode === 'upload-video') {
46
- return uploadVideo({
100
+
101
+ if (!plan.isText) {
102
+ return uploadBinaryFile({
47
103
  localPath,
48
104
  workspacePath,
49
105
  workspaceId,
50
- filename: plan.filename,
51
106
  mime: plan.mime,
52
- mode: plan.mode,
107
+ presign,
108
+ confirmUpload,
53
109
  });
54
110
  }
55
111
 
112
+ // Text files: existing path via workspace-memory
56
113
  const buf = readFileSyncFn(localPath);
57
- const content = `data:${plan.mime};base64,${buf.toString('base64')}`;
114
+ const content = buf.toString('utf8');
58
115
  await uploadWorkspaceMemory({
59
116
  workspacePath,
60
117
  workspaceId,
61
118
  content,
62
119
  mime: plan.mime,
63
120
  bytes: buf.length,
64
- mode: plan.mode,
121
+ mode: 'workspace-memory',
65
122
  });
66
- return {
67
- mode: plan.mode,
68
- mime: plan.mime,
69
- bytes: buf.length,
70
- };
123
+ return { mode: 'workspace-memory', mime: plan.mime, bytes: buf.length };
71
124
  }