diff --git a/inc/js/api-functions.mjs b/inc/js/api-functions.mjs index d342d44..4ce8d94 100644 --- a/inc/js/api-functions.mjs +++ b/inc/js/api-functions.mjs @@ -20,10 +20,31 @@ async function availableExperiences(ctx){ mbr_id, } } +async function entry(ctx){ + await mAPIKeyValidation(ctx) + const { assistantType, mbr_id } = ctx.state + if(!ctx.request.body?.summary?.length) + throw new Error('No entry summary provided. Use `summary` field.') + console.log(chalk.yellowBright('entry()::entry attempted:'), ctx.request.body) + const summary = { + ...ctx.request.body, + assistantType, + mbr_id, + } + const entry = await ctx.MyLife.entry(summary) + console.log(chalk.yellowBright('entry()::entry submitted:'), entry, summary) + ctx.status = 200 + ctx.body = { + id: entry.id, + message: 'entry submitted successfully.', + success: true, + } + +} // @todo implement builder functionality, allowing for interface creation of experiences by members // @todo implement access to exposed member experiences using `mbr_key` as parameter to `factory.getItem()` async function experienceBuilder(ctx){ - mAPIKeyValidation(ctx) + await mAPIKeyValidation(ctx) const { assistantType, mbr_id } = ctx.state const { eid, sid } = ctx.params const { experience } = ctx.request.body?.experience @@ -54,7 +75,7 @@ async function experienceCast(ctx){ * @property {object} scene - Scene data, regardless if "current" or new. */ async function experience(ctx){ - mAPIKeyValidation(ctx) + await mAPIKeyValidation(ctx) const { MemberSession, } = ctx.state const { eid, } = ctx.params const { memberInput, } = ctx.request.body @@ -66,8 +87,8 @@ async function experience(ctx){ * @returns {Object} - Represents `ctx.body` object with following `experience` properties. * @property {boolean} success - Success status, true/false. */ -function experienceEnd(ctx){ - mAPIKeyValidation(ctx) +async function experienceEnd(ctx){ + await mAPIKeyValidation(ctx) const { MemberSession, } = ctx.state const { eid, } = ctx.params ctx.body = MemberSession.experienceEnd(eid) @@ -80,8 +101,8 @@ function experienceEnd(ctx){ * @property {Array} cast - Experience cast array. * @property {Object} navigation - Navigation object (optional - for interactive display purposes only). */ -function experienceManifest(ctx){ - mAPIKeyValidation(ctx) +async function experienceManifest(ctx){ + await mAPIKeyValidation(ctx) const { avatar, } = ctx.state ctx.body = avatar.manifest return @@ -89,8 +110,8 @@ function experienceManifest(ctx){ /** * Navigation array of scenes for experience. */ -function experienceNavigation(ctx){ - mAPIKeyValidation(ctx) +async function experienceNavigation(ctx){ + await mAPIKeyValidation(ctx) const { avatar, } = ctx.state ctx.body = avatar.navigation return @@ -103,18 +124,26 @@ function experienceNavigation(ctx){ * @property {array} experiences - Array of Experience shorthand objects. */ async function experiences(ctx){ - mAPIKeyValidation(ctx) + await mAPIKeyValidation(ctx) const { MemberSession, } = ctx.state // limit one mandatory experience (others could be highlighted in alerts) per session const experiencesObject = await MemberSession.experiences() ctx.body = experiencesObject } async function experiencesLived(ctx){ - mAPIKeyValidation(ctx) + await mAPIKeyValidation(ctx) const { MemberSession, } = ctx.state ctx.body = MemberSession.experiencesLived } -async function keyValidation(ctx){ // from openAI +/** + * Validates member key and returns member data. Leverages the key validation structure to ensure payload is liegimate. Currently in use by OpenAI GPT and local Postman instance. + * @param {Koa} ctx - Koa Context object + * @returns {object} - Object with following properties. + * @property {boolean} success - Success status. + * @property {string} message - Message to querying intelligence. + * @property {object} data - Consented Member data. + */ +async function keyValidation(ctx){ await mAPIKeyValidation(ctx) ctx.status = 200 // OK if(ctx.method === 'HEAD') return @@ -137,10 +166,10 @@ async function keyValidation(ctx){ // from openAI ?? names?.[0].split(' ')[0] ?? '', } - console.log(chalk.yellowBright(`keyValidation():${memberCoreData.mbr_id}`), memberCoreData.fullName) + console.log(chalk.yellowBright(`keyValidation()::`), chalk.redBright(`success::`), chalk.redBright(memberCoreData.mbr_id)) ctx.body = { success: true, - message: 'Valid member.', + message: 'Valid Member', data: memberCoreData, } } @@ -195,21 +224,25 @@ async function register(ctx){ /** * Functionality around story contributions. * @param {Koa} ctx - Koa Context object - * @returns {Koa} Koa Context object */ -async function story(ctx){ +async function memory(ctx){ await mAPIKeyValidation(ctx) // sets ctx.state.mbr_id and more const { assistantType, mbr_id } = ctx.state - const { storySummary } = ctx.request?.body??{} - if(!storySummary?.length) - ctx.throw(400, 'No story summary provided. Use `storySummary` field.') - // write to cosmos db - const _story = await ctx.MyLife.story(mbr_id, assistantType, storySummary) // @todo: remove await - console.log(chalk.yellowBright('story submitted:'), _story) - ctx.status = 200 // OK + if(!ctx.request.body?.summary?.length) + throw new Error('No memory summary provided. Use `summary` field.') + console.log(chalk.yellowBright('memory()::memory attempted:'), ctx.request.body) + const summary = { + ...ctx.request.body, + assistantType, + mbr_id, + } + const memory = await ctx.MyLife.memory(summary) + console.log(chalk.yellowBright('memory()::memory submitted:'), memory, summary) + ctx.status = 200 ctx.body = { + id: memory.id, + message: 'memory submitted successfully.', success: true, - message: 'Story submitted successfully.', } } /** @@ -220,7 +253,7 @@ async function story(ctx){ * @param {function} next Koa next function * @returns {function} Koa next function */ -async function tokenValidation(ctx, next) { +async function tokenValidation(ctx, next){ try { const authHeader = ctx.request.headers['authorization'] if(!authHeader){ @@ -228,13 +261,13 @@ async function tokenValidation(ctx, next) { ctx.body = { error: 'Authorization header is missing' } return } - const _token = authHeader.split(' ')[1] // Bearer TOKEN_VALUE - if(!mTokenValidation(_token)){ + const token = authHeader.split(' ')[1] // Bearer TOKEN_VALUE + if(!mTokenValidation(token)){ ctx.status = 401 ctx.body = { error: 'Authorization token failure' } return } - ctx.state.token = _token // **note:** keep first, as it is used in mTokenType() + ctx.state.token = token // **note:** keep first, as it is used in mTokenType() ctx.state.assistantType = mTokenType(ctx) await next() } catch (error) { @@ -283,19 +316,11 @@ async function mAPIKeyValidation(ctx){ // transforms ctx.state ctx.throw(400, 'Missing member key.') else // unlocked, providing passphrase return - let isValidated - if(!ctx.state.locked || ctx.session?.isAPIValidated){ - isValidated = true - } else { - const serverHostedMembers = JSON.parse(process.env.MYLIFE_HOSTED_MBR_ID ?? '[]') - const localHostedMembers = [ - 'system-one|4e6e2f26-174b-43e4-851f-7cf9cdf056df', - ].filter(member=>serverHostedMembers.includes(member)) // none currently - serverHostedMembers.push(...localHostedMembers) - if(serverHostedMembers.includes(memberId)) - isValidated = await ctx.MyLife.testPartitionKey(memberId) - } - if(isValidated){ + if( // validated + !ctx.state.locked + || ( ctx.session.isAPIValidated ?? false ) + || await ctx.MyLife.isMemberHosted(memberId) + ){ ctx.state.isValidated = true ctx.state.mbr_id = memberId ctx.state.assistantType = mTokenType(ctx) @@ -308,12 +333,13 @@ function mTokenType(ctx){ const assistantType = mBotSecrets?.[token] ?? 'personal-avatar' return assistantType } -function mTokenValidation(_token){ - return mBotSecrets?.[_token]?.length??false +function mTokenValidation(token){ + return mBotSecrets?.[token]?.length??false } /* exports */ export { availableExperiences, + entry, experience, experienceCast, experienceEnd, @@ -323,8 +349,8 @@ export { experiencesLived, keyValidation, logout, + memory, register, - story, tokenValidation, upload, } \ No newline at end of file diff --git a/inc/js/core.mjs b/inc/js/core.mjs index 9847862..41a84fd 100644 --- a/inc/js/core.mjs +++ b/inc/js/core.mjs @@ -241,6 +241,7 @@ class MyLife extends Organization { // form=server this.#avatar = await this.factory.getAvatar() return await super.init(this.#avatar) } + /* public functions */ /** * Retrieves all public experiences (i.e., owned by MyLife). * @returns {Object[]} - An array of the currently available public experiences. @@ -262,7 +263,22 @@ class MyLife extends Organization { // form=server if(!_mbr_id || _mbr_id===this.mbr_id) throw new Error('datacore cannot be accessed') return await this.factory.datacore(_mbr_id) } - /* public functions */ + /** + * Submits and returns the journal or diary entry to MyLife via API. + * @public + * @todo - consent check-in with spawned Member Avatar + * @param {object} summary - Object with story summary and metadata + * @returns {object} - The story document from Cosmos. + */ + async entry(summary){ + if(!summary.mbr_id?.length) + throw new Error('entry `mbr_id` required') + if(!summary.summary?.length) + throw new Error('entry `summary` required') + summary.being = 'entry' + summary.form = summary.form ?? 'journal' + return await this.summary(summary) + } /** * Server MyLife _Maht instantiation uses this function to populate the most current alerts in the modular factory memoryspace. Currently only applicable to system types, but since this is implemented at the `core.mjs` scope, we can account * @public @@ -274,6 +290,10 @@ class MyLife extends Organization { // form=server async getMyLifeSession(){ return await this.factory.getMyLifeSession() } + async hostedMemberList(){ + let members = await this.hostedMembers() + return members.map(member=>member.mbr_id) + } /** * Returns Array of hosted members based on validation requirements. * @param {Array} validations - Array of validation strings to filter membership. @@ -282,6 +302,20 @@ class MyLife extends Organization { // form=server async hostedMembers(validations){ return await this.factory.hostedMembers(validations) } + /** + * Returns whether a specified member id is hosted on this instance. + * @param {string} memberId - Member id + * @returns {boolean} - Returns true if member is hosted + */ + async isMemberHosted(memberId){ + const hostedMembers = await this.hostedMemberList() + const isHosted = hostedMembers.includes(memberId) + let isValidated = false + if(isHosted) + isValidated = await this.testPartitionKey(memberId) + console.log('isMemberHosted:', isHosted, isValidated, memberId) + return isValidated + } /** * Registers a new candidate to MyLife membership * @public @@ -291,26 +325,44 @@ class MyLife extends Organization { // form=server return await this.factory.registerCandidate(candidate) } /** - * Submits a story to MyLife via API. Unclear if can be dual-purposed for internal, or if internal still instantiates API context. + * Submits and returns the memory to MyLife via API. * @public - * @param {string} _mbr_id - Member id - * @param {string} _assistantType - String name of assistant type - * @param {string} _summary - String summary of story + * @todo - consent check-in with spawned Member Avatar + * @param {object} summary - Object with story summary and metadata + * @returns {object} - The story document from Cosmos. + */ + async memory(summary){ + if(!summary.mbr_id?.length) + throw new Error('story `mbr_id` required') + if(!summary.summary?.length) + throw new Error('story `summary` required') + summary.being = 'story' + summary.form = 'memory' + return await this.summary(summary) // @todo - convert modular + } + /** + * Submits and returns a summary to MyLife via API. + * @param {object} summary - Object with story summary and metadata * @returns {object} - The story document from Cosmos. */ - async story(_mbr_id, _assistantType, storySummary){ - const id = this.globals.newGuid - const _story = { - assistantType: _assistantType, - being: 'story', - form: _assistantType, + async summary(summary){ + const { + being='story', + form='story', + id=this.globals.newGuid, + mbr_id, + title=`untitled ${ form }`, + } = summary + const story = { + ...summary, + being, + form, id, - mbr_id: _mbr_id, - name: `story_${_assistantType}_${_mbr_id}`, - summary: storySummary, + mbr_id, + name: `${ being }_${ title.substring(0,64) }_${ mbr_id }`, } - const _storyCosmos = await this.factory.story(_story) - return this.globals.stripCosmosFields(_storyCosmos) + const savedStory = this.globals.stripCosmosFields(await this.factory.summary(story)) + return savedStory } /** * Tests partition key for member diff --git a/inc/js/factory-class-extenders/class-conversation-functions.mjs b/inc/js/factory-class-extenders/class-conversation-functions.mjs index 550a857..d3d642a 100644 --- a/inc/js/factory-class-extenders/class-conversation-functions.mjs +++ b/inc/js/factory-class-extenders/class-conversation-functions.mjs @@ -1,8 +1,9 @@ -async function mSaveConversation(_factory, _conversation){ - const { thread, messages, ..._retainedProperties} = _conversation.inspect(true) - _retainedProperties.thread = _conversation.thread - _retainedProperties.messages = [] // populated separately as unshifted array to cosmos - await _factory.dataservices.pushItem(_retainedProperties) +async function mSaveConversation(factory, conversation){ + const { thread, messages, ...properties} = conversation.inspect(true) + properties.thread = conversation.thread + properties.messages = [] // populated separately as unshifted array to cosmos + const savedConversation = await factory.dataservices.pushItem(properties) + console.log('mSaveConversation', savedConversation) } export { mSaveConversation, diff --git a/inc/js/factory-class-extenders/class-extenders.mjs b/inc/js/factory-class-extenders/class-extenders.mjs index 02a8b3c..9fd3adb 100644 --- a/inc/js/factory-class-extenders/class-extenders.mjs +++ b/inc/js/factory-class-extenders/class-extenders.mjs @@ -129,6 +129,7 @@ function extendClass_conversation(originClass, referencesObject) { #messages = [] #saved = false #thread + #threads = new Set() /** * * @param {Object} obj - The object to construct the conversation from. @@ -143,7 +144,7 @@ function extendClass_conversation(originClass, referencesObject) { this.bot_id = bot_id this.form = this.form ?? 'system' - this.name = `conversation_${this.#factory.mbr_id}_${thread.thread_id}` + this.name = `conversation_${this.#factory.mbr_id}` this.type = this.type ?? 'chat' } @@ -178,6 +179,14 @@ function extendClass_conversation(originClass, referencesObject) { .forEach(message => this.addMessage(message)) return this.messages } + /** + * Adds a thread id to the conversation archive + * @param {string} thread_id - The thread id to add + * @returns {void} + */ + addThread(thread_id){ + this.#threads.add(thread_id) + } /** * Get the message by id, or defaults to last message added. * @public @@ -189,18 +198,30 @@ function extendClass_conversation(originClass, referencesObject) { ? this.messages.find(message=>message.id===messageId) : this.message } + /** + * Removes a thread id from the conversation archive + * @param {string} thread_id - The thread id to remove + * @returns {void} + */ + removeThread(thread_id){ + this.#threads.delete(thread_id) + } + setThread(thread){ + const { id: thread_id, } = thread + if(thread_id?.length && thread_id!=this.thread_id){ + this.#threads.add(this.thread_id) + this.#thread = thread + } + } async save(){ - // also if this not saved yet, save to cosmos - if(!this.isSaved){ + if(!this.isSaved) // create new MyLife conversation await mSaveConversation(this.#factory, this) - } - // save messages to cosmos // @todo: no need to await - await this.#factory.dataservices.patch( + const messages = this.messages.map(_msg=>_msg.micro) + const dataUpdate = await this.#factory.dataservices.patch( this.id, - { messages: this.messages.map(_msg=>_msg.micro), } + { messages, } ) - // flag as saved this.#saved = true return this } @@ -247,12 +268,18 @@ function extendClass_conversation(originClass, referencesObject) { get thread(){ return this.#thread } + set thread(thread){ + this.setThread(thread) + } get thread_id(){ return this.thread.id } get threadId(){ return this.thread_id } + get threads(){ + return this.#threads + } } return Conversation } diff --git a/inc/js/functions.mjs b/inc/js/functions.mjs index 774da6b..ecc1b19 100644 --- a/inc/js/functions.mjs +++ b/inc/js/functions.mjs @@ -232,6 +232,21 @@ async function loginSelect(ctx){ async function members(ctx){ // members home await ctx.render('members') } +async function migrateBot(ctx){ + const { bid, } = ctx.params + const { avatar, } = ctx.state + ctx.body = await avatar.migrateBot(bid) +} +async function migrateChat(ctx){ + const { tid, } = ctx.params + const { avatar, } = ctx.state + ctx.body = await avatar.migrateChat(tid) +} +/** + * Reset the passphrase for the member's avatar. + * @param {Koa} ctx - Koa Context object + * @returns {boolean} - Whether or not passpharase successfully reset + */ async function passphraseReset(ctx){ const { avatar, } = ctx.state if(avatar?.isMyLife ?? true) @@ -241,11 +256,39 @@ async function passphraseReset(ctx){ ctx.throw(400, `passphrase required for reset`) ctx.body = await avatar.resetPassphrase(passphrase) } +/** + * Display the privacy policy page - ensure it can work in member view. + * @param {Koa} ctx - Koa Context object + */ async function privacyPolicy(ctx){ ctx.state.title = `MyLife Privacy Policy` ctx.state.subtitle = `Effective Date: 2024-01-01` await ctx.render('privacy-policy') // privacy-policy } +/** + * Direct request from member to retire a bot. + * @param {Koa} ctx - Koa Context object + */ +async function retireBot(ctx){ + const { avatar, } = ctx.state + const { bid, } = ctx.params // bot id + if(!ctx.Globals.isValidGuid(bid)) + ctx.throw(400, `missing bot id`) + const response = await avatar.retireBot(bid) + ctx.body = response +} +/** + * Direct request from member to retire a conversation/chat/thread. + * @param {Koa} ctx - Koa Context object + */ +async function retireChat(ctx){ + const { avatar, } = ctx.state + const { tid, } = ctx.params // thread_id + if(!tid?.length) + ctx.throw(400, `missing thread id`) + const response = await avatar.retireChat(tid) + ctx.body = response +} /** * Gets the list of shadows. * @returns {Object[]} - Array of shadow objects. @@ -371,8 +414,12 @@ export { logout, loginSelect, members, + migrateBot, + migrateChat, passphraseReset, privacyPolicy, + retireBot, + retireChat, shadows, signup, summarize, diff --git a/inc/js/mylife-agent-factory.mjs b/inc/js/mylife-agent-factory.mjs index 90a2be9..71f3cdb 100644 --- a/inc/js/mylife-agent-factory.mjs +++ b/inc/js/mylife-agent-factory.mjs @@ -623,25 +623,29 @@ class AgentFactory extends BotFactory { return await this.dataservices.getItem(id) } async entry(entry){ - const { + const defaultType = 'entry' + const { assistantType='journaler', - being='entry', + being=defaultType, form='journal', + id=this.newGuid, keywords=[], + mbr_id=(!this.isMyLife ? this.mbr_id : undefined), summary, - thread_id, - title='New Journal Entry', + title=`New ${ defaultType }`, } = entry + if(!mbr_id) // only triggered if not MyLife server + throw new Error('mbr_id required for entry summary') + let { name, } = entry + name = name ?? `${ defaultType }_${ form }_${ title.substring(0,64) }_${ mbr_id }` if(!summary?.length) throw new Error('entry summary required') - const { mbr_id, newGuid: id, } = this - const name = `entry_${ title.substring(0,64) }_${ mbr_id }_${ id }` /* assign default keywords */ - if(!keywords.includes('memory')) - keywords.push('memory') - if(!keywords.includes('biographer')) - keywords.push('biographer') - const completeEntry = { + if(!keywords.includes('entry')) + keywords.push('entry') + if(!keywords.includes('journal')) + keywords.push('journal') + const _entry = { ...entry, ...{ assistantType, @@ -652,10 +656,9 @@ class AgentFactory extends BotFactory { mbr_id, name, summary, - thread_id, title, }} - return await this.dataservices.entry(completeEntry) + return await this.dataservices.pushItem(_entry) } async getAlert(_alert_id){ const _alert = mAlerts.system.find(alert => alert.id === _alert_id) @@ -763,31 +766,35 @@ class AgentFactory extends BotFactory { return savedExperience } /** - * Submits a story to MyLife. Currently via API, but could be also work internally. - * @param {object} story - Story object. - * @returns {object} - The story document from Cosmos. + * Submits a story to MyLife. Currently called both from API _and_ LLM function. + * @param {object} story - Story object + * @returns {object} - The story document from Cosmos */ async story(story){ + const defaultType = 'story' const { - assistantType='biographer-bot', - being='story', - form='biographer', + assistantType='biographer', + being=defaultType, + form=defaultType, + id=this.newGuid, keywords=[], + mbr_id=(!this.isMyLife ? this.mbr_id : undefined), phaseOfLife='unknown', summary, - thread_id, - title='New Memory Entry', + title=`New ${ defaultType }`, } = story + if(!mbr_id) // only triggered if not MyLife server + throw new Error('mbr_id required for story summary') + let { name, } = story + name = name ?? `${ defaultType }_${ form }_${ title.substring(0,64) }_${ mbr_id }` if(!summary?.length) throw new Error('story summary required') - const { mbr_id, newGuid: id, } = this - const name = `story_${ title.substring(0,64) }_${ mbr_id }_${ id }` /* assign default keywords */ if(!keywords.includes('memory')) keywords.push('memory') if(!keywords.includes('biographer')) keywords.push('biographer') - const validatedStory = { + const _story = { // add validated fields back into `story` object ...story, ...{ assistantType, @@ -799,10 +806,21 @@ class AgentFactory extends BotFactory { name, phaseOfLife, summary, - thread_id, title, }} - return await this.dataservices.story(validatedStory) + return await this.dataservices.pushItem(_story) + } + async summary(summary){ + const { being='story', } = summary + switch(being){ + case 'entry': + return await this.entry(summary) + case 'memory': + case 'story': + return await this.story(summary) + default: + throw new Error('summary being not recognized') + } } /** * Tests partition key for member diff --git a/inc/js/mylife-avatar.mjs b/inc/js/mylife-avatar.mjs index 908438f..1f13e4d 100644 --- a/inc/js/mylife-avatar.mjs +++ b/inc/js/mylife-avatar.mjs @@ -70,7 +70,7 @@ class Avatar extends EventEmitter { return this } /** - * Get a bot. + * Get a bot's properties from Cosmos (or type in .bots). * @public * @async * @param {Guid} id - The bot id. @@ -206,7 +206,7 @@ class Avatar extends EventEmitter { const conversation = new (this.#factory.conversation)({ mbr_id: this.mbr_id, type, }, this.#factory, thread, botId) if(saveToConversations){ this.#conversations.push(conversation) - console.log('createConversation::saving into local memory', conversation.thread_id) + console.log(`Avatar::createConversation::saving into local memory, thread: ${ threadId }; bot.id: ${ botId }`) } return conversation } @@ -340,22 +340,32 @@ class Avatar extends EventEmitter { this.#experienceGenericVariables ) } + getBot(id){ + const bot = this.bots.find(bot=>bot.id===id) + return bot + ?? this.activeBot + } /** * Gets Conversation object. If no thread id, creates new conversation. - * @param {string} threadId - openai thread id - * @param {string} type - Type of conversation: chat, experience, dialog, inter-system, etc. + * @param {string} threadId - openai thread id (optional) + * @param {Guid} botId - The bot id (optional) * @returns {Conversation} - The conversation object. */ - getConversation(threadId){ + getConversation(threadId, botId){ const conversation = this.#conversations - .find(conversation=>conversation.thread?.id===threadId) + .filter(c=>(threadId?.length && c.thread_id===threadId) || (botId?.length && c.botId===botId)) + ?.[0] return conversation } /** * Returns all conversations of a specific-type stored in memory. * @param {string} type - Type of conversation: chat, experience, dialog, inter-system, etc.; defaults to `chat`. - * @retu .replace(/\[.*?†.*?\]/gs, '') // This line removes OpenAI LLM "source" referencesLM "source" references.#conversations + * @returns {Conversation[]} - The array of conversation objects. + */ + getConversations(type='chat'){ + return this.conversations .filter(_=>_?.type===type) + .map(conversation=>(mPruneConversation(conversation))) } /** * Get a static or dynamic greeting from active bot. @@ -365,41 +375,6 @@ class Avatar extends EventEmitter { async getGreeting(dynamic=false){ return await mGreeting(this.activeBot, dynamic, this.#llmServices, this.#factory) } - /** - * Request help about MyLife. **caveat** - correct avatar should have been selected prior to calling. - * @param {string} helpRequest - The help request text. - * @param {string} type - The type of help request. - * @returns {Promise} - openai `message` objects. - */ - async help(helpRequest, type){ - const processStartTime = Date.now() - if(!helpRequest?.length) - throw new Error('Help request required.') - // @stub - force-type into enum? - helpRequest = mHelpIncludePreamble(type, this.isMyLife) + helpRequest - const { thread_id, } = this.activeBot - const { bot_id, } = this.helpBots?.find(bot=>(bot?.subType ?? bot?.sub_type ?? bot?.subtype)===type) - ?? this.helpBots?.[0] - ?? this.activeBot - const conversation = this.getConversation(thread_id) - const helpResponseArray = await this.factory.help(thread_id, bot_id, helpRequest) - conversation.addMessages(helpResponseArray) - if(mAllowSave) - conversation.save() - else - console.log('help::BYPASS-SAVE', conversation.message.content) - const response = mPruneMessages(this.activeBot, helpResponseArray, 'help', processStartTime) - return response - } - /** - * Returns all conversations of a specific-type stored in memory. - * @param {string} type - Type of conversation: chat, experience, dialog, inter-system, etc.; defaults to `chat`. - * @returns {Conversation[]} - The array of conversation objects. - */ - getConversations(type='chat'){ - return this.#conversations - .filter(_=>_?.type===type) - } /** * Request help about MyLife. **caveat** - correct avatar should have been selected prior to calling. * @param {string} helpRequest - The help request text. @@ -462,6 +437,79 @@ class Avatar extends EventEmitter { success, } } + /** + * Migrates a bot to a new, presumed combined (with internal or external) bot. + * @param {Guid} botId - The bot id. + * @returns + */ + async migrateBot(botId){ + const bot = this.getBot(botId) + if(!bot) + throw new Error(`Bot not found with id: ${ botId }`) + const { id, } = bot + if(botId!==id) + throw new Error(`Bot id mismatch: ${ botId }!=${ id }`) + return bot + } + /** + * Migrates a chat conversation from an old thread to a newly created (or identified) destination thread. + * @param {string} thread_id - Conversation thread id in OpenAI + * @returns {Conversation} - The migrated conversation object + */ + async migrateChat(thread_id){ + /* MyLife conversation re-assignment */ + const conversation = this.getConversation(thread_id) + if(!conversation) + throw new Error(`Conversation not found with thread_id: ${ thread_id }`) + const messages = (await this.#llmServices.messages(thread_id)) + .slice(0, 12) + .map(message=>{ + const { content: contentArray, id, metadata, role, } = message + const content = contentArray + .filter(_content=>_content.type==='text') + .map(_content=>_content.text?.value) + ?.[0] + return { content, metadata, role, } + }) + const { botId, } = conversation + const bot = this.getBot(botId) + // @todo - switch modular function by bot type, therefore conversation collection-type + const memories = ( await this.collections('story') ) + .sort((a, b)=>a._ts-b._ts) + .slice(0, 12) + const memoryList = memories + .map(memory=>`- itemId: ${ memory.id } :: ${ memory.title }`) + .join('\n') + const memoryCollectionList = memories + .map(memory=>memory.id) + .join(',') + .slice(0, 512) + messages.push({ + content: `## MEMORY COLLECTION LIST\n${ memoryList }`, // insert actual memory list with titles here for intelligence to reference + metadata: { + collectionList: memoryCollectionList, + collectiontypes: 'memory,story,narrative', + }, + role: 'assistant', + }) // add summary of Memories (etc. due to type) for intelligence to reference, also could add attachment file + const metadata = { + bot_id: botId, + conversation_id: conversation.id, + } + const newThread = await this.#llmServices.thread(null, messages.reverse(), metadata) + conversation.setThread(newThread) + bot.thread_id = conversation.thread_id + const _bot = { + id: bot.id, + thread_id: bot.thread_id, + } + await this.#factory.updateBot(_bot) + if(mAllowSave) + conversation.save() + else + console.log('migrateChat::BYPASS-SAVE', conversation.thread_id) + return conversation + } /** * Register a candidate in database. * @param {object} candidate - The candidate data object. @@ -500,6 +548,40 @@ class Avatar extends EventEmitter { throw new Error('Passphrase required for reset.') return await this.#factory.resetPassphrase(passphrase) } + /** + * Member request to retire a bot. + * @param {Guid} botId - The bot id. + * @returns {object} - The retired bot object. + */ + async retireBot(botId){ + const bot = this.getBot(botId) + if(!bot) + throw new Error(`Bot not found with id: ${ botId }`) + const { id, } = bot + if(botId!==id) + throw new Error(`Bot id mismatch: ${ botId }!=${ id }`) + mDeleteBot(bot, this.#bots, this.#llmServices) + return bot + } + /** + * Member-request to retire a chat conversation. + * @param {string} thread_id - Conversation thread id in OpenAI + * @returns {Conversation} - The retired conversation object. + */ + async retireChat(thread_id){ + const conversation = this.getConversation(thread_id) + if(!conversation) + throw new Error(`Conversation not found with thread_id: ${ thread_id }`) + const { botId, thread_id: cid, } = conversation + const bot = this.getBot(botId) + const { id: _botId, thread_id: tid, } = bot + if(botId!=_botId) + throw new Error(`Bot id mismatch: ${ botId }!=${ bot_id }`) + if(tid!=thread_id || cid!=thread_id) + throw new Error(`Conversation mismatch: ${ tid }!=${ thread_id } || ${ cid }!=${ thread_id }`) + mDeleteConversation(conversation, this.conversations, bot, this.#factory, this.#llmServices) + return conversation + } /** * Takes a shadow message and sends it to the appropriate bot for response, returning the standard array of bot responses. * @param {Guid} shadowId - The shadow id. @@ -623,10 +705,11 @@ class Avatar extends EventEmitter { return this.#factory.teams() } async thread_id(){ - if(!this.#conversations.length){ + if(!this.conversations.length){ await this.createConversation() + console.log('Avatar::thread_id::created new conversation', this.conversations[0].thread_id) } - return this.#conversations[0].threadId + return this.conversations[0].threadId } /** * Update a specific bot. @@ -1311,6 +1394,7 @@ function mAvatarDropdown(globals, avatar){ /** * Validates and cleans bot object then updates or creates bot (defaults to new personal-avatar) in Cosmos and returns successful `bot` object, complete with conversation (including thread/thread_id in avatar) and gpt-assistant intelligence. * @todo Fix occasions where there will be no object_id property to use, as it was created through a hydration method based on API usage, so will be attached to mbr_id, but NOT avatar.id + * @todo - Turn this into Bot class * @module * @param {AgentFactory} factory - Agent Factory object * @param {Avatar} avatar - Avatar object that will govern bot @@ -1338,12 +1422,13 @@ async function mBot(factory, avatar, bot){ }, {}) /* create or update bot special properties */ const { thread_id, type, } = originBot // @stub - `bot_id` cannot be updated through this mechanic - if(!thread_id?.length && !avatar.isMyLife){ // add thread_id to relevant bots + if(!thread_id?.length && !avatar.isMyLife){ const excludeTypes = ['collection', 'library', 'custom'] // @stub - custom mechanic? if(!excludeTypes.includes(type)){ - const conversation = await avatar.createConversation() + const conversation = avatar.getConversation(null, botId) + ?? await avatar.createConversation('chat', null, botId) updatedBot.thread_id = conversation.thread_id // triggers `factory.updateBot()` - avatar.conversations.push(conversation) + console.log('Avatar::mBot::conversation created given NO thread_id', updatedBot.thread_id, avatar.getConversation(updatedBot.thread_id)) } } let updatedOriginBot @@ -1461,12 +1546,59 @@ function mCreateSystemMessage(activeBot, message, factory){ message = mPruneMessage(activeBot, message, 'system') return message } +async function mDeleteBot(bot, bots, llm){ + const cannotRetire = ['actor', 'system', 'personal-avatar'] + const { bot_id, id, type, } = bot + if(cannotRetire.includes(type)) + throw new Error(`Cannot retire bot type: ${ type }`) + /* delete from memory */ + const botId = bots.findIndex(_bot=>_bot.id===id) + if(botId<0) + throw new Error('Bot not found in bots.') + bots.splice(botId, 1) + /* delete bot from Cosmos */ + const deletedBot = await factory.deleteItem(id) + /* delete bot from OpenAI */ + const deletedLLMBot = await factory.deleteBot(bot_id) + console.log('mDeleteBot', deletedBot, deletedLLMBot) + return true +} +/** + * Deletes conversation and updates + * @param {Conversation} conversation - The conversation object + * @param {Conversation[]} conversations - The conversations array + * @param {Object} bot - The bot involved in the conversation + * @param {AgentFactory} factory - Agent Factory object + * @param {LLMServices} llm - OpenAI object + * @returns {Promise} - `true` if successful + */ +async function mDeleteConversation(conversation, conversations, bot, factory, llm){ + const { id, } = conversation + /* delete conversation from memory */ + const conversationId = conversations.findIndex(_conversation=>_conversation.id===id) + if(conversationId<0) + throw new Error('Conversation not found in conversations.') + conversations.splice(conversationId, 1) + /* delete thread_id from bot and save to Cosmos */ + bot.thread_id = '' + const { id: botId, thread_id, } = bot + factory.updateBot({ + id: botId, + thread_id, + }) + /* delete conversation from Cosmos */ + const deletedConversation = await factory.deleteItem(conversation.id) + /* delete thread from LLM */ + const deletedThread = await llm.deleteThread(thread_id) + console.log('mDeleteConversation', conversation.id, deletedConversation, thread_id, deletedThread) + return true +} /** * Takes character data and makes necessary adjustments to roles, urls, etc. * @todo - icon and background changes * @todo - bot changes... allowed? - * @param {LLMServices} llm - OpenAI object. - * @param {Experience} experience - Experience class instance. + * @param {LLMServices} llm - OpenAI object + * @param {Experience} experience - Experience class instance * @param {Object} character - Synthetic character object */ async function mEventCharacter(llm, experience, character){ @@ -2015,15 +2147,14 @@ async function mInit(factory, llmServices, avatar, bots, assetAgent){ ?? avatar.names?.[0] ?? `${avatar.memberFirstName ?? 'member'}'s avatar` /* vectorstore */ - if(!vectorstore_id){ // run once if not erroring + if(!vectorstore_id){ const vectorstore = await llmServices.createVectorstore(mbr_id) if(vectorstore?.id){ avatar.vectorstore_id = vectorstore.id // also sets vectorstore_id in Cosmos await assetAgent.init(avatar.vectorstore_id) - console.log('avatar::init()::createVectorStore()', avatar.vectorstore_id) } } - /* bots */ // @stub - determine by default or activated team + /* bots */ requiredBotTypes.push('personal-biographer') // default memory team } bots.push(...await factory.bots(avatar.id)) @@ -2040,15 +2171,21 @@ async function mInit(factory, llmServices, avatar, bots, assetAgent){ /* conversations */ await Promise.all( bots.map(async bot=>{ - const { thread_id, } = bot - if(!avatar.getConversation(thread_id)){ - const conversation = await avatar.createConversation('chat', thread_id,) - avatar.updateBot(bot, conversation) - avatar.conversations.push(conversation) + const { id: botId, thread_id, type, } = bot + /* exempt certain types */ + const excludedMemberTypes = ['library', 'ubi'] + if(factory.isMyLife && type!=='personal-avatar') + return + else if(excludedMemberTypes.includes(type)) + return + if(!avatar.getConversation(thread_id, botId)){ + const conversation = await avatar.createConversation('chat', thread_id, botId) + avatar.updateBot(bot) + if(!avatar.getConversation(thread_id)) // may happen in cases of MyLife? others? + avatar.conversations.push(conversation) } }) ) - // need to create new conversation for Q variant, but also need to ensure that no thread_id is saved for Q /* evolver */ if(!factory.isMyLife) avatar.evolver = await (new EvolutionAssistant(avatar)) @@ -2103,6 +2240,17 @@ function mPruneBot(assistantData){ type, } } +function mPruneConversation(conversation){ + const { bot_id, form, id, name, thread_id, type, } = conversation + return { + bot_id, + form, + id, + name, + thread_id, + type, + } +} /** * Returns a frontend-ready object, pruned of cosmos database fields. * @param {object} document - The document object to prune. diff --git a/inc/js/mylife-data-service.js b/inc/js/mylife-data-service.js index 971a6b7..fc6640c 100644 --- a/inc/js/mylife-data-service.js +++ b/inc/js/mylife-data-service.js @@ -297,19 +297,26 @@ class Dataservices { * @async * @public * @param {Guid} id - The id of the item to delete. + * @param {boolean} bSuppressError - Suppress error, default: `true` * @returns {boolean} - true if item deleted successfully. */ - async deleteItem(id){ - return await this.datamanager.deleteItem(id) - } - /** - * Submits an entry to MyLife. Currently via API, but could be also work internally. - * @param {object} entry - Entry object. - * @returns {object} - The entry document from Cosmos. - */ - async entry(entry){ - const entryItem = await this.datamanager.pushItem(entry) - return entryItem + async deleteItem(id, bSuppressError=true){ + if(!id?.length) + throw new Error('ERROR::deleteItem::Item `id` required') + let success=false + if(bSuppressError){ + try{ + const response = await this.datamanager.deleteItem(id) + console.log('mylife-data-service::deleteItem() response', response) + success = response?.id===id + } catch(err){ + console.log('mylife-data-service::deleteItem() ERROR', err.code) // NotFound + } + } else { + await this.datamanager.deleteItem(id) + success = true + } + return success } async findRegistrationIdByEmail(_email){ /* pull record for email, returning id or new guid */ @@ -630,15 +637,6 @@ class Dataservices { const savedExperience = await this.pushItem(experience) return savedExperience } - /** - * Submits a story to MyLife. Currently via API, but could be also work internally. - * @param {object} story - Story object. - * @returns {object} - The story document from Cosmos. - */ - async story(story){ - const storyItem = await this.datamanager.pushItem(story) - return storyItem - } /** * Tests partition key for member * @public diff --git a/inc/js/mylife-llm-services.mjs b/inc/js/mylife-llm-services.mjs index 217dfe7..a52646c 100644 --- a/inc/js/mylife-llm-services.mjs +++ b/inc/js/mylife-llm-services.mjs @@ -53,6 +53,38 @@ class LLMServices { }) return vectorstore } + /** + * Deletes an assistant from OpenAI. + * @param {string} botId - GPT-Assistant external ID + * @returns + */ + async deleteBot(botId){ + try { + const deletedBot = await this.openai.beta.assistants.del(botId) + return deletedBot + } catch (error) { + if(error.name==='PermissionDeniedError') + console.error(`Permission denied to delete assistant: ${ botId }`) + else + console.error(`ERROR trying to delete assistant: ${ botId }`, error.name, error.message) + } + } + /** + * Deletes a thread from OpenAI. + * @param {string} thread_id - Thread id. + * @returns + */ + async deleteThread(thread_id){ + try { + const deletedThread = await this.openai.beta.threads.del(thread_id) + return deletedThread + } catch (error) { + if(error.name==='PermissionDeniedError') + console.error(`Permission denied to delete thread: ${ thread_id }`) + else + console.error(`ERROR trying to delete thread: ${ thread_id }`, error.name, error.message) + } + } /** * Returns openAI file object. * @param {string} fileId - OpenAI file ID. @@ -85,31 +117,42 @@ class LLMServices { await mAssignRequestToThread(this.openai, threadId, prompt) const run = await mRunTrigger(this.openai, botId, threadId, factory, avatar) const { assistant_id, id: run_id, model, provider='openai', required_action, status, usage } = run - const llmMessageObject = await mMessages(this.provider, threadId) - const { data: llmMessages} = llmMessageObject + const llmMessages = await this.messages(threadId) return llmMessages .filter(message=>message.role=='assistant' && message.run_id==run_id) } /** * Given member request for help, get response from specified bot assistant. - * @param {string} threadId - Thread id. + * @param {string} thread_id - Thread id. * @param {string} botId - GPT-Assistant/Bot id. * @param {string} helpRequest - Member input. * @param {AgentFactory} factory - Avatar Factory object to process request. * @param {Avatar} avatar - Avatar object. * @returns {Promise} - openai `message` objects. */ - async help(threadId, botId, helpRequest, factory, avatar){ - const helpResponse = await this.getLLMResponse(threadId, botId, helpRequest, factory, avatar) + async help(thread_id, botId, helpRequest, factory, avatar){ + const helpResponse = await this.getLLMResponse(thread_id, botId, helpRequest, factory, avatar) return helpResponse } + /** + * Returns messages associated with specified thread. + * @param {string} thread_id - Thread id + * @returns {Promise} - Array of openai `message` objects. + */ + async messages(thread_id){ + const { data: messages } = await mMessages(this.provider, thread_id) + return messages + } /** * Create a new OpenAI thread. - * @param {string} threadId - thread id + * @param {string} thread_id - thread id + * @param {Message[]} messages - array of messages (optional) + * @param {object} metadata - metadata object (optional) * @returns {Promise} - openai thread object */ - async thread(threadId){ - return await mThread(this.openai, threadId) + async thread(thread_id, messages=[], metadata){ + const thread = await mThread(this.openai, thread_id, messages, metadata) + return thread } /** * Updates assistant with specified data. Example: Tools object for openai: { tool_resources: { file_search: { vector_store_ids: [vectorStore.id] } }, }; https://platform.openai.com/docs/assistants/tools/file-search/quickstart?lang=node.js @@ -586,14 +629,33 @@ async function mRunTrigger(openai, botId, threadId, factory, avatar){ * @todo - create case for failure in thread creation/retrieval * @module * @param {OpenAI} openai - openai object - * @param {string} threadId - thread id + * @param {string} thread_id - thread id + * @param {Message[]} messages - array of messages (optional) + * @param {object} metadata - metadata object (optional) * @returns {Promise} - openai thread object */ -async function mThread(openai, threadId){ - if(threadId?.length) - return await openai.beta.threads.retrieve(threadId) +async function mThread(openai, thread_id, messages=[], metadata){ + if(thread_id?.length) + return await openai.beta.threads.retrieve(thread_id) else - return await openai.beta.threads.create() + return mThreadCreate(openai, messages, metadata) +} +/** + * Create an OpenAI thread. + * @module + * @async + * @param {OpenAI} openai - openai object + * @param {Message[]} messages - array of messages (optional) + * @param {object} metadata - metadata object (optional) + * @returns {object} - openai `thread` object + */ +async function mThreadCreate(openai, messages, metadata){ + const thread = await openai.beta.threads.create({ + messages, + metadata, + tool_resources: {}, + }) + return thread } /** * Validates assistant data before sending to OpenAI. diff --git a/inc/js/routes.mjs b/inc/js/routes.mjs index e2e7235..d0f3e6c 100644 --- a/inc/js/routes.mjs +++ b/inc/js/routes.mjs @@ -19,8 +19,12 @@ import { logout, loginSelect, members, + migrateBot, + migrateChat, passphraseReset, privacyPolicy, + retireBot, + retireChat, shadows, signup, summarize, @@ -38,6 +42,7 @@ import { } from './memory-functions.mjs' import { availableExperiences, + entry, experience, experienceCast, experienceEnd, @@ -47,8 +52,8 @@ import { experiencesLived, keyValidation, logout as apiLogout, + memory, register, - story, tokenValidation, } from './api-functions.mjs' // variables @@ -85,9 +90,10 @@ _apiRouter.patch('/experiences/:mid/experience/:eid/manifest', experienceManifes _apiRouter.patch('/experiences/:mid/experience/:eid/navigation', experienceNavigation) _apiRouter.patch('/experiences/:mid/experience/:eid', experience) // **note**: This line should be the last one alphabetically due to the wildcard. _apiRouter.post('/challenge/:mid', challenge) +_apiRouter.post('/entry/:mid', entry) _apiRouter.post('/keyValidation/:mid', keyValidation) +_apiRouter.post('/memory/:mid', memory) _apiRouter.post('/register', register) -_apiRouter.post('/story/:mid', story) _apiRouter.post('/upload', upload) _apiRouter.post('/upload/:mid', upload) /* member routes */ @@ -115,8 +121,12 @@ _memberRouter.post('/bots', bots) _memberRouter.post('/bots/create', createBot) _memberRouter.post('/bots/activate/:bid', activateBot) _memberRouter.post('/category', category) +_memberRouter.post('/migrate/bot/:bid', migrateBot) +_memberRouter.post('/migrate/chat/:tid', migrateChat) _memberRouter.post('/mode', interfaceMode) _memberRouter.post('/passphrase', passphraseReset) +_memberRouter.post('/retire/bot/:bid', retireBot) +_memberRouter.post('/retire/chat/:tid', retireChat) _memberRouter.post('/summarize', summarize) _memberRouter.post('/teams/:tid', team) _memberRouter.post('/upload', upload) @@ -141,10 +151,12 @@ function connectRoutes(_Menu){ * @param {function} next Koa next function * @returns {function} Koa next function */ -async function memberValidation(ctx, next) { - if(ctx.state?.locked ?? true) +async function memberValidation(ctx, next){ + const { locked=true, } = ctx.state + if(locked) ctx.redirect(`/?type=select`) // Redirect to /members if not authorized - await next() // Proceed to the next middleware if authorized + else + await next() // Proceed to the next middleware if authorized } /** * Returns the member session logged in status diff --git a/inc/yaml/README.md b/inc/yaml/README.md index 64dd9b0..eb3c57b 100644 --- a/inc/yaml/README.md +++ b/inc/yaml/README.md @@ -25,3 +25,7 @@ The folder structure is as follows: ## Versioning Currently schemas are using `openapi v.3.0.0`, and each `.yaml` will be individually versioned, actual changes maintained in git repository. + +## References and Links + +- [OpenAI specification page](https://spec.openapis.org/oas/v3.1.0#server-object-example) with a direct anchor to multiple server listing \ No newline at end of file diff --git a/inc/yaml/mylife_biographer-bot_openai.yaml b/inc/yaml/mylife_biographer-bot_openai.yaml index 9a5bf43..5044ae2 100644 --- a/inc/yaml/mylife_biographer-bot_openai.yaml +++ b/inc/yaml/mylife_biographer-bot_openai.yaml @@ -1,12 +1,16 @@ -openapi: 3.0.0 +openapi: 3.1.0 info: title: MyLife Biographer Bot API - description: | - This API is for receiving webhooks from [MyLife's public Biographer Bot instance](https://chat.openai.com/g/g-QGzfgKj6I-mylife-biographer-bot). + description: > + This API is for receiving webhooks from [MyLife's public Biographer Bot + instance](https://chat.openai.com/g/g-QGzfgKj6I-mylife-biographer-bot). ## Updates + - updated functions after failure to connect - added: keywords, phaseOfLife, relationships to bot package - version: 1.0.1 + version: 1.0.2 servers: + - url: https://mylife.ngrok.app/api/v1 + description: Local development endpoint using ngrok for testing the MyLife Biographer Bot GPT. - url: https://humanremembranceproject.org/api/v1 description: Endpoint for receiving stories from the MyLife Biographer Bot instance. security: @@ -16,13 +20,14 @@ paths: post: x-openai-isConsequential: false operationId: MyLifeKeyValidation - summary: MyLife Biographer Bot will access this endpoint to validate a `memberKey` in MyLife Cosmos. + summary: MyLife Biographer Bot will access this endpoint to validate a `memberKey` in MyLife. description: Endpoint for handling incoming registration webhook data from the MyLife GPT service. parameters: - name: mid in: path required: true description: The `memberKey` data to be sent by MyLife Biographer Bot. Visitor enters memberKey and it is kept in GPT memory and sent with each request so that MyLife knows partition. + example: memberHandle|ae3a090d-1089-4110-8575-eecd119f9d8e schema: maxLength: 256 minLength: 40 @@ -63,17 +68,18 @@ paths: type: string "400": description: Invalid member. Field `memberKey` is not valid, check again with member. - /story/{mid}: + /memory/{mid}: post: x-openai-isConsequential: false - operationId: MyLifeBiographerStoryCreation + operationId: MyLifeSaveMemory summary: MyLife Biographer Bot will access this endpoint to generate a `bio-story` document in MyLife Cosmos. description: Endpoint for handling incoming registration webhook data from the MyLife GPT service. parameters: - name: mid in: path required: true - description: The `memberKey` data to be sent by MyLife Biographer Bot. Visitor enters memberKey and it is kept in GPT memory and sent with each request so that MyLife knows partition. + description: The `memberKey` data to be sent by MyLife Biographer Bot. Visitor enters memberKey and it is kept in GPT memory and sent with each request so that MyLife knows partition. Is **not** just a guid/uuid. + example: memberHandle|ae3a090d-1089-4110-8575-eecd119f9d8e schema: maxLength: 256 minLength: 40 @@ -83,25 +89,30 @@ paths: content: application/json: schema: - description: The `story` data sent by MyLife Biographer Bot. + description: The `memory` data sent by MyLife Biographer Bot. type: object required: - keywords - - phaseOfLife - summary - title properties: keywords: - description: MyLife Biographer Bot interprets (or asks) keywords for this `story`. Should not include relations or names, but would tag content dynamically. Each should be a single word or short phrase. + description: MyLife Biographer Bot interprets (or asks) keywords for this `memory`. Should not include relations or names, but would tag content dynamically. Each should be a single word or short phrase. items: - description: A single word or short phrase to tag `story` content. + description: A single word or short phrase to tag `memory` content. maxLength: 64 type: string maxItems: 12 minItems: 3 type: array + mbr_id: + description: The `memberKey` given by member visitor. Is **not** just a guid/uuid. + exampple: memberHandle|ae3a090d-1089-4110-8575-eecd119f9d8e + maxLength: 256 + minLength: 40 + type: string phaseOfLife: - description: MyLife Biographer Bot interprets (or asks) phase of life for this `story`. Can be `unkown`. + description: MyLife Biographer Bot interprets (or asks) phase of life for this `memory`. enum: - birth - childhood @@ -113,50 +124,52 @@ paths: - senior - end-of-life - past-life - - unknown - other type: string relationships: - description: MyLife Biographer Bot does its best to record individuals (or pets) mentioned in this `story`. + description: MyLife Biographer Bot does its best to record individuals (or pets) mentioned in this `memory`. items: - description: A name of relational individual/pet to the `story` content. + description: A name of relational individual/pet to the `memory` content. type: string maxItems: 24 type: array summary: - description: MyLife Biographer Bot summary of identified `story`. + description: MyLife Biographer Bot summary of identified `memory`. maxLength: 20480 type: string title: - description: MyLife Biographer Bot determines title for this `story`. + description: MyLife Biographer Bot determines title for this `memory`. maxLength: 256 type: string -responses: - "200": - description: Story submitted successfully. - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - const: true - example: true - message: - type: string - example: Story submitted successfully. - "400": - description: No story summary provided. Use `summary` field. - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - const: false - example: false - message: - type: string - example: No story summary provided. Use `summary` field. \ No newline at end of file + responses: + "200": + description: Story submitted successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + format: uuid + message: + type: string + example: Story submitted successfully. + success: + type: boolean + const: true + example: true + "401": + description: No story summary provided. Use `summary` field. + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + const: false + example: false + message: + type: string + example: No story summary provided. Use `summary` field. diff --git a/inc/yaml/mylife_diary-bot_openai.yaml b/inc/yaml/mylife_diary-bot_openai.yaml new file mode 100644 index 0000000..3c16598 --- /dev/null +++ b/inc/yaml/mylife_diary-bot_openai.yaml @@ -0,0 +1,162 @@ +openapi: 3.1.0 +info: + title: MyLife Diary Bot API + description: This API is for receiving webhooks from [MyLife's public Diary Bot instance](https://chatgpt.com/g/g-rSCnLVUKd-mylife-diary-bot-prototype). All diary submissions are referred to as `entry` within this API. + version: 1.0.0 +servers: + - url: https://mylife.ngrok.app/api/v1 + description: Local development endpoint using ngrok for testing the MyLife Diary Bot GPT. + - url: https://humanremembranceproject.org/api/v1 + description: Production endpoint for receiving entries from the MyLife Diary Bot instance. +security: + - bearerAuth: [] +paths: + /keyValidation/{mid}: + post: + x-openai-isConsequential: false + operationId: MyLifeKeyValidation + summary: MyLife Diary Bot will access this endpoint to validate a `memberKey` in MyLife. + description: Endpoint for handling incoming registration webhook data from the MyLife GPT service. + parameters: + - name: mid + in: path + required: true + description: The `memberKey` data to be sent by MyLife Diary Bot. Visitor enters memberKey and it is kept in GPT memory and sent with each request so that MyLife knows partition. + example: memberHandle|ae3a090d-1089-4110-8575-eecd119f9d8e + schema: + maxLength: 256 + minLength: 40 + type: string + responses: + "200": + description: A successful response indicating the member key is valid + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: Valid member. + data: + type: object + properties: + mbr_id: + maxLength: 256 + minLength: 40 + type: string + updates: + type: string + interests: + type: string + birthDate: + type: string + format: date-time + birthPlace: + type: string + fullName: + type: string + preferredName: + type: string + "400": + description: Invalid member. Field `memberKey` is not valid, check again with member. + /entry/{mid}: + post: + x-openai-isConsequential: false + operationId: MyLifeSaveEntry + summary: MyLife Diary Bot will access this endpoint to log an `entry` document in MyLife. + description: Endpoint for handling incoming entry data from the MyLife GPT service. Each entry represents a diary submission from the user, and the bot handles tagging and metadata generation. + parameters: + - name: mid + in: path + required: true + description: The `memberKey` data to be sent by MyLife Biographer Bot. Visitor enters memberKey and it is kept in GPT memory and sent with each request so that MyLife knows partition. Is **not** just a guid/uuid. + example: memberHandle|ae3a090d-1089-4110-8575-eecd119f9d8e + schema: + maxLength: 256 + minLength: 40 + type: string + requestBody: + required: true + content: + application/json: + schema: + description: The entry data sent by MyLife Diary Bot. + type: object + required: + - content + - form + - keywords + - mood + - summary + - title + properties: + content: + description: The raw content of the `entry` as submitted by the user, concatenated from multiple exchanges when appropriate. + maxLength: 20480 + type: string + form: + const: diary + description: Constant form of "diary" + type: string + keywords: + description: MyLife Diary Bot interprets and infers keywords relevant to this `entry`. Should NOT include names of individuals (there is a property for this). + items: + description: A single word or short phrase to tag `entry` content. + maxLength: 64 + type: string + maxItems: 12 + minItems: 3 + type: array + mood: + description: The mood of the `entry` as interpreted by the Diary Bot. + example: happy + maxLength: 64 + type: string + relationships: + description: MyLife Diary Bot does its best to record individuals (or pets) mentioned in this `entry`. + items: + description: A name of a relational individual/pet relevant to the `entry` content. + type: string + maxItems: 24 + type: array + summary: + description: A detailed summary of the `entry` as interpreted by the Diary Bot. + maxLength: 20480 + type: string + title: + description: A distinct title for this `entry` as determined by the Diary Bot. + maxLength: 256 + type: string + responses: + "200": + description: Entry submitted successfully. + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + const: true + example: true + message: + type: string + example: Entry submitted successfully. + "400": + description: No entry summary provided. Use the `summary` field. + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + const: false + example: false + message: + type: string + example: No entry summary provided. Use the `summary` field. diff --git a/server.js b/server.js index 12020e4..c768276 100644 --- a/server.js +++ b/server.js @@ -1,23 +1,21 @@ -// *imports +/** imports **/ import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' -// server +/* server imports */ import Koa from 'koa' import { koaBody } from 'koa-body' import render from 'koa-ejs' import session from 'koa-generic-session' import serve from 'koa-static' -// import Router from 'koa-router' -// misc +/* misc imports */ import chalk from 'chalk' -// local services +/* local service imports */ import MyLife from './inc/js/mylife-agent-factory.mjs' -// constants/variables -// @todo - parse environment variables in Globals and then have them available via as values -const version = '0.0.20' +/** variables **/ +const version = '0.0.21' const app = new Koa() -const port = JSON.parse(process.env.PORT ?? '3000') +const port = process.env.PORT ?? '3000' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const _Maht = await MyLife // Mylife is the pre-instantiated exported version of organization with very unique properties. MyLife class can protect fields that others cannot, #factory as first refactor will request @@ -27,7 +25,7 @@ _Maht.version = version console.log(chalk.bgBlue('created-core-entity:'), _Maht.version) const MemoryStore = new session.MemoryStore() const mimeTypesToExtensions = { - // Text Formats + /* text formats */ 'text/plain': ['.txt', '.markdown', '.md', '.csv', '.log',], // Including Markdown (.md) as plain text 'text/html': ['.html', '.htm'], 'text/css': ['.css'], @@ -36,7 +34,7 @@ const mimeTypesToExtensions = { 'application/json': ['.json'], 'application/javascript': ['.js'], 'application/xml': ['.xml'], - // Image Formats + /* image formats */ 'image/jpeg': ['.jpg', '.jpeg'], 'image/png': ['.png'], 'image/gif': ['.gif'], @@ -45,7 +43,7 @@ const mimeTypesToExtensions = { 'image/tiff': ['.tiff', '.tif'], 'image/bmp': ['.bmp'], 'image/x-icon': ['.ico'], - // Document Formats + /* document formats */ 'application/pdf': ['.pdf'], 'application/msword': ['.doc'], 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], @@ -57,14 +55,14 @@ const mimeTypesToExtensions = { 'application/vnd.oasis.opendocument.text': ['.odt'], 'application/vnd.oasis.opendocument.spreadsheet': ['.ods'], 'application/vnd.oasis.opendocument.presentation': ['.odp'], - // Audio Formats + /* audio formats */ 'audio/mpeg': ['.mp3'], 'audio/vorbis': ['.ogg'], // Commonly .ogg can also be used for video 'audio/x-wav': ['.wav'], 'audio/webm': ['.weba'], 'audio/aac': ['.aac'], 'audio/flac': ['.flac'], - // Video Formats + /* video formats */ 'video/mp4': ['.mp4'], 'video/x-msvideo': ['.avi'], 'video/x-ms-wmv': ['.wmv'], @@ -73,12 +71,11 @@ const mimeTypesToExtensions = { 'video/ogg': ['.ogv'], 'video/x-flv': ['.flv'], 'video/quicktime': ['.mov'], - // Add more MIME categories, types, and extensions as needed } const serverRouter = await _Maht.router console.log(chalk.bgBlue('created-core-entity:', chalk.bgRedBright('MAHT'))) -// test harness region -// koa-ejs +/** RESERVED: test harness **/ +/** application startup **/ render(app, { root: path.join(__dirname, 'views'), layout: 'layout', @@ -86,20 +83,16 @@ render(app, { cache: false, debug: false, }) -// Set an interval to check for alerts every minute (60000 milliseconds) setInterval(checkForLiveAlerts, JSON.parse(process.env.MYLIFE_SYSTEM_ALERT_CHECK_INTERVAL ?? '60000')) -// app bootup /* upload directory */ const uploadDir = path.join(__dirname, '.tmp') if(!fs.existsSync(uploadDir)){ fs.mkdirSync(uploadDir, { recursive: true }) } -// app context (ctx) modification app.context.MyLife = _Maht app.context.Globals = _Maht.globals app.context.menu = _Maht.menu app.keys = [process.env.MYLIFE_SESSION_KEY ?? `mylife-session-failsafe|${_Maht.newGuid()}`] -// Enable Koa body w/ configuration app.use(koaBody({ multipart: true, formidable: { @@ -184,8 +177,8 @@ app.use(koaBody({ .listen(port, () => { // start the server console.log(chalk.bgGreenBright('server available')+chalk.yellow(`\nlistening on port ${port}`)) }) -// Example of a periodic alert check function +/** server functions **/ function checkForLiveAlerts() { console.log("Checking for live alerts...") _Maht.getAlerts() -} +} \ No newline at end of file