// ***************************************************************************** // Copyright (C) 2025 EclipseSource GmbH and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { Summary, SummaryMetadata, TaskContextStorageService } from '@theia/ai-chat/lib/browser/task-context-service'; import { InMemoryTaskContextStorage } from '@theia/ai-chat/lib/browser/task-context-storage-service'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { DisposableCollection, EOL, Emitter, ILogger, Path, PreferenceService, URI, unreachable } from '@theia/core'; import { OpenerService, open } from '@theia/core/lib/browser'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import * as yaml from 'js-yaml'; import { FileChange, FileChangeType } from '@theia/filesystem/lib/common/files'; import { TASK_CONTEXT_STORAGE_DIRECTORY_PREF } from '../common/workspace-preferences'; import { BinaryBuffer } from '@theia/core/lib/common/buffer'; @injectable() export class TaskContextFileStorageService implements TaskContextStorageService { @inject(InMemoryTaskContextStorage) protected readonly inMemoryStorage: InMemoryTaskContextStorage; @inject(PreferenceService) protected readonly preferenceService: PreferenceService; @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @inject(FileService) protected readonly fileService: FileService; @inject(OpenerService) protected readonly openerService: OpenerService; @inject(ILogger) protected readonly logger: ILogger; protected readonly onDidChangeEmitter = new Emitter(); readonly onDidChange = this.onDidChangeEmitter.event; protected sanitizeLabel(label: string): string { return label.replace(/^[^\p{L}\p{N}]+/ug, ''); } protected getStorageLocation(): URI | undefined { if (!this.workspaceService.opened) { return; } const values = this.preferenceService.inspect(TASK_CONTEXT_STORAGE_DIRECTORY_PREF); const configuredPath = values?.globalValue === undefined ? values?.defaultValue : values?.globalValue; if (!configuredPath || typeof configuredPath !== 'string') { return; } const asPath = new Path(configuredPath); return asPath.isAbsolute ? new URI(configuredPath) : this.workspaceService.tryGetRoots().at(0)?.resource.resolve(configuredPath); } @postConstruct() protected init(): void { this.doInit(); } protected get ready(): Promise { return Promise.all([ this.workspaceService.ready, this.preferenceService.ready, ]).then(() => undefined); } protected async doInit(): Promise { await this.ready; this.watchStorage(); this.preferenceService.onPreferenceChanged(e => { if (e.preferenceName === TASK_CONTEXT_STORAGE_DIRECTORY_PREF) { this.watchStorage().catch(error => this.logger.error(error)); } }); } protected toDisposeOnStorageChange?: DisposableCollection; protected async watchStorage(): Promise { const newStorage = await this.getStorageLocation(); this.toDisposeOnStorageChange?.dispose(); this.toDisposeOnStorageChange = undefined; if (!newStorage) { return; } this.toDisposeOnStorageChange = new DisposableCollection( this.fileService.watch(newStorage, { recursive: true, excludes: [] }), this.fileService.onDidFilesChange(event => { const relevantChanges = event.changes.filter(candidate => newStorage.isEqualOrParent(candidate.resource)); this.handleChanges(relevantChanges); }), { dispose: () => this.clearInMemoryStorage() }, ); this.cacheNewTasks(newStorage).catch(this.logger.error.bind(this.logger)); } protected async handleChanges(changes: FileChange[]): Promise { await Promise.all(changes.map(change => { switch (change.type) { case FileChangeType.DELETED: return this.deleteFileReference(change.resource); case FileChangeType.ADDED: case FileChangeType.UPDATED: return this.readFile(change.resource); default: return unreachable(change.type); } })); } protected clearInMemoryStorage(): void { this.inMemoryStorage.clear(); } protected deleteFileReference(uri: URI): boolean { if (this.inMemoryStorage.delete(uri.path.base)) { return true; } for (const summary of this.inMemoryStorage.getAll()) { if (summary.uri?.isEqual(uri)) { return this.inMemoryStorage.delete(summary.id); } } return false; } protected async cacheNewTasks(storageLocation: URI): Promise { const contents = await this.fileService.resolve(storageLocation).catch(() => undefined); if (!contents?.children?.length) { return; } await Promise.all(contents.children.map(child => this.readFile(child.resource))); this.onDidChangeEmitter.fire(); } protected async readFile(uri: URI): Promise { const content = await this.fileService.read(uri).then(read => read.value).catch(() => undefined); if (content === undefined) { return; } const { frontmatter, body } = this.maybeReadFrontmatter(content); const rawLabel = frontmatter?.label || uri.path.base.slice(0, (-1 * uri.path.ext.length) || uri.path.base.length); const summary = { ...frontmatter, summary: body, label: this.sanitizeLabel(rawLabel), uri, id: frontmatter?.id || frontmatter?.sessionId || uri.path.base }; const existingSummary = !frontmatter?.id && summary.sessionId && this.getAll().find(candidate => candidate.sessionId === summary.sessionId); if (existingSummary) { summary.id = existingSummary.id; } this.inMemoryStorage.store(summary); } async store(summary: Summary): Promise { await this.ready; const label = this.sanitizeLabel(summary.label); const storageLocation = this.getStorageLocation(); if (storageLocation) { const frontmatter = { id: summary.id, sessionId: summary.sessionId, date: new Date().toISOString(), label, }; const derivedName = label.trim().replace(/[^\p{L}\p{N}]/ug, '-').replace(/^-+|-+$/g, ''); const filename = (derivedName.length > 32 ? derivedName.slice(0, derivedName.indexOf('-', 32)) : derivedName) + '.md'; const content = yaml.dump(frontmatter).trim() + `${EOL}---${EOL}` + summary.summary; const uri = storageLocation.resolve(filename); summary.uri = uri; await this.fileService.writeFile(uri, BinaryBuffer.fromString(content)); } this.inMemoryStorage.store({ ...summary, label }); this.onDidChangeEmitter.fire(); } getAll(): Summary[] { return this.inMemoryStorage.getAll(); } async get(identifier: string): Promise { const cached = this.inMemoryStorage.get(identifier); if (!cached?.uri) { return cached; } // Read fresh content from disk const content = await this.fileService.read(cached.uri).then(read => read.value).catch(reason => { this.logger.error(`Failed to read file ${cached.uri}: ${reason}`); return undefined; }); if (content === undefined) { return cached; // Fall back to cache if read fails } const { body } = this.maybeReadFrontmatter(content); return { ...cached, summary: body }; } async delete(identifier: string): Promise { const summary = this.inMemoryStorage.get(identifier); if (summary?.uri) { await this.fileService.delete(summary.uri); } this.inMemoryStorage.delete(identifier); if (summary) { this.onDidChangeEmitter.fire(); } return !!summary; } protected maybeReadFrontmatter(content: string): { body: string, frontmatter: SummaryMetadata | undefined } { const frontmatterEnd = content.indexOf('---'); if (frontmatterEnd !== -1) { try { const frontmatter = yaml.load(content.slice(0, frontmatterEnd)); if (this.hasLabel(frontmatter)) { return { frontmatter, body: content.slice(frontmatterEnd + 3).trim() }; } } catch { /* Probably not frontmatter, then. */ } } return { body: content, frontmatter: undefined }; } protected hasLabel(candidate: unknown): candidate is SummaryMetadata { return !!candidate && typeof candidate === 'object' && !Array.isArray(candidate) && 'label' in candidate && typeof candidate.label === 'string'; } async open(identifier: string): Promise { const summary = await this.get(identifier); if (!summary) { throw new Error('Unable to open requested task context: none found with specified identifier.'); } await (summary.uri ? open(this.openerService, summary.uri) : this.inMemoryStorage.open(identifier)); } }