basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import { createHash } from 'node:crypto';8import * as fs from 'node:fs/promises';9import * as path from 'node:path';10import lockfile from 'proper-lockfile';11import { Storage } from '../config/storage.js';12 13export interface ChannelMemoryTarget {14 channelName: string;15 chatId: string;16 threadId?: string;17}18 19export interface ChannelMemoryWriteResult {20 changed: boolean;21 filePath: string;22}23 24export const CHANNEL_MEMORY_FILE_NAME = 'CHANNEL.md';25export const MAX_CHANNEL_MEMORY_BYTES = 1024 * 1024;26const pendingAppends = new Map<string, Promise<void>>();27const LOCK_OPTIONS: lockfile.LockOptions = {28 realpath: false,29 retries: {30 retries: 12,31 minTimeout: 50,32 maxTimeout: 1000,33 factor: 2,34 randomize: true,35 },36 stale: 5000,37};38 39function isMissingFile(error: unknown): boolean {40 return (error as NodeJS.ErrnoException).code === 'ENOENT';41}42 43async function releaseLock(release: () => Promise<void>): Promise<void> {44 try {45 await release();46 } catch {47 // The write/delete already completed; stale-lock cleanup is non-fatal.48 }49}50 51function safeChannelName(channelName: string): string {52 const slug = channelName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 20) || '_';53 const hash = createHash('sha256')54 .update(channelName)55 .digest('hex')56 .slice(0, 16);57 return `${slug}-${hash}`;58}59 60function hashedThreadPath(target: ChannelMemoryTarget): string {61 return createHash('sha256')62 .update(target.chatId)63 .update('\0')64 .update(target.threadId ?? '')65 .digest('hex')66 .slice(0, 32);67}68 69export function getChannelMemoryFilePath(target: ChannelMemoryTarget): string {70 return path.join(71 Storage.getGlobalQwenDir(),72 'channels',73 'memory',74 safeChannelName(target.channelName),75 hashedThreadPath(target),76 CHANNEL_MEMORY_FILE_NAME,77 );78}79 80async function serializeAppend<T>(81 filePath: string,82 task: () => Promise<T>,83): Promise<T> {84 const previous = pendingAppends.get(filePath) ?? Promise.resolve();85 let release: () => void = () => {};86 const current = new Promise<void>((resolve) => {87 release = resolve;88 });89 const queued = previous.then(90 () => current,91 () => current,92 );93 pendingAppends.set(filePath, queued);94 95 await previous.catch(() => {});96 try {97 return await task();98 } finally {99 release();100 if (pendingAppends.get(filePath) === queued) {101 pendingAppends.delete(filePath);102 }103 }104}105 106export async function readChannelMemory(107 target: ChannelMemoryTarget,108): Promise<string> {109 const filePath = getChannelMemoryFilePath(target);110 return serializeAppend(filePath, async () => {111 let size: number;112 try {113 size = (await fs.stat(filePath)).size;114 } catch (error) {115 if (isMissingFile(error)) {116 return '';117 }118 throw error;119 }120 if (size > MAX_CHANNEL_MEMORY_BYTES) {121 process.stderr.write(122 `[channel-memory] ${filePath} is ${size} bytes, exceeding ${MAX_CHANNEL_MEMORY_BYTES}; treating as empty\n`,123 );124 return '';125 }126 try {127 return await fs.readFile(filePath, 'utf8');128 } catch (error) {129 if (isMissingFile(error)) {130 return '';131 }132 throw error;133 }134 });135}136 137export async function appendChannelMemory(138 target: ChannelMemoryTarget,139 text: string,140): Promise<ChannelMemoryWriteResult> {141 const filePath = getChannelMemoryFilePath(target);142 const entry = text.trim();143 if (!entry) {144 return { changed: false, filePath };145 }146 147 return serializeAppend(filePath, async () => {148 const appendBytes = Buffer.byteLength(`${entry}\n`, 'utf8');149 await fs.mkdir(path.dirname(filePath), { recursive: true });150 // proper-lockfile requires the target file to exist before locking it.151 const initialHandle = await fs.open(filePath, 'a+');152 await initialHandle.close();153 let release: () => Promise<void>;154 try {155 release = await lockfile.lock(filePath, LOCK_OPTIONS);156 } catch (error) {157 if (!isMissingFile(error)) {158 throw error;159 }160 const retryHandle = await fs.open(filePath, 'a+');161 await retryHandle.close();162 release = await lockfile.lock(filePath, LOCK_OPTIONS);163 }164 try {165 const handle = await fs.open(filePath, 'a+');166 try {167 const existingSize = (await handle.stat()).size;168 if (existingSize + appendBytes > MAX_CHANNEL_MEMORY_BYTES) {169 throw new Error('Channel memory exceeds maximum size');170 }171 await handle.appendFile(`${entry}\n`, 'utf8');172 } finally {173 await handle.close();174 }175 } finally {176 await releaseLock(release);177 }178 return { changed: true, filePath };179 });180}181 182export async function clearChannelMemory(183 target: ChannelMemoryTarget,184): Promise<ChannelMemoryWriteResult> {185 const filePath = getChannelMemoryFilePath(target);186 return serializeAppend(filePath, async () => {187 try {188 await fs.access(filePath);189 } catch (error) {190 if (isMissingFile(error)) {191 return { changed: false, filePath };192 }193 throw error;194 }195 196 let release: () => Promise<void>;197 try {198 release = await lockfile.lock(filePath, LOCK_OPTIONS);199 } catch (error) {200 if (isMissingFile(error)) {201 return { changed: false, filePath };202 }203 throw error;204 }205 try {206 await fs.unlink(filePath);207 return { changed: true, filePath };208 } catch (error) {209 if (isMissingFile(error)) {210 return { changed: false, filePath };211 }212 throw error;213 } finally {214 await releaseLock(release);215 }216 });217}218 