diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs
index cb22e25..7fc24a3 100644
--- a/mindspace-assets.mjs
+++ b/mindspace-assets.mjs
@@ -301,6 +301,7 @@ export function createAssetService(pool, options = {}) {
h5Root,
maxFileBytes,
idFactory,
+ conversationPackageRegistry,
});
const mirrorToUserWorkspace = async (userId, { categoryCode, filename, sourcePath }) => {
diff --git a/mindspace-public-finish-sync.mjs b/mindspace-public-finish-sync.mjs
index 37958e7..a2ae5fc 100644
--- a/mindspace-public-finish-sync.mjs
+++ b/mindspace-public-finish-sync.mjs
@@ -386,6 +386,7 @@ export async function syncPublicHtmlAfterFinish({
currentUser,
publishDir,
syncWorkspaceAssets,
+ sessionId = null,
} = {}) {
if (!hasRecentOwnPublicHtmlReference(messages, currentUser, { publishDir })) {
return { materialized: [], skipped: [], synced: false };
@@ -395,7 +396,7 @@ export async function syncPublicHtmlAfterFinish({
const docxSync = syncPublicDocxDownloads({ publishDir });
let synced = false;
if (typeof syncWorkspaceAssets === 'function' && currentUser?.id) {
- await syncWorkspaceAssets(currentUser.id, { categoryCode: 'public' });
+ await syncWorkspaceAssets(currentUser.id, { categoryCode: 'public', sourceSessionId: sessionId });
synced = true;
}
return { materialized, skipped, synced, docxSync };
diff --git a/mindspace-public-finish-sync.test.mjs b/mindspace-public-finish-sync.test.mjs
index dd258b0..75b03e0 100644
--- a/mindspace-public-finish-sync.test.mjs
+++ b/mindspace-public-finish-sync.test.mjs
@@ -10,6 +10,7 @@ import {
materializeMissingPublicHtmlWrites,
materializePublicHtmlWritesFromSessionEvent,
syncPublicDocxDownloads,
+ syncPublicHtmlAfterFinish,
} from './mindspace-public-finish-sync.mjs';
const CURRENT_USER = { id: 'a6fb1e97-2b0f-447b-b138-4561d8e5c53e', username: 'john' };
@@ -151,6 +152,46 @@ test('materializeMissingPublicHtmlWrites writes html from sandbox write_file too
}
});
+test('syncPublicHtmlAfterFinish forwards session source to workspace sync', async () => {
+ const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
+ try {
+ const calls = [];
+ const messages = [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: 'toolRequest',
+ toolCall: {
+ value: {
+ name: 'write_file',
+ arguments: {
+ path: 'public/session-page.html',
+ content: '
Session page',
+ },
+ },
+ },
+ },
+ ],
+ },
+ ];
+ const result = await syncPublicHtmlAfterFinish({
+ messages,
+ currentUser: CURRENT_USER,
+ publishDir,
+ sessionId: 'session-1',
+ syncWorkspaceAssets: async (...args) => {
+ calls.push(args);
+ },
+ });
+
+ assert.equal(result.synced, true);
+ assert.deepEqual(calls, [[CURRENT_USER.id, { categoryCode: 'public', sourceSessionId: 'session-1' }]]);
+ } finally {
+ fs.rmSync(publishDir, { recursive: true, force: true });
+ }
+});
+
test('materializeMissingPublicHtmlWrites writes html from developer write tool calls', () => {
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
try {
diff --git a/mindspace-workspace-sync.mjs b/mindspace-workspace-sync.mjs
index d47df32..2812d14 100644
--- a/mindspace-workspace-sync.mjs
+++ b/mindspace-workspace-sync.mjs
@@ -39,6 +39,19 @@ function asNumber(value) {
return Number(value ?? 0);
}
+function generatedArtifactKindForMime(mimeType, filename) {
+ const normalizedMime = String(mimeType ?? '').toLowerCase();
+ const normalizedName = String(filename ?? '').toLowerCase();
+ if (normalizedMime.startsWith('image/')) return 'generated_image';
+ if (normalizedName.endsWith('.docx') || normalizedName.endsWith('.doc')) return 'docx';
+ if (normalizedName.endsWith('.pdf')) return 'pdf';
+ return 'generated_file';
+}
+
+function workspaceAssetDownloadUrl(assetId) {
+ return `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download`;
+}
+
export function normalizeWorkspaceRelativePath(relativePath) {
const normalized = String(relativePath ?? '')
.normalize('NFKC')
@@ -100,7 +113,14 @@ export async function listWorkspaceZoneFiles(workspaceRoot, categoryCode) {
return files;
}
-export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idFactory }) {
+export function createWorkspaceAssetSync({
+ pool,
+ storageRoot,
+ h5Root,
+ maxFileBytes,
+ idFactory,
+ conversationPackageRegistry = null,
+}) {
void storageRoot;
const loadExistingAssets = async (userId, categoryId) => {
const [rows] = await pool.query(
@@ -221,7 +241,16 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
[buffer.length, now, category.space_id, userId],
);
await conn.commit();
- return { action: 'imported', assetId, filename: file.filename, checksum };
+ return {
+ action: 'imported',
+ assetId,
+ filename: file.filename,
+ checksum,
+ mimeType: detectedMimeType,
+ sizeBytes: buffer.length,
+ storageKey: finalStorageKey,
+ categoryCode: category.category_code,
+ };
} catch (error) {
await conn.rollback();
throw error;
@@ -323,7 +352,16 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
);
}
await conn.commit();
- return { action: 'updated', assetId: existing.id, filename: file.filename, checksum };
+ return {
+ action: 'updated',
+ assetId: existing.id,
+ filename: file.filename,
+ checksum,
+ mimeType: detectedMimeType,
+ sizeBytes: buffer.length,
+ storageKey: finalStorageKey,
+ categoryCode: category.category_code,
+ };
} catch (error) {
await conn.rollback();
throw error;
@@ -332,7 +370,40 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
}
};
- const syncCategory = async (userId, categoryCode) => {
+ const registerWorkspaceArtifactForConversation = async (userId, source, result, now = Date.now()) => {
+ const sessionId = String(source?.sessionId ?? source?.sourceSessionId ?? '').trim();
+ if (!conversationPackageRegistry || !sessionId || !result?.assetId) return null;
+ try {
+ const packageRecord = await conversationPackageRegistry.ensurePackage({
+ userId,
+ sessionId,
+ title: source?.title ?? null,
+ now,
+ });
+ const artifact = await conversationPackageRegistry.recordArtifact({
+ id: `ca_workspace_${result.assetId}`,
+ packageId: packageRecord.id,
+ artifactKind: generatedArtifactKindForMime(result.mimeType, result.filename),
+ role: 'assistant',
+ assetId: result.assetId,
+ messageId: source?.messageId ?? source?.sourceMessageId ?? null,
+ displayName: result.filename,
+ mimeType: result.mimeType,
+ sizeBytes: result.sizeBytes,
+ storageKey: result.storageKey,
+ canonicalUrl: workspaceAssetDownloadUrl(result.assetId),
+ sortOrder: now,
+ now,
+ });
+ await conversationPackageRegistry.writeManifestForSession({ userId, sessionId });
+ return artifact;
+ } catch (error) {
+ console.warn('[MindSpace] workspace conversation artifact registration failed:', error?.message ?? error);
+ return null;
+ }
+ };
+
+ const syncCategory = async (userId, categoryCode, source = {}) => {
if (!h5Root) return { imported: 0, updated: 0, skipped: 0 };
const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId });
const files = await listWorkspaceZoneFiles(workspaceRoot, categoryCode);
@@ -369,11 +440,13 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
continue;
}
if (existing) {
- await updateWorkspaceFile(userId, category, existing, file, buffer);
+ const result = await updateWorkspaceFile(userId, category, existing, file, buffer);
+ await registerWorkspaceArtifactForConversation(userId, source, result);
existing.checksum = checksum;
updated += 1;
} else {
- await importWorkspaceFile(userId, category, file, buffer);
+ const result = await importWorkspaceFile(userId, category, file, buffer);
+ await registerWorkspaceArtifactForConversation(userId, source, result);
existingByName.set(file.filename, { checksum });
imported += 1;
}
@@ -381,13 +454,14 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
return { imported, updated, skipped };
};
- const syncUserWorkspace = async (userId, { categoryCode } = {}) => {
+ const syncUserWorkspace = async (userId, { categoryCode, sourceSessionId, sourceMessageId, title } = {}) => {
const codes = categoryCode ? [categoryCode] : UPLOAD_ZONE_CODES;
let imported = 0;
let updated = 0;
let skipped = 0;
+ const source = { sessionId: sourceSessionId, messageId: sourceMessageId, title };
for (const code of codes) {
- const result = await syncCategory(userId, code);
+ const result = await syncCategory(userId, code, source);
imported += result.imported;
updated += result.updated;
skipped += result.skipped;
@@ -398,6 +472,11 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
return { syncUserWorkspace, syncCategory, listWorkspaceZoneFiles };
}
+export const workspaceSyncInternals = {
+ generatedArtifactKindForMime,
+ workspaceAssetDownloadUrl,
+};
+
export function startWorkspaceAssetSyncWatcher({ publishRoot, syncUserWorkspaceByDirKey }) {
if (!publishRoot || !syncUserWorkspaceByDirKey) return () => {};
diff --git a/mindspace-workspace-sync.test.mjs b/mindspace-workspace-sync.test.mjs
index 09f19c5..260a0c3 100644
--- a/mindspace-workspace-sync.test.mjs
+++ b/mindspace-workspace-sync.test.mjs
@@ -142,21 +142,65 @@ test('syncUserWorkspace imports new workspace files into asset library', async (
},
};
+ const packageCalls = [];
+ const conversationPackageRegistry = {
+ async ensurePackage(input) {
+ packageCalls.push(['ensurePackage', input]);
+ return { id: 'cp-session-1' };
+ },
+ async recordArtifact(input) {
+ packageCalls.push(['recordArtifact', input]);
+ return input;
+ },
+ async writeManifestForSession(input) {
+ packageCalls.push(['writeManifestForSession', input]);
+ return input;
+ },
+ };
const sync = createWorkspaceAssetSync({
pool,
storageRoot,
h5Root,
maxFileBytes: 1024 * 1024,
idFactory: () => `id-${++nextId}`,
+ conversationPackageRegistry,
});
- const result = await sync.syncUserWorkspace('user-1', { categoryCode: 'oa' });
+ const result = await sync.syncUserWorkspace('user-1', {
+ categoryCode: 'oa',
+ sourceSessionId: 'session-1',
+ sourceMessageId: 'message-1',
+ title: 'Workspace chat',
+ });
assert.equal(result.imported, 1);
assert.equal(state.assets.length, 1);
assert.equal(state.assets[0].original_filename, 'memo.txt');
assert.ok(state.versions.length === 1);
assert.match(state.versions[0].storage_key, /^workspace:\/\/user-1\/oa\/memo\.txt$/);
assert.equal(await fs.readFile(path.join(workspace, 'oa', 'memo.txt'), 'utf8'), 'hello workspace\n');
+ assert.deepEqual(packageCalls[0], [
+ 'ensurePackage',
+ {
+ userId: 'user-1',
+ sessionId: 'session-1',
+ title: 'Workspace chat',
+ now: packageCalls[0][1].now,
+ },
+ ]);
+ const artifactCall = packageCalls.find(([name]) => name === 'recordArtifact')?.[1];
+ assert.equal(artifactCall.packageId, 'cp-session-1');
+ assert.equal(artifactCall.artifactKind, 'generated_file');
+ assert.equal(artifactCall.role, 'assistant');
+ assert.equal(artifactCall.assetId, 'id-1');
+ assert.equal(artifactCall.messageId, 'message-1');
+ assert.equal(artifactCall.displayName, 'memo.txt');
+ assert.equal(artifactCall.mimeType, 'text/plain');
+ assert.equal(artifactCall.storageKey, 'workspace://user-1/oa/memo.txt');
+ assert.equal(artifactCall.canonicalUrl, '/api/mindspace/v1/assets/id-1/download');
+ assert.deepEqual(packageCalls.at(-1), [
+ 'writeManifestForSession',
+ { userId: 'user-1', sessionId: 'session-1' },
+ ]);
});
test('syncUserWorkspace imports nested workspace files into asset library', async () => {
diff --git a/server.mjs b/server.mjs
index c9752af..6e5d92a 100644
--- a/server.mjs
+++ b/server.mjs
@@ -4508,6 +4508,7 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
messages,
currentUser: req.currentUser,
publishDir,
+ sessionId: sid,
syncWorkspaceAssets:
WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets
? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options)